Skip to content

Commit

Permalink
Merge pull request #504 from Lemoncode/fixaccessibilitybug/custom-select
Browse files Browse the repository at this point in the history
Fixaccessibilitybug/custom select
  • Loading branch information
brauliodiez authored Jun 18, 2024
2 parents 87b9668 + 31d0911 commit 5a7c37e
Show file tree
Hide file tree
Showing 45 changed files with 1,116 additions and 784 deletions.
19 changes: 19 additions & 0 deletions src/common/a11y/click-outside.hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react';

export const useClickOutside = (
isOpen: boolean,
ref: React.RefObject<HTMLElement>,
callback: (e: MouseEvent) => void
) => {
const handleClickOutside = (e: MouseEvent) => {
callback(e);
};

React.useEffect(() => {
ref.current?.addEventListener('click', handleClickOutside);

return () => {
ref.current?.removeEventListener('click', handleClickOutside);
};
}, [isOpen]);
};
15 changes: 15 additions & 0 deletions src/common/a11y/common.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export type BaseA11yOption<Option> = Option & {
tabIndex: number;
};

export type NestedOption<Option> = {
id: string;
children?: Option[];
};

export type FlatOption<Option extends NestedOption<Option>> = Omit<
Option,
'children'
> & {
parentId?: string;
};
13 changes: 13 additions & 0 deletions src/common/a11y/focus.common-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export const getArrowUpIndex = (currentIndex: number) => {
const isFirstOption = currentIndex === 0;
return isFirstOption ? currentIndex : currentIndex - 1;
};

export const getArrowDownIndex = (currentIndex: number, options: any[]) => {
const isLastOption = currentIndex === options.length - 1;
return isLastOption ? currentIndex : currentIndex + 1;
};

export const getFocusedOption = <FocusableOption extends { tabIndex: number }>(
options: FocusableOption[]
) => options.find(option => option.tabIndex === 0);
6 changes: 6 additions & 0 deletions src/common/a11y/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export * from './select';
export * from './nested-select';
export * from './on-key.hook';
export * from './focus.common-helpers';
export * from './nested-list';
export * from './common.model';
26 changes: 26 additions & 0 deletions src/common/a11y/list/focus.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { BaseA11yOption } from '../common.model';

export const setInitialFocus = <
Option,
A11yOption extends BaseA11yOption<Option>,
>(
options: Option[]
): A11yOption[] => {
const a11ySelectionOptions = options.map<A11yOption>(
(option, index) =>
({
...option,
tabIndex: index === 0 ? 0 : -1,
}) as unknown as A11yOption
);

return a11ySelectionOptions;
};

export const onFocusOption =
<Option>(option: BaseA11yOption<Option>) =>
(element: any) => {
if (option.tabIndex === 0) {
element?.focus();
}
};
2 changes: 2 additions & 0 deletions src/common/a11y/list/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './list.hooks';
export * from './list.model';
95 changes: 95 additions & 0 deletions src/common/a11y/list/list.hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React from 'react';
import { BaseA11yOption } from '../common.model';
import { getArrowDownIndex, getArrowUpIndex } from '../focus.common-helpers';
import { useOnKey } from '../on-key.hook';
import { onFocusOption, setInitialFocus } from './focus.helpers';
import { SetInitialFocusFn } from './list.model';
import { useOnTwoKeys } from '../on-two-Keys.hook';

export const useA11yList = <Option, A11yOption extends BaseA11yOption<Option>>(
options: Option[],
onSetInitialFocus: SetInitialFocusFn<Option, A11yOption> = setInitialFocus
) => {
const optionListRef = React.useRef<any>(null);
const [internalOptions, setInternalOptions] = React.useState<A11yOption[]>(
onSetInitialFocus(options)
);

const handleFocus = (event: KeyboardEvent) => {
const currentIndex = internalOptions.findIndex(
option => option.tabIndex === 0
);
const nextIndex =
event.key === 'ArrowUp'
? getArrowUpIndex(currentIndex)
: getArrowDownIndex(currentIndex, internalOptions);

if (currentIndex !== nextIndex) {
setInternalOptions(prevOptions =>
prevOptions.map((option, index) => {
switch (index) {
case currentIndex:
return {
...option,
tabIndex: -1,
};
case nextIndex:
return {
...option,
tabIndex: 0,
};
default:
return option;
}
})
);
}
};

const handleFirstAndLast = (value: number) => {
setInternalOptions(prevOptions =>
prevOptions.map((option, index) => {
switch (index) {
case value:
return {
...option,
tabIndex: 0,
};
default:
return {
...option,
tabIndex: -1,
};
}
})
);
};

//Need this for Mac users
useOnTwoKeys(
optionListRef,
['ArrowUp', 'ArrowDown'],
'Meta',
(event: KeyboardEvent) =>
event.key === 'ArrowUp'
? handleFirstAndLast(0)
: handleFirstAndLast(internalOptions.length - 1)
);

useOnKey(optionListRef, ['ArrowDown', 'ArrowUp'], (event: KeyboardEvent) => {
handleFocus(event);
});

useOnKey(optionListRef, ['Home', 'End'], (event: KeyboardEvent) =>
event.key === 'Home'
? handleFirstAndLast(0)
: handleFirstAndLast(internalOptions.length - 1)
);

return {
optionListRef,
options: internalOptions,
setOptions: setInternalOptions,
onFocusOption,
};
};
3 changes: 3 additions & 0 deletions src/common/a11y/list/list.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type SetInitialFocusFn<Option, A11yOption> = (
options: Option[]
) => A11yOption[];
2 changes: 2 additions & 0 deletions src/common/a11y/nested-list/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './nested-list.hooks';
export * from './nested-list.model';
29 changes: 29 additions & 0 deletions src/common/a11y/nested-list/nested-list.hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
mapFlatOptionsToNestedListOptions,
mapNestedListOptionsToFlatOptions,
} from './nested-list.mappers';
import { NestedOption } from '../common.model';
import { useA11yNested } from '../nested.hooks';
import { useA11yList } from '../list';

export const useA11yNestedList = <Option extends NestedOption<Option>>(
options: Option[]
) => {
const flatOptions = mapNestedListOptionsToFlatOptions(options);

const {
optionListRef,
options: internalOptions,
setOptions,
onFocusOption,
} = useA11yList(flatOptions);

useA11yNested(optionListRef, internalOptions, setOptions);

return {
optionListRef,
options: mapFlatOptionsToNestedListOptions(internalOptions),
setOptions,
onFocusOption,
};
};
53 changes: 53 additions & 0 deletions src/common/a11y/nested-list/nested-list.mappers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { FlatOption, NestedOption } from '../common.model';
import { A11yNestedListOption } from './nested-list.model';

export const mapNestedListOptionsToFlatOptions = <
Option extends NestedOption<Option>,
>(
options: Option[],
parentId?: string
): FlatOption<Option>[] => {
return options.reduce<FlatOption<Option>[]>((acc, o) => {
const { children, ...option } = o;
const flatOption: FlatOption<Option> = {
...option,
parentId,
};
return [
...acc,
flatOption,
...(children
? mapNestedListOptionsToFlatOptions(children, option.id)
: []),
];
}, []);
};

export const mapFlatOptionsToNestedListOptions = <
Option extends NestedOption<Option>,
>(
flatOptions: A11yNestedListOption<FlatOption<Option>>[]
): A11yNestedListOption<Option>[] => {
const map = new Map<string, any>();
flatOptions.forEach(flatOption => {
const { parentId, tabIndex, id, ...option } = flatOption;
map.set(id, { ...option, id, tabIndex, children: undefined });
});

const rootIds = new Set(map.keys());

flatOptions.forEach(flatOption => {
const { parentId, id } = flatOption;
const parent = map.get(parentId!);
const child = map.get(id);
if (parent && child) {
if (parent.children === undefined) {
parent.children = [];
}
parent.children.push(child);
rootIds.delete(id);
}
});

return Array.from(rootIds).map(id => map.get(id));
};
9 changes: 9 additions & 0 deletions src/common/a11y/nested-list/nested-list.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { NestedOption } from '../common.model';

export type A11yNestedListOption<Option extends NestedOption<Option>> = Omit<
Option,
'children'
> & {
tabIndex: number;
children?: A11yNestedListOption<Option>[];
};
3 changes: 3 additions & 0 deletions src/common/a11y/nested-select/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './nested-select.hooks';
export * from './nested-select.model';
export * from './nested-select.mappers';
46 changes: 46 additions & 0 deletions src/common/a11y/nested-select/nested-select.hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useA11ySelect } from '../select';
import { useA11yNested } from '../nested.hooks';
import { NestedOption, FlatOption } from '../common.model';
import {
mapNestedSelectOptionsToFlatOptions,
mapFlatOptionsToNestedSelectOptions,
} from './nested-select.mappers';

export const useA11yNestedSelect = <Option extends NestedOption<Option>>(
options: Option[],
getOptionId: <Key extends keyof FlatOption<Option>>(
option: FlatOption<Option>
) => FlatOption<Option>[Key],
initialOption?: Option,
onChangeOption?: (option: FlatOption<Option> | undefined) => void
) => {
const flatOptions = mapNestedSelectOptionsToFlatOptions(options);

const {
optionListRef,
buttonRef,
veilRef,
isOpen,
setIsOpen,
options: internalOptions,
setOptions,
selectedOption,
setSelectedOption,
onFocusOption,
} = useA11ySelect(flatOptions, getOptionId, initialOption, onChangeOption);

useA11yNested(optionListRef, internalOptions, setOptions);

return {
optionListRef,
buttonRef,
veilRef,
options: mapFlatOptionsToNestedSelectOptions(internalOptions),
setOptions,
isOpen,
setIsOpen,
selectedOption,
setSelectedOption,
onFocusOption,
};
};
55 changes: 55 additions & 0 deletions src/common/a11y/nested-select/nested-select.mappers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { NestedOption, FlatOption } from '../common.model';
import { A11yNestedSelectOption } from './nested-select.model';
import { A11ySelectOption } from '../select';

export const mapNestedSelectOptionsToFlatOptions = <
Option extends NestedOption<Option>,
>(
options: Option[],
parentId?: string
): FlatOption<Option>[] => {
return options.reduce<FlatOption<Option>[]>((acc, o) => {
const { children, ...option } = o;
const flatOption: FlatOption<Option> = {
...option,
parentId,
isSelectable: !Array.isArray(children) || children.length === 0,
};
return [
...acc,
flatOption,
...(children
? mapNestedSelectOptionsToFlatOptions(children, option.id)
: []),
];
}, []);
};

export const mapFlatOptionsToNestedSelectOptions = <
Option extends NestedOption<Option>,
>(
flatOptions: A11ySelectOption<FlatOption<Option>>[]
): A11yNestedSelectOption<Option>[] => {
const map = new Map<string, any>();
flatOptions.forEach(flatOption => {
const { parentId, tabIndex, id, isSelectable, ...option } = flatOption;
map.set(id, { ...option, id, tabIndex, isSelectable, children: undefined });
});

const rootIds = new Set(map.keys());

flatOptions.forEach(flatOption => {
const { parentId, id } = flatOption;
const parent = map.get(parentId!);
const child = map.get(id);
if (parent && child) {
if (parent.children === undefined) {
parent.children = [];
}
parent.children.push(child);
rootIds.delete(id);
}
});

return Array.from(rootIds).map(id => map.get(id));
};
Loading

0 comments on commit 5a7c37e

Please sign in to comment.