@chromvoid/headless-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/a11y-contracts/index.d.ts +23 -0
- package/dist/a11y-contracts/index.js +1 -0
- package/dist/accordion/index.d.ts +78 -0
- package/dist/accordion/index.js +264 -0
- package/dist/adapters/index.d.ts +9 -0
- package/dist/adapters/index.js +1 -0
- package/dist/alert/index.d.ts +33 -0
- package/dist/alert/index.js +54 -0
- package/dist/alert-dialog/index.d.ts +69 -0
- package/dist/alert-dialog/index.js +94 -0
- package/dist/badge/index.d.ts +48 -0
- package/dist/badge/index.js +89 -0
- package/dist/breadcrumb/index.d.ts +55 -0
- package/dist/breadcrumb/index.js +77 -0
- package/dist/button/index.d.ts +46 -0
- package/dist/button/index.js +86 -0
- package/dist/callout/index.d.ts +41 -0
- package/dist/callout/index.js +63 -0
- package/dist/card/index.d.ts +54 -0
- package/dist/card/index.js +103 -0
- package/dist/carousel/index.d.ts +98 -0
- package/dist/carousel/index.js +243 -0
- package/dist/checkbox/index.d.ts +50 -0
- package/dist/checkbox/index.js +87 -0
- package/dist/combobox/index.d.ts +114 -0
- package/dist/combobox/index.js +431 -0
- package/dist/command-palette/index.d.ts +73 -0
- package/dist/command-palette/index.js +147 -0
- package/dist/context-menu/index.d.ts +111 -0
- package/dist/context-menu/index.js +372 -0
- package/dist/copy-button/index.d.ts +62 -0
- package/dist/copy-button/index.js +183 -0
- package/dist/core/index.d.ts +20 -0
- package/dist/core/index.js +2 -0
- package/dist/core/selection.d.ts +5 -0
- package/dist/core/selection.js +39 -0
- package/dist/core/value-range.d.ts +49 -0
- package/dist/core/value-range.js +134 -0
- package/dist/date-picker/index.d.ts +210 -0
- package/dist/date-picker/index.js +895 -0
- package/dist/dialog/index.d.ts +95 -0
- package/dist/dialog/index.js +153 -0
- package/dist/disclosure/index.d.ts +52 -0
- package/dist/disclosure/index.js +159 -0
- package/dist/drawer/index.d.ts +30 -0
- package/dist/drawer/index.js +39 -0
- package/dist/feed/index.d.ts +77 -0
- package/dist/feed/index.js +260 -0
- package/dist/grid/index.d.ts +103 -0
- package/dist/grid/index.js +415 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +51 -0
- package/dist/input/index.d.ts +86 -0
- package/dist/input/index.js +156 -0
- package/dist/interactions/composite-navigation.d.ts +69 -0
- package/dist/interactions/composite-navigation.js +169 -0
- package/dist/interactions/index.d.ts +15 -0
- package/dist/interactions/index.js +4 -0
- package/dist/interactions/keyboard-intents.d.ts +16 -0
- package/dist/interactions/keyboard-intents.js +33 -0
- package/dist/interactions/overlay-focus.d.ts +40 -0
- package/dist/interactions/overlay-focus.js +93 -0
- package/dist/interactions/typeahead.d.ts +20 -0
- package/dist/interactions/typeahead.js +41 -0
- package/dist/landmarks/index.d.ts +39 -0
- package/dist/landmarks/index.js +58 -0
- package/dist/link/index.d.ts +34 -0
- package/dist/link/index.js +39 -0
- package/dist/listbox/index.d.ts +92 -0
- package/dist/listbox/index.js +337 -0
- package/dist/menu/index.d.ts +132 -0
- package/dist/menu/index.js +541 -0
- package/dist/menu-button/index.d.ts +71 -0
- package/dist/menu-button/index.js +121 -0
- package/dist/meter/index.d.ts +45 -0
- package/dist/meter/index.js +106 -0
- package/dist/number/index.d.ts +113 -0
- package/dist/number/index.js +252 -0
- package/dist/popover/index.d.ts +70 -0
- package/dist/popover/index.js +126 -0
- package/dist/progress/index.d.ts +49 -0
- package/dist/progress/index.js +79 -0
- package/dist/radio-group/index.d.ts +61 -0
- package/dist/radio-group/index.js +150 -0
- package/dist/select/index.d.ts +92 -0
- package/dist/select/index.js +239 -0
- package/dist/sidebar/index.d.ts +74 -0
- package/dist/sidebar/index.js +186 -0
- package/dist/slider/index.d.ts +61 -0
- package/dist/slider/index.js +150 -0
- package/dist/slider-multi-thumb/index.d.ts +70 -0
- package/dist/slider-multi-thumb/index.js +222 -0
- package/dist/spinbutton/index.d.ts +75 -0
- package/dist/spinbutton/index.js +214 -0
- package/dist/spinner/index.d.ts +1 -0
- package/dist/spinner/index.js +1 -0
- package/dist/spinner/spinner.d.ts +23 -0
- package/dist/spinner/spinner.js +25 -0
- package/dist/switch/index.d.ts +40 -0
- package/dist/switch/index.js +61 -0
- package/dist/table/index.d.ts +117 -0
- package/dist/table/index.js +377 -0
- package/dist/tabs/index.d.ts +63 -0
- package/dist/tabs/index.js +174 -0
- package/dist/textarea/index.d.ts +68 -0
- package/dist/textarea/index.js +137 -0
- package/dist/toast/index.d.ts +67 -0
- package/dist/toast/index.js +145 -0
- package/dist/toolbar/index.d.ts +59 -0
- package/dist/toolbar/index.js +139 -0
- package/dist/tooltip/index.d.ts +52 -0
- package/dist/tooltip/index.js +169 -0
- package/dist/treegrid/index.d.ts +101 -0
- package/dist/treegrid/index.js +463 -0
- package/dist/treeview/index.d.ts +68 -0
- package/dist/treeview/index.js +370 -0
- package/dist/window-splitter/index.d.ts +65 -0
- package/dist/window-splitter/index.js +204 -0
- package/package.json +92 -0
- package/specs/ADR-001-headless-architecture.md +461 -0
- package/specs/ADR-002-repo-release-model.md +108 -0
- package/specs/ADR-003-public-api-versioning.md +136 -0
- package/specs/ADR-004-focus-selection-policy.md +117 -0
- package/specs/IMPLEMENTATION-ROADMAP.md +237 -0
- package/specs/ISSUE-BACKLOG.md +681 -0
- package/specs/RELEASE-CANDIDATE.md +30 -0
- package/specs/components/accordion.md +130 -0
- package/specs/components/alert-dialog.md +72 -0
- package/specs/components/alert.md +65 -0
- package/specs/components/badge.md +220 -0
- package/specs/components/breadcrumb.md +74 -0
- package/specs/components/button.md +115 -0
- package/specs/components/callout.md +195 -0
- package/specs/components/card.md +280 -0
- package/specs/components/carousel.md +140 -0
- package/specs/components/checkbox.md +172 -0
- package/specs/components/combobox.md +423 -0
- package/specs/components/command-palette.md +92 -0
- package/specs/components/context-menu.md +556 -0
- package/specs/components/copy-button.md +293 -0
- package/specs/components/date-picker.md +400 -0
- package/specs/components/dialog.md +298 -0
- package/specs/components/disclosure.md +257 -0
- package/specs/components/drawer.md +353 -0
- package/specs/components/feed.md +265 -0
- package/specs/components/grid.md +186 -0
- package/specs/components/input.md +254 -0
- package/specs/components/landmarks.md +136 -0
- package/specs/components/link.md +134 -0
- package/specs/components/listbox.md +351 -0
- package/specs/components/menu-button.md +76 -0
- package/specs/components/menu.md +623 -0
- package/specs/components/meter.md +149 -0
- package/specs/components/number.md +393 -0
- package/specs/components/popover.md +252 -0
- package/specs/components/progress.md +188 -0
- package/specs/components/radio-group.md +151 -0
- package/specs/components/select.md +144 -0
- package/specs/components/sidebar.md +321 -0
- package/specs/components/slider-multi-thumb.md +78 -0
- package/specs/components/slider.md +84 -0
- package/specs/components/spinbutton.md +140 -0
- package/specs/components/spinner.md +132 -0
- package/specs/components/switch.md +175 -0
- package/specs/components/table.md +403 -0
- package/specs/components/tabs.md +265 -0
- package/specs/components/textarea.md +185 -0
- package/specs/components/toast.md +198 -0
- package/specs/components/toolbar.md +278 -0
- package/specs/components/tooltip.md +252 -0
- package/specs/components/treegrid.md +281 -0
- package/specs/components/treeview.md +91 -0
- package/specs/components/window-splitter.md +297 -0
- package/specs/ops/git-shard-sync.md +107 -0
- package/specs/ops/release-checklist.md +76 -0
- package/specs/release/GAP-TO-GREEN-ISSUES.md +88 -0
- package/specs/release/api-freeze-candidate.md +54 -0
- package/specs/release/changelog-automation.md +76 -0
- package/specs/release/changelog.generated.md +53 -0
- package/specs/release/changelog.patch.generated.md +46 -0
- package/specs/release/consumer-integration.md +53 -0
- package/specs/release/migration-notes-pre-v1.md +40 -0
- package/specs/release/mvp-changelog.md +57 -0
- package/specs/release/release-notes-template.md +61 -0
- package/specs/release/release-rehearsal.md +113 -0
- package/specs/release/semver-deprecation-dry-run.md +89 -0
- package/specs/release/shard-release-drill-report.md +50 -0
- package/specs/release/shard-release-follow-ups.md +31 -0
- package/specs/signals.md +208 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { action, atom } from '@reatom/core';
|
|
2
|
+
export function createInput(options = {}) {
|
|
3
|
+
const idBase = options.idBase ?? 'input';
|
|
4
|
+
const valueAtom = atom(options.value ?? '', `${idBase}.value`);
|
|
5
|
+
const typeAtom = atom(options.type ?? 'text', `${idBase}.type`);
|
|
6
|
+
const disabledAtom = atom(options.disabled ?? false, `${idBase}.disabled`);
|
|
7
|
+
const readonlyAtom = atom(options.readonly ?? false, `${idBase}.readonly`);
|
|
8
|
+
const requiredAtom = atom(options.required ?? false, `${idBase}.required`);
|
|
9
|
+
const placeholderAtom = atom(options.placeholder ?? '', `${idBase}.placeholder`);
|
|
10
|
+
const clearableAtom = atom(options.clearable ?? false, `${idBase}.clearable`);
|
|
11
|
+
const passwordToggleAtom = atom(options.passwordToggle ?? false, `${idBase}.passwordToggle`);
|
|
12
|
+
const passwordVisibleAtom = atom(false, `${idBase}.passwordVisible`);
|
|
13
|
+
const focusedAtom = atom(false, `${idBase}.focused`);
|
|
14
|
+
const filled = () => valueAtom().length > 0;
|
|
15
|
+
const resolvedType = () => {
|
|
16
|
+
const t = typeAtom();
|
|
17
|
+
const visible = passwordVisibleAtom();
|
|
18
|
+
return t === 'password' && visible ? 'text' : t;
|
|
19
|
+
};
|
|
20
|
+
const showClearButton = () => clearableAtom() && filled() && !disabledAtom() && !readonlyAtom();
|
|
21
|
+
const showPasswordToggle = () => typeAtom() === 'password' && passwordToggleAtom();
|
|
22
|
+
const setValue = action((value) => {
|
|
23
|
+
valueAtom.set(value);
|
|
24
|
+
options.onInput?.(value);
|
|
25
|
+
}, `${idBase}.setValue`);
|
|
26
|
+
const setType = action((type) => {
|
|
27
|
+
typeAtom.set(type);
|
|
28
|
+
passwordVisibleAtom.set(false);
|
|
29
|
+
}, `${idBase}.setType`);
|
|
30
|
+
const setDisabled = action((disabled) => {
|
|
31
|
+
disabledAtom.set(disabled);
|
|
32
|
+
}, `${idBase}.setDisabled`);
|
|
33
|
+
const setReadonly = action((readonly) => {
|
|
34
|
+
readonlyAtom.set(readonly);
|
|
35
|
+
}, `${idBase}.setReadonly`);
|
|
36
|
+
const setRequired = action((required) => {
|
|
37
|
+
requiredAtom.set(required);
|
|
38
|
+
}, `${idBase}.setRequired`);
|
|
39
|
+
const setPlaceholder = action((placeholder) => {
|
|
40
|
+
placeholderAtom.set(placeholder);
|
|
41
|
+
}, `${idBase}.setPlaceholder`);
|
|
42
|
+
const setClearable = action((clearable) => {
|
|
43
|
+
clearableAtom.set(clearable);
|
|
44
|
+
}, `${idBase}.setClearable`);
|
|
45
|
+
const setPasswordToggle = action((toggle) => {
|
|
46
|
+
passwordToggleAtom.set(toggle);
|
|
47
|
+
if (!toggle) {
|
|
48
|
+
passwordVisibleAtom.set(false);
|
|
49
|
+
}
|
|
50
|
+
}, `${idBase}.setPasswordToggle`);
|
|
51
|
+
const togglePasswordVisibility = action(() => {
|
|
52
|
+
if (typeAtom() !== 'password' || !passwordToggleAtom())
|
|
53
|
+
return;
|
|
54
|
+
passwordVisibleAtom.set(!passwordVisibleAtom());
|
|
55
|
+
}, `${idBase}.togglePasswordVisibility`);
|
|
56
|
+
const setFocused = action((focused) => {
|
|
57
|
+
focusedAtom.set(focused);
|
|
58
|
+
}, `${idBase}.setFocused`);
|
|
59
|
+
const clear = action(() => {
|
|
60
|
+
if (disabledAtom() || readonlyAtom())
|
|
61
|
+
return;
|
|
62
|
+
valueAtom.set('');
|
|
63
|
+
options.onClear?.();
|
|
64
|
+
}, `${idBase}.clear`);
|
|
65
|
+
const handleInput = action((value) => {
|
|
66
|
+
if (disabledAtom() || readonlyAtom())
|
|
67
|
+
return;
|
|
68
|
+
setValue(value);
|
|
69
|
+
}, `${idBase}.handleInput`);
|
|
70
|
+
const handleKeyDown = action((event) => {
|
|
71
|
+
if (event.key === 'Escape') {
|
|
72
|
+
if (clearableAtom() && filled() && !disabledAtom() && !readonlyAtom()) {
|
|
73
|
+
clear();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}, `${idBase}.handleKeyDown`);
|
|
77
|
+
const contracts = {
|
|
78
|
+
getInputProps() {
|
|
79
|
+
const isDisabled = disabledAtom();
|
|
80
|
+
const isReadonly = readonlyAtom();
|
|
81
|
+
const isRequired = requiredAtom();
|
|
82
|
+
const currentPlaceholder = placeholderAtom();
|
|
83
|
+
const currentType = typeAtom();
|
|
84
|
+
return {
|
|
85
|
+
id: `${idBase}-input`,
|
|
86
|
+
type: resolvedType(),
|
|
87
|
+
'aria-disabled': isDisabled ? 'true' : undefined,
|
|
88
|
+
'aria-readonly': isReadonly ? 'true' : undefined,
|
|
89
|
+
'aria-required': isRequired ? 'true' : undefined,
|
|
90
|
+
placeholder: currentPlaceholder || undefined,
|
|
91
|
+
disabled: isDisabled || undefined,
|
|
92
|
+
readonly: isReadonly || undefined,
|
|
93
|
+
tabindex: isDisabled ? '-1' : '0',
|
|
94
|
+
autocomplete: currentType === 'password' ? 'off' : undefined,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
getClearButtonProps() {
|
|
98
|
+
const isVisible = showClearButton();
|
|
99
|
+
return {
|
|
100
|
+
role: 'button',
|
|
101
|
+
'aria-label': 'Clear input',
|
|
102
|
+
tabindex: '-1',
|
|
103
|
+
hidden: isVisible ? undefined : true,
|
|
104
|
+
'aria-hidden': isVisible ? undefined : 'true',
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
getPasswordToggleProps() {
|
|
108
|
+
const isVisible = showPasswordToggle();
|
|
109
|
+
const isPwdVisible = passwordVisibleAtom();
|
|
110
|
+
return {
|
|
111
|
+
role: 'button',
|
|
112
|
+
'aria-label': isPwdVisible ? 'Hide password' : 'Show password',
|
|
113
|
+
'aria-pressed': isPwdVisible ? 'true' : 'false',
|
|
114
|
+
tabindex: isVisible ? '0' : '-1',
|
|
115
|
+
hidden: isVisible ? undefined : true,
|
|
116
|
+
'aria-hidden': isVisible ? undefined : 'true',
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
const state = {
|
|
121
|
+
value: valueAtom,
|
|
122
|
+
type: typeAtom,
|
|
123
|
+
disabled: disabledAtom,
|
|
124
|
+
readonly: readonlyAtom,
|
|
125
|
+
required: requiredAtom,
|
|
126
|
+
placeholder: placeholderAtom,
|
|
127
|
+
clearable: clearableAtom,
|
|
128
|
+
passwordToggle: passwordToggleAtom,
|
|
129
|
+
passwordVisible: passwordVisibleAtom,
|
|
130
|
+
focused: focusedAtom,
|
|
131
|
+
filled,
|
|
132
|
+
resolvedType,
|
|
133
|
+
showClearButton,
|
|
134
|
+
showPasswordToggle,
|
|
135
|
+
};
|
|
136
|
+
const actions = {
|
|
137
|
+
setValue,
|
|
138
|
+
setType,
|
|
139
|
+
setDisabled,
|
|
140
|
+
setReadonly,
|
|
141
|
+
setRequired,
|
|
142
|
+
setPlaceholder,
|
|
143
|
+
setClearable,
|
|
144
|
+
setPasswordToggle,
|
|
145
|
+
togglePasswordVisibility,
|
|
146
|
+
setFocused,
|
|
147
|
+
clear,
|
|
148
|
+
handleInput,
|
|
149
|
+
handleKeyDown,
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
state,
|
|
153
|
+
actions,
|
|
154
|
+
contracts,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type Atom, type Computed } from '@reatom/core';
|
|
2
|
+
export type CompositeNavigationOrientation = 'horizontal' | 'vertical';
|
|
3
|
+
export type CompositeFocusStrategy = 'roving-tabindex' | 'aria-activedescendant';
|
|
4
|
+
export type CompositeWrapMode = 'wrap' | 'clamp';
|
|
5
|
+
export interface CompositeNavigationItem {
|
|
6
|
+
id: string;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface CompositeKeyboardEventLike {
|
|
10
|
+
key: string;
|
|
11
|
+
shiftKey: boolean;
|
|
12
|
+
ctrlKey: boolean;
|
|
13
|
+
metaKey: boolean;
|
|
14
|
+
altKey: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface CompositeKeyboardIntentContext {
|
|
17
|
+
orientation: CompositeNavigationOrientation;
|
|
18
|
+
homeEndEnabled?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export type CompositeKeyboardIntent = 'NAV_NEXT' | 'NAV_PREV' | 'NAV_FIRST' | 'NAV_LAST';
|
|
21
|
+
export interface CreateCompositeNavigationOptions {
|
|
22
|
+
items: readonly CompositeNavigationItem[];
|
|
23
|
+
idBase?: string;
|
|
24
|
+
orientation?: CompositeNavigationOrientation;
|
|
25
|
+
focusStrategy?: CompositeFocusStrategy;
|
|
26
|
+
wrapMode?: CompositeWrapMode;
|
|
27
|
+
initialActiveId?: string | null;
|
|
28
|
+
}
|
|
29
|
+
export interface CompositeNavigationState {
|
|
30
|
+
items: Atom<CompositeNavigationItem[]>;
|
|
31
|
+
activeId: Atom<string | null>;
|
|
32
|
+
enabledIds: Computed<readonly string[]>;
|
|
33
|
+
activeDomId: Computed<string | null>;
|
|
34
|
+
orientation: CompositeNavigationOrientation;
|
|
35
|
+
focusStrategy: CompositeFocusStrategy;
|
|
36
|
+
wrapMode: CompositeWrapMode;
|
|
37
|
+
}
|
|
38
|
+
export interface CompositeNavigationActions {
|
|
39
|
+
setItems(items: readonly CompositeNavigationItem[]): void;
|
|
40
|
+
setActive(id: string | null): void;
|
|
41
|
+
moveNext(): void;
|
|
42
|
+
movePrev(): void;
|
|
43
|
+
moveFirst(): void;
|
|
44
|
+
moveLast(): void;
|
|
45
|
+
handleKeyDown(event: CompositeKeyboardEventLike): void;
|
|
46
|
+
}
|
|
47
|
+
export interface CompositeContainerFocusProps {
|
|
48
|
+
tabindex: '0' | '-1';
|
|
49
|
+
'aria-activedescendant'?: string;
|
|
50
|
+
}
|
|
51
|
+
export interface CompositeItemFocusProps {
|
|
52
|
+
id: string;
|
|
53
|
+
tabindex: '0' | '-1';
|
|
54
|
+
'aria-disabled'?: 'true';
|
|
55
|
+
'data-active': 'true' | 'false';
|
|
56
|
+
}
|
|
57
|
+
export interface CompositeNavigationContracts {
|
|
58
|
+
getContainerFocusProps(): CompositeContainerFocusProps;
|
|
59
|
+
getItemFocusProps(id: string): CompositeItemFocusProps;
|
|
60
|
+
}
|
|
61
|
+
export interface CompositeNavigationModel {
|
|
62
|
+
readonly state: CompositeNavigationState;
|
|
63
|
+
readonly actions: CompositeNavigationActions;
|
|
64
|
+
readonly contracts: CompositeNavigationContracts;
|
|
65
|
+
}
|
|
66
|
+
export declare const getEnabledCompositeIds: (items: readonly CompositeNavigationItem[]) => string[];
|
|
67
|
+
export declare const getNextCompositeIndex: (currentIndex: number, direction: 1 | -1, size: number, wrapMode: CompositeWrapMode) => number;
|
|
68
|
+
export declare const mapCompositeNavigationIntent: (event: CompositeKeyboardEventLike, context: CompositeKeyboardIntentContext) => CompositeKeyboardIntent | null;
|
|
69
|
+
export declare function createCompositeNavigation(options: CreateCompositeNavigationOptions): CompositeNavigationModel;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { action, atom, computed } from '@reatom/core';
|
|
2
|
+
export const getEnabledCompositeIds = (items) => items.filter((item) => !item.disabled).map((item) => item.id);
|
|
3
|
+
export const getNextCompositeIndex = (currentIndex, direction, size, wrapMode) => {
|
|
4
|
+
if (size <= 0)
|
|
5
|
+
return -1;
|
|
6
|
+
if (wrapMode === 'wrap') {
|
|
7
|
+
return (currentIndex + direction + size) % size;
|
|
8
|
+
}
|
|
9
|
+
const candidate = currentIndex + direction;
|
|
10
|
+
if (candidate < 0)
|
|
11
|
+
return 0;
|
|
12
|
+
if (candidate >= size)
|
|
13
|
+
return size - 1;
|
|
14
|
+
return candidate;
|
|
15
|
+
};
|
|
16
|
+
export const mapCompositeNavigationIntent = (event, context) => {
|
|
17
|
+
if (event.ctrlKey || event.metaKey || event.altKey) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const nextKey = context.orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
|
|
21
|
+
const prevKey = context.orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
|
|
22
|
+
if (event.key === nextKey)
|
|
23
|
+
return 'NAV_NEXT';
|
|
24
|
+
if (event.key === prevKey)
|
|
25
|
+
return 'NAV_PREV';
|
|
26
|
+
if (context.homeEndEnabled !== false) {
|
|
27
|
+
if (event.key === 'Home')
|
|
28
|
+
return 'NAV_FIRST';
|
|
29
|
+
if (event.key === 'End')
|
|
30
|
+
return 'NAV_LAST';
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
};
|
|
34
|
+
export function createCompositeNavigation(options) {
|
|
35
|
+
const idBase = options.idBase ?? 'composite-nav';
|
|
36
|
+
const orientation = options.orientation ?? 'horizontal';
|
|
37
|
+
const focusStrategy = options.focusStrategy ?? 'roving-tabindex';
|
|
38
|
+
const wrapMode = options.wrapMode ?? 'wrap';
|
|
39
|
+
const itemsAtom = atom([...options.items], `${idBase}.items`);
|
|
40
|
+
const enabledIdsAtom = computed(() => getEnabledCompositeIds(itemsAtom()), `${idBase}.enabledIds`);
|
|
41
|
+
const resolveInitialActiveId = () => {
|
|
42
|
+
const enabledIds = getEnabledCompositeIds(options.items);
|
|
43
|
+
if (options.initialActiveId != null && enabledIds.includes(options.initialActiveId)) {
|
|
44
|
+
return options.initialActiveId;
|
|
45
|
+
}
|
|
46
|
+
return enabledIds[0] ?? null;
|
|
47
|
+
};
|
|
48
|
+
const activeIdAtom = atom(resolveInitialActiveId(), `${idBase}.activeId`);
|
|
49
|
+
const itemDomId = (id) => `${idBase}-item-${id}`;
|
|
50
|
+
const activeDomIdAtom = computed(() => {
|
|
51
|
+
const activeId = activeIdAtom();
|
|
52
|
+
return activeId == null ? null : itemDomId(activeId);
|
|
53
|
+
}, `${idBase}.activeDomId`);
|
|
54
|
+
const ensureActiveInvariant = () => {
|
|
55
|
+
const enabledIds = enabledIdsAtom();
|
|
56
|
+
if (enabledIds.length === 0) {
|
|
57
|
+
activeIdAtom.set(null);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const activeId = activeIdAtom();
|
|
61
|
+
if (activeId == null || !enabledIds.includes(activeId)) {
|
|
62
|
+
activeIdAtom.set(enabledIds[0] ?? null);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const move = (direction) => {
|
|
66
|
+
const enabledIds = enabledIdsAtom();
|
|
67
|
+
if (enabledIds.length === 0) {
|
|
68
|
+
activeIdAtom.set(null);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const activeId = activeIdAtom();
|
|
72
|
+
if (activeId == null || !enabledIds.includes(activeId)) {
|
|
73
|
+
activeIdAtom.set(direction === -1 ? (enabledIds[enabledIds.length - 1] ?? null) : (enabledIds[0] ?? null));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const currentIndex = enabledIds.indexOf(activeId);
|
|
77
|
+
const nextIndex = getNextCompositeIndex(currentIndex, direction, enabledIds.length, wrapMode);
|
|
78
|
+
activeIdAtom.set(enabledIds[nextIndex] ?? null);
|
|
79
|
+
};
|
|
80
|
+
const setItems = action((items) => {
|
|
81
|
+
itemsAtom.set([...items]);
|
|
82
|
+
ensureActiveInvariant();
|
|
83
|
+
}, `${idBase}.setItems`);
|
|
84
|
+
const setActive = action((id) => {
|
|
85
|
+
if (id == null) {
|
|
86
|
+
activeIdAtom.set(null);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (!enabledIdsAtom().includes(id))
|
|
90
|
+
return;
|
|
91
|
+
activeIdAtom.set(id);
|
|
92
|
+
}, `${idBase}.setActive`);
|
|
93
|
+
const moveNext = action(() => {
|
|
94
|
+
move(1);
|
|
95
|
+
}, `${idBase}.moveNext`);
|
|
96
|
+
const movePrev = action(() => {
|
|
97
|
+
move(-1);
|
|
98
|
+
}, `${idBase}.movePrev`);
|
|
99
|
+
const moveFirst = action(() => {
|
|
100
|
+
activeIdAtom.set(enabledIdsAtom()[0] ?? null);
|
|
101
|
+
}, `${idBase}.moveFirst`);
|
|
102
|
+
const moveLast = action(() => {
|
|
103
|
+
const enabledIds = enabledIdsAtom();
|
|
104
|
+
activeIdAtom.set(enabledIds[enabledIds.length - 1] ?? null);
|
|
105
|
+
}, `${idBase}.moveLast`);
|
|
106
|
+
const handleKeyDown = action((event) => {
|
|
107
|
+
const intent = mapCompositeNavigationIntent(event, { orientation });
|
|
108
|
+
switch (intent) {
|
|
109
|
+
case 'NAV_NEXT':
|
|
110
|
+
moveNext();
|
|
111
|
+
return;
|
|
112
|
+
case 'NAV_PREV':
|
|
113
|
+
movePrev();
|
|
114
|
+
return;
|
|
115
|
+
case 'NAV_FIRST':
|
|
116
|
+
moveFirst();
|
|
117
|
+
return;
|
|
118
|
+
case 'NAV_LAST':
|
|
119
|
+
moveLast();
|
|
120
|
+
return;
|
|
121
|
+
default:
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}, `${idBase}.handleKeyDown`);
|
|
125
|
+
const actions = {
|
|
126
|
+
setItems,
|
|
127
|
+
setActive,
|
|
128
|
+
moveNext,
|
|
129
|
+
movePrev,
|
|
130
|
+
moveFirst,
|
|
131
|
+
moveLast,
|
|
132
|
+
handleKeyDown,
|
|
133
|
+
};
|
|
134
|
+
const contracts = {
|
|
135
|
+
getContainerFocusProps() {
|
|
136
|
+
return {
|
|
137
|
+
tabindex: focusStrategy === 'aria-activedescendant' ? '0' : '-1',
|
|
138
|
+
'aria-activedescendant': focusStrategy === 'aria-activedescendant' ? (activeDomIdAtom() ?? undefined) : undefined,
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
getItemFocusProps(id) {
|
|
142
|
+
const item = itemsAtom().find((current) => current.id === id);
|
|
143
|
+
if (!item) {
|
|
144
|
+
throw new Error(`Unknown composite navigation item id: ${id}`);
|
|
145
|
+
}
|
|
146
|
+
const isActive = activeIdAtom() === id;
|
|
147
|
+
return {
|
|
148
|
+
id: itemDomId(id),
|
|
149
|
+
tabindex: focusStrategy === 'roving-tabindex' && isActive ? '0' : '-1',
|
|
150
|
+
'aria-disabled': item.disabled ? 'true' : undefined,
|
|
151
|
+
'data-active': isActive ? 'true' : 'false',
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
const state = {
|
|
156
|
+
items: itemsAtom,
|
|
157
|
+
activeId: activeIdAtom,
|
|
158
|
+
enabledIds: enabledIdsAtom,
|
|
159
|
+
activeDomId: activeDomIdAtom,
|
|
160
|
+
orientation,
|
|
161
|
+
focusStrategy,
|
|
162
|
+
wrapMode,
|
|
163
|
+
};
|
|
164
|
+
return {
|
|
165
|
+
state,
|
|
166
|
+
actions,
|
|
167
|
+
contracts,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type InteractionIntent = 'NAV_NEXT' | 'NAV_PREV' | 'NAV_FIRST' | 'NAV_LAST' | 'ACTIVATE' | 'TOGGLE_SELECTION' | 'DISMISS';
|
|
2
|
+
export type InteractionSource = 'keyboard' | 'pointer' | 'programmatic';
|
|
3
|
+
export interface InteractionEvent {
|
|
4
|
+
intent: InteractionIntent;
|
|
5
|
+
source: InteractionSource;
|
|
6
|
+
shiftKey?: boolean;
|
|
7
|
+
ctrlKey?: boolean;
|
|
8
|
+
metaKey?: boolean;
|
|
9
|
+
altKey?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export type InteractionHandler = (event: InteractionEvent) => void;
|
|
12
|
+
export * from './keyboard-intents.js';
|
|
13
|
+
export * from './typeahead.js';
|
|
14
|
+
export * from './composite-navigation.js';
|
|
15
|
+
export * from './overlay-focus.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type ListOrientation = 'vertical' | 'horizontal';
|
|
2
|
+
export type KeyboardSelectionMode = 'single' | 'multiple';
|
|
3
|
+
export interface KeyboardEventLike {
|
|
4
|
+
key: string;
|
|
5
|
+
shiftKey: boolean;
|
|
6
|
+
ctrlKey: boolean;
|
|
7
|
+
metaKey: boolean;
|
|
8
|
+
altKey: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ListKeyboardIntentContext {
|
|
11
|
+
orientation: ListOrientation;
|
|
12
|
+
selectionMode: KeyboardSelectionMode;
|
|
13
|
+
rangeSelectionEnabled: boolean;
|
|
14
|
+
}
|
|
15
|
+
export type ListKeyboardIntent = 'NAV_NEXT' | 'NAV_PREV' | 'NAV_FIRST' | 'NAV_LAST' | 'TOGGLE_SELECTION' | 'RANGE_NEXT' | 'RANGE_PREV' | 'RANGE_SELECT_ACTIVE' | 'ACTIVATE' | 'DISMISS' | 'SELECT_ALL';
|
|
16
|
+
export declare function mapListboxKeyboardIntent(event: KeyboardEventLike, context: ListKeyboardIntentContext): ListKeyboardIntent | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const isSelectAllShortcut = (event) => (event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === 'a';
|
|
2
|
+
export function mapListboxKeyboardIntent(event, context) {
|
|
3
|
+
const nextKey = context.orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
|
|
4
|
+
const prevKey = context.orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
|
|
5
|
+
const rangeEnabled = context.selectionMode === 'multiple' && context.rangeSelectionEnabled;
|
|
6
|
+
if (event.key === nextKey) {
|
|
7
|
+
if (rangeEnabled && event.shiftKey)
|
|
8
|
+
return 'RANGE_NEXT';
|
|
9
|
+
return 'NAV_NEXT';
|
|
10
|
+
}
|
|
11
|
+
if (event.key === prevKey) {
|
|
12
|
+
if (rangeEnabled && event.shiftKey)
|
|
13
|
+
return 'RANGE_PREV';
|
|
14
|
+
return 'NAV_PREV';
|
|
15
|
+
}
|
|
16
|
+
if (event.key === 'Home')
|
|
17
|
+
return 'NAV_FIRST';
|
|
18
|
+
if (event.key === 'End')
|
|
19
|
+
return 'NAV_LAST';
|
|
20
|
+
if (event.key === 'Escape')
|
|
21
|
+
return 'DISMISS';
|
|
22
|
+
if (event.key === ' ' || event.key === 'Spacebar') {
|
|
23
|
+
if (rangeEnabled && event.shiftKey)
|
|
24
|
+
return 'RANGE_SELECT_ACTIVE';
|
|
25
|
+
return 'TOGGLE_SELECTION';
|
|
26
|
+
}
|
|
27
|
+
if (event.key === 'Enter')
|
|
28
|
+
return 'ACTIVATE';
|
|
29
|
+
if (context.selectionMode === 'multiple' && isSelectAllShortcut(event)) {
|
|
30
|
+
return 'SELECT_ALL';
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Atom, type Computed } from '@reatom/core';
|
|
2
|
+
export type OverlayOpenSource = 'keyboard' | 'pointer' | 'programmatic';
|
|
3
|
+
export type OverlayDismissIntent = 'escape' | 'outside-pointer' | 'outside-focus' | 'programmatic';
|
|
4
|
+
export interface OverlayKeyboardEventLike {
|
|
5
|
+
key: string;
|
|
6
|
+
}
|
|
7
|
+
export interface CreateOverlayFocusOptions {
|
|
8
|
+
idBase?: string;
|
|
9
|
+
initialOpen?: boolean;
|
|
10
|
+
initialTriggerId?: string | null;
|
|
11
|
+
trapFocus?: boolean;
|
|
12
|
+
restoreFocus?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface OverlayFocusState {
|
|
15
|
+
isOpen: Atom<boolean>;
|
|
16
|
+
openedBy: Atom<OverlayOpenSource | null>;
|
|
17
|
+
triggerId: Atom<string | null>;
|
|
18
|
+
restoreTargetId: Atom<string | null>;
|
|
19
|
+
lastDismissIntent: Atom<OverlayDismissIntent | null>;
|
|
20
|
+
isFocusTrapped: Computed<boolean>;
|
|
21
|
+
shouldRestoreFocus: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface OverlayFocusActions {
|
|
24
|
+
setTrigger(id: string | null): void;
|
|
25
|
+
open(source?: OverlayOpenSource, triggerId?: string | null): void;
|
|
26
|
+
close(intent?: OverlayDismissIntent): void;
|
|
27
|
+
dismiss(intent: OverlayDismissIntent): void;
|
|
28
|
+
trap(): void;
|
|
29
|
+
restore(): void;
|
|
30
|
+
handleKeyDown(event: OverlayKeyboardEventLike): void;
|
|
31
|
+
handleOutsidePointer(): void;
|
|
32
|
+
handleOutsideFocus(): void;
|
|
33
|
+
}
|
|
34
|
+
export interface OverlayFocusModel {
|
|
35
|
+
readonly state: OverlayFocusState;
|
|
36
|
+
readonly actions: OverlayFocusActions;
|
|
37
|
+
}
|
|
38
|
+
export declare const mapOverlayDismissIntent: (event: OverlayKeyboardEventLike) => OverlayDismissIntent | null;
|
|
39
|
+
export declare const shouldTrapOverlayFocus: (isOpen: boolean, trapFocus: boolean) => boolean;
|
|
40
|
+
export declare function createOverlayFocus(options?: CreateOverlayFocusOptions): OverlayFocusModel;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { action, atom, computed } from '@reatom/core';
|
|
2
|
+
export const mapOverlayDismissIntent = (event) => {
|
|
3
|
+
if (event.key === 'Escape')
|
|
4
|
+
return 'escape';
|
|
5
|
+
return null;
|
|
6
|
+
};
|
|
7
|
+
export const shouldTrapOverlayFocus = (isOpen, trapFocus) => isOpen && trapFocus;
|
|
8
|
+
export function createOverlayFocus(options = {}) {
|
|
9
|
+
const idBase = options.idBase ?? 'overlay-focus';
|
|
10
|
+
const trapFocus = options.trapFocus ?? true;
|
|
11
|
+
const restoreFocus = options.restoreFocus ?? true;
|
|
12
|
+
const isOpenAtom = atom(options.initialOpen ?? false, `${idBase}.isOpen`);
|
|
13
|
+
const openedByAtom = atom(null, `${idBase}.openedBy`);
|
|
14
|
+
const triggerIdAtom = atom(options.initialTriggerId ?? null, `${idBase}.triggerId`);
|
|
15
|
+
const restoreTargetIdAtom = atom(null, `${idBase}.restoreTargetId`);
|
|
16
|
+
const lastDismissIntentAtom = atom(null, `${idBase}.lastDismissIntent`);
|
|
17
|
+
const forceTrapAtom = atom(false, `${idBase}.forceTrap`);
|
|
18
|
+
const isFocusTrappedAtom = computed(() => shouldTrapOverlayFocus(isOpenAtom(), trapFocus || forceTrapAtom()), `${idBase}.isFocusTrapped`);
|
|
19
|
+
const setTrigger = action((id) => {
|
|
20
|
+
triggerIdAtom.set(id);
|
|
21
|
+
}, `${idBase}.setTrigger`);
|
|
22
|
+
const open = action((source = 'programmatic', triggerId = triggerIdAtom()) => {
|
|
23
|
+
if (triggerId != null) {
|
|
24
|
+
triggerIdAtom.set(triggerId);
|
|
25
|
+
}
|
|
26
|
+
lastDismissIntentAtom.set(null);
|
|
27
|
+
restoreTargetIdAtom.set(null);
|
|
28
|
+
openedByAtom.set(source);
|
|
29
|
+
isOpenAtom.set(true);
|
|
30
|
+
}, `${idBase}.open`);
|
|
31
|
+
const close = action((intent = 'programmatic') => {
|
|
32
|
+
if (!isOpenAtom()) {
|
|
33
|
+
lastDismissIntentAtom.set(intent);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
isOpenAtom.set(false);
|
|
37
|
+
openedByAtom.set(null);
|
|
38
|
+
lastDismissIntentAtom.set(intent);
|
|
39
|
+
if (restoreFocus) {
|
|
40
|
+
restoreTargetIdAtom.set(triggerIdAtom());
|
|
41
|
+
}
|
|
42
|
+
}, `${idBase}.close`);
|
|
43
|
+
const dismiss = action((intent) => {
|
|
44
|
+
close(intent);
|
|
45
|
+
}, `${idBase}.dismiss`);
|
|
46
|
+
const trap = action(() => {
|
|
47
|
+
forceTrapAtom.set(true);
|
|
48
|
+
}, `${idBase}.trap`);
|
|
49
|
+
const restore = action(() => {
|
|
50
|
+
forceTrapAtom.set(false);
|
|
51
|
+
restoreTargetIdAtom.set(null);
|
|
52
|
+
}, `${idBase}.restore`);
|
|
53
|
+
const handleKeyDown = action((event) => {
|
|
54
|
+
const intent = mapOverlayDismissIntent(event);
|
|
55
|
+
if (intent != null) {
|
|
56
|
+
dismiss(intent);
|
|
57
|
+
}
|
|
58
|
+
}, `${idBase}.handleKeyDown`);
|
|
59
|
+
const handleOutsidePointer = action(() => {
|
|
60
|
+
if (!isOpenAtom())
|
|
61
|
+
return;
|
|
62
|
+
dismiss('outside-pointer');
|
|
63
|
+
}, `${idBase}.handleOutsidePointer`);
|
|
64
|
+
const handleOutsideFocus = action(() => {
|
|
65
|
+
if (!isOpenAtom())
|
|
66
|
+
return;
|
|
67
|
+
dismiss('outside-focus');
|
|
68
|
+
}, `${idBase}.handleOutsideFocus`);
|
|
69
|
+
const actions = {
|
|
70
|
+
setTrigger,
|
|
71
|
+
open,
|
|
72
|
+
close,
|
|
73
|
+
dismiss,
|
|
74
|
+
trap,
|
|
75
|
+
restore,
|
|
76
|
+
handleKeyDown,
|
|
77
|
+
handleOutsidePointer,
|
|
78
|
+
handleOutsideFocus,
|
|
79
|
+
};
|
|
80
|
+
const state = {
|
|
81
|
+
isOpen: isOpenAtom,
|
|
82
|
+
openedBy: openedByAtom,
|
|
83
|
+
triggerId: triggerIdAtom,
|
|
84
|
+
restoreTargetId: restoreTargetIdAtom,
|
|
85
|
+
lastDismissIntent: lastDismissIntentAtom,
|
|
86
|
+
isFocusTrapped: isFocusTrappedAtom,
|
|
87
|
+
shouldRestoreFocus: restoreFocus,
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
state,
|
|
91
|
+
actions,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { KeyboardEventLike } from './keyboard-intents.js';
|
|
2
|
+
export interface TypeaheadState {
|
|
3
|
+
buffer: string;
|
|
4
|
+
lastInputAt: number;
|
|
5
|
+
}
|
|
6
|
+
export interface TypeaheadItem {
|
|
7
|
+
id: string;
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const normalizeTypeaheadText: (value: string) => string;
|
|
11
|
+
export declare const createInitialTypeaheadState: () => TypeaheadState;
|
|
12
|
+
export declare const isTypeaheadEvent: (event: KeyboardEventLike) => boolean;
|
|
13
|
+
export declare const advanceTypeaheadState: (previous: TypeaheadState, key: string, now: number, timeoutMs: number) => {
|
|
14
|
+
query: string;
|
|
15
|
+
next: {
|
|
16
|
+
buffer: string;
|
|
17
|
+
lastInputAt: number;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export declare const findTypeaheadMatch: (query: string, items: readonly TypeaheadItem[], startIndex: number) => string | null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const normalizeTypeaheadText = (value) => value.trim().toLocaleLowerCase();
|
|
2
|
+
export const createInitialTypeaheadState = () => ({
|
|
3
|
+
buffer: '',
|
|
4
|
+
lastInputAt: 0,
|
|
5
|
+
});
|
|
6
|
+
const isSameCharacterSequence = (value) => value.length > 0 && [...value].every((character) => character === value[0]);
|
|
7
|
+
export const isTypeaheadEvent = (event) => {
|
|
8
|
+
if (event.key.length !== 1 || event.key === ' ')
|
|
9
|
+
return false;
|
|
10
|
+
if (event.ctrlKey || event.metaKey || event.altKey)
|
|
11
|
+
return false;
|
|
12
|
+
return normalizeTypeaheadText(event.key).length > 0;
|
|
13
|
+
};
|
|
14
|
+
export const advanceTypeaheadState = (previous, key, now, timeoutMs) => {
|
|
15
|
+
const normalizedKey = normalizeTypeaheadText(key);
|
|
16
|
+
const expired = now - previous.lastInputAt > timeoutMs;
|
|
17
|
+
const baseBuffer = expired ? '' : previous.buffer;
|
|
18
|
+
const candidateBuffer = `${baseBuffer}${normalizedKey}`;
|
|
19
|
+
const query = isSameCharacterSequence(candidateBuffer) ? normalizedKey : candidateBuffer;
|
|
20
|
+
return {
|
|
21
|
+
query,
|
|
22
|
+
next: {
|
|
23
|
+
buffer: candidateBuffer,
|
|
24
|
+
lastInputAt: now,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
export const findTypeaheadMatch = (query, items, startIndex) => {
|
|
29
|
+
if (query.length === 0 || items.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
for (let offset = 0; offset < items.length; offset += 1) {
|
|
32
|
+
const index = (startIndex + offset) % items.length;
|
|
33
|
+
const item = items[index];
|
|
34
|
+
if (item == null)
|
|
35
|
+
continue;
|
|
36
|
+
if (item.text.startsWith(query)) {
|
|
37
|
+
return item.id;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
};
|