@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.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/a11y-contracts/index.d.ts +23 -0
  4. package/dist/a11y-contracts/index.js +1 -0
  5. package/dist/accordion/index.d.ts +78 -0
  6. package/dist/accordion/index.js +264 -0
  7. package/dist/adapters/index.d.ts +9 -0
  8. package/dist/adapters/index.js +1 -0
  9. package/dist/alert/index.d.ts +33 -0
  10. package/dist/alert/index.js +54 -0
  11. package/dist/alert-dialog/index.d.ts +69 -0
  12. package/dist/alert-dialog/index.js +94 -0
  13. package/dist/badge/index.d.ts +48 -0
  14. package/dist/badge/index.js +89 -0
  15. package/dist/breadcrumb/index.d.ts +55 -0
  16. package/dist/breadcrumb/index.js +77 -0
  17. package/dist/button/index.d.ts +46 -0
  18. package/dist/button/index.js +86 -0
  19. package/dist/callout/index.d.ts +41 -0
  20. package/dist/callout/index.js +63 -0
  21. package/dist/card/index.d.ts +54 -0
  22. package/dist/card/index.js +103 -0
  23. package/dist/carousel/index.d.ts +98 -0
  24. package/dist/carousel/index.js +243 -0
  25. package/dist/checkbox/index.d.ts +50 -0
  26. package/dist/checkbox/index.js +87 -0
  27. package/dist/combobox/index.d.ts +114 -0
  28. package/dist/combobox/index.js +431 -0
  29. package/dist/command-palette/index.d.ts +73 -0
  30. package/dist/command-palette/index.js +147 -0
  31. package/dist/context-menu/index.d.ts +111 -0
  32. package/dist/context-menu/index.js +372 -0
  33. package/dist/copy-button/index.d.ts +62 -0
  34. package/dist/copy-button/index.js +183 -0
  35. package/dist/core/index.d.ts +20 -0
  36. package/dist/core/index.js +2 -0
  37. package/dist/core/selection.d.ts +5 -0
  38. package/dist/core/selection.js +39 -0
  39. package/dist/core/value-range.d.ts +49 -0
  40. package/dist/core/value-range.js +134 -0
  41. package/dist/date-picker/index.d.ts +210 -0
  42. package/dist/date-picker/index.js +895 -0
  43. package/dist/dialog/index.d.ts +95 -0
  44. package/dist/dialog/index.js +153 -0
  45. package/dist/disclosure/index.d.ts +52 -0
  46. package/dist/disclosure/index.js +159 -0
  47. package/dist/drawer/index.d.ts +30 -0
  48. package/dist/drawer/index.js +39 -0
  49. package/dist/feed/index.d.ts +77 -0
  50. package/dist/feed/index.js +260 -0
  51. package/dist/grid/index.d.ts +103 -0
  52. package/dist/grid/index.js +415 -0
  53. package/dist/index.d.ts +51 -0
  54. package/dist/index.js +51 -0
  55. package/dist/input/index.d.ts +86 -0
  56. package/dist/input/index.js +156 -0
  57. package/dist/interactions/composite-navigation.d.ts +69 -0
  58. package/dist/interactions/composite-navigation.js +169 -0
  59. package/dist/interactions/index.d.ts +15 -0
  60. package/dist/interactions/index.js +4 -0
  61. package/dist/interactions/keyboard-intents.d.ts +16 -0
  62. package/dist/interactions/keyboard-intents.js +33 -0
  63. package/dist/interactions/overlay-focus.d.ts +40 -0
  64. package/dist/interactions/overlay-focus.js +93 -0
  65. package/dist/interactions/typeahead.d.ts +20 -0
  66. package/dist/interactions/typeahead.js +41 -0
  67. package/dist/landmarks/index.d.ts +39 -0
  68. package/dist/landmarks/index.js +58 -0
  69. package/dist/link/index.d.ts +34 -0
  70. package/dist/link/index.js +39 -0
  71. package/dist/listbox/index.d.ts +92 -0
  72. package/dist/listbox/index.js +337 -0
  73. package/dist/menu/index.d.ts +132 -0
  74. package/dist/menu/index.js +541 -0
  75. package/dist/menu-button/index.d.ts +71 -0
  76. package/dist/menu-button/index.js +121 -0
  77. package/dist/meter/index.d.ts +45 -0
  78. package/dist/meter/index.js +106 -0
  79. package/dist/number/index.d.ts +113 -0
  80. package/dist/number/index.js +252 -0
  81. package/dist/popover/index.d.ts +70 -0
  82. package/dist/popover/index.js +126 -0
  83. package/dist/progress/index.d.ts +49 -0
  84. package/dist/progress/index.js +79 -0
  85. package/dist/radio-group/index.d.ts +61 -0
  86. package/dist/radio-group/index.js +150 -0
  87. package/dist/select/index.d.ts +92 -0
  88. package/dist/select/index.js +239 -0
  89. package/dist/sidebar/index.d.ts +74 -0
  90. package/dist/sidebar/index.js +186 -0
  91. package/dist/slider/index.d.ts +61 -0
  92. package/dist/slider/index.js +150 -0
  93. package/dist/slider-multi-thumb/index.d.ts +70 -0
  94. package/dist/slider-multi-thumb/index.js +222 -0
  95. package/dist/spinbutton/index.d.ts +75 -0
  96. package/dist/spinbutton/index.js +214 -0
  97. package/dist/spinner/index.d.ts +1 -0
  98. package/dist/spinner/index.js +1 -0
  99. package/dist/spinner/spinner.d.ts +23 -0
  100. package/dist/spinner/spinner.js +25 -0
  101. package/dist/switch/index.d.ts +40 -0
  102. package/dist/switch/index.js +61 -0
  103. package/dist/table/index.d.ts +117 -0
  104. package/dist/table/index.js +377 -0
  105. package/dist/tabs/index.d.ts +63 -0
  106. package/dist/tabs/index.js +174 -0
  107. package/dist/textarea/index.d.ts +68 -0
  108. package/dist/textarea/index.js +137 -0
  109. package/dist/toast/index.d.ts +67 -0
  110. package/dist/toast/index.js +145 -0
  111. package/dist/toolbar/index.d.ts +59 -0
  112. package/dist/toolbar/index.js +139 -0
  113. package/dist/tooltip/index.d.ts +52 -0
  114. package/dist/tooltip/index.js +169 -0
  115. package/dist/treegrid/index.d.ts +101 -0
  116. package/dist/treegrid/index.js +463 -0
  117. package/dist/treeview/index.d.ts +68 -0
  118. package/dist/treeview/index.js +370 -0
  119. package/dist/window-splitter/index.d.ts +65 -0
  120. package/dist/window-splitter/index.js +204 -0
  121. package/package.json +92 -0
  122. package/specs/ADR-001-headless-architecture.md +461 -0
  123. package/specs/ADR-002-repo-release-model.md +108 -0
  124. package/specs/ADR-003-public-api-versioning.md +136 -0
  125. package/specs/ADR-004-focus-selection-policy.md +117 -0
  126. package/specs/IMPLEMENTATION-ROADMAP.md +237 -0
  127. package/specs/ISSUE-BACKLOG.md +681 -0
  128. package/specs/RELEASE-CANDIDATE.md +30 -0
  129. package/specs/components/accordion.md +130 -0
  130. package/specs/components/alert-dialog.md +72 -0
  131. package/specs/components/alert.md +65 -0
  132. package/specs/components/badge.md +220 -0
  133. package/specs/components/breadcrumb.md +74 -0
  134. package/specs/components/button.md +115 -0
  135. package/specs/components/callout.md +195 -0
  136. package/specs/components/card.md +280 -0
  137. package/specs/components/carousel.md +140 -0
  138. package/specs/components/checkbox.md +172 -0
  139. package/specs/components/combobox.md +423 -0
  140. package/specs/components/command-palette.md +92 -0
  141. package/specs/components/context-menu.md +556 -0
  142. package/specs/components/copy-button.md +293 -0
  143. package/specs/components/date-picker.md +400 -0
  144. package/specs/components/dialog.md +298 -0
  145. package/specs/components/disclosure.md +257 -0
  146. package/specs/components/drawer.md +353 -0
  147. package/specs/components/feed.md +265 -0
  148. package/specs/components/grid.md +186 -0
  149. package/specs/components/input.md +254 -0
  150. package/specs/components/landmarks.md +136 -0
  151. package/specs/components/link.md +134 -0
  152. package/specs/components/listbox.md +351 -0
  153. package/specs/components/menu-button.md +76 -0
  154. package/specs/components/menu.md +623 -0
  155. package/specs/components/meter.md +149 -0
  156. package/specs/components/number.md +393 -0
  157. package/specs/components/popover.md +252 -0
  158. package/specs/components/progress.md +188 -0
  159. package/specs/components/radio-group.md +151 -0
  160. package/specs/components/select.md +144 -0
  161. package/specs/components/sidebar.md +321 -0
  162. package/specs/components/slider-multi-thumb.md +78 -0
  163. package/specs/components/slider.md +84 -0
  164. package/specs/components/spinbutton.md +140 -0
  165. package/specs/components/spinner.md +132 -0
  166. package/specs/components/switch.md +175 -0
  167. package/specs/components/table.md +403 -0
  168. package/specs/components/tabs.md +265 -0
  169. package/specs/components/textarea.md +185 -0
  170. package/specs/components/toast.md +198 -0
  171. package/specs/components/toolbar.md +278 -0
  172. package/specs/components/tooltip.md +252 -0
  173. package/specs/components/treegrid.md +281 -0
  174. package/specs/components/treeview.md +91 -0
  175. package/specs/components/window-splitter.md +297 -0
  176. package/specs/ops/git-shard-sync.md +107 -0
  177. package/specs/ops/release-checklist.md +76 -0
  178. package/specs/release/GAP-TO-GREEN-ISSUES.md +88 -0
  179. package/specs/release/api-freeze-candidate.md +54 -0
  180. package/specs/release/changelog-automation.md +76 -0
  181. package/specs/release/changelog.generated.md +53 -0
  182. package/specs/release/changelog.patch.generated.md +46 -0
  183. package/specs/release/consumer-integration.md +53 -0
  184. package/specs/release/migration-notes-pre-v1.md +40 -0
  185. package/specs/release/mvp-changelog.md +57 -0
  186. package/specs/release/release-notes-template.md +61 -0
  187. package/specs/release/release-rehearsal.md +113 -0
  188. package/specs/release/semver-deprecation-dry-run.md +89 -0
  189. package/specs/release/shard-release-drill-report.md +50 -0
  190. package/specs/release/shard-release-follow-ups.md +31 -0
  191. package/specs/signals.md +208 -0
@@ -0,0 +1,39 @@
1
+ import { type Atom } from '@reatom/core';
2
+ export type LandmarkType = 'banner' | 'main' | 'navigation' | 'complementary' | 'contentinfo' | 'search' | 'form' | 'region';
3
+ export type MaybeAtom<T> = T | Atom<T>;
4
+ export interface CreateLandmarkOptions {
5
+ type: LandmarkType;
6
+ label?: MaybeAtom<string>;
7
+ labelId?: MaybeAtom<string>;
8
+ idBase?: string;
9
+ }
10
+ export interface LandmarkState {
11
+ type: Atom<LandmarkType>;
12
+ label: Atom<string | null>;
13
+ labelId: Atom<string | null>;
14
+ }
15
+ export interface LandmarkProps {
16
+ role: LandmarkType;
17
+ 'aria-label'?: string;
18
+ 'aria-labelledby'?: string;
19
+ }
20
+ export interface LandmarkContracts {
21
+ getLandmarkProps(): LandmarkProps;
22
+ }
23
+ export interface LandmarkModel {
24
+ readonly state: LandmarkState;
25
+ readonly contracts: LandmarkContracts;
26
+ }
27
+ export interface LandmarkDescriptor {
28
+ type: LandmarkType;
29
+ label?: string;
30
+ labelId?: string;
31
+ }
32
+ export interface LandmarkUniquenessIssue {
33
+ type: LandmarkType;
34
+ key: string;
35
+ count: number;
36
+ }
37
+ export declare const findLandmarkUniquenessIssues: (descriptors: readonly LandmarkDescriptor[]) => LandmarkUniquenessIssue[];
38
+ export declare const hasLandmarkUniquenessIssues: (descriptors: readonly LandmarkDescriptor[]) => boolean;
39
+ export declare function createLandmark(options: CreateLandmarkOptions): LandmarkModel;
@@ -0,0 +1,58 @@
1
+ import { atom, computed } from '@reatom/core';
2
+ const uniquenessKey = (descriptor) => descriptor.labelId ?? descriptor.label ?? '';
3
+ export const findLandmarkUniquenessIssues = (descriptors) => {
4
+ const buckets = new Map();
5
+ for (const descriptor of descriptors) {
6
+ const key = uniquenessKey(descriptor);
7
+ const bucketKey = `${descriptor.type}::${key}`;
8
+ const existing = buckets.get(bucketKey);
9
+ if (existing) {
10
+ existing.count += 1;
11
+ }
12
+ else {
13
+ buckets.set(bucketKey, {
14
+ type: descriptor.type,
15
+ key,
16
+ count: 1,
17
+ });
18
+ }
19
+ }
20
+ return [...buckets.values()].filter((issue) => issue.count > 1);
21
+ };
22
+ export const hasLandmarkUniquenessIssues = (descriptors) => findLandmarkUniquenessIssues(descriptors).length > 0;
23
+ function resolveAtom(value, fallback, name) {
24
+ if (value == null)
25
+ return atom(fallback, name);
26
+ if (typeof value === 'function')
27
+ return value;
28
+ return atom(value, name);
29
+ }
30
+ export function createLandmark(options) {
31
+ const idBase = options.idBase ?? `landmark-${options.type}`;
32
+ const typeAtom = atom(options.type, `${idBase}.type`);
33
+ const labelAtom = resolveAtom(options.label, null, `${idBase}.label`);
34
+ const labelIdAtom = resolveAtom(options.labelId, null, `${idBase}.labelId`);
35
+ const landmarkPropsAtom = computed(() => {
36
+ const label = labelAtom();
37
+ const labelId = labelIdAtom();
38
+ return {
39
+ role: typeAtom(),
40
+ 'aria-label': labelId == null ? (label ?? undefined) : undefined,
41
+ 'aria-labelledby': labelId ?? undefined,
42
+ };
43
+ }, `${idBase}.landmarkProps`);
44
+ const state = {
45
+ type: typeAtom,
46
+ label: labelAtom,
47
+ labelId: labelIdAtom,
48
+ };
49
+ const contracts = {
50
+ getLandmarkProps() {
51
+ return landmarkPropsAtom();
52
+ },
53
+ };
54
+ return {
55
+ state,
56
+ contracts,
57
+ };
58
+ }
@@ -0,0 +1,34 @@
1
+ export interface CreateLinkOptions {
2
+ idBase?: string;
3
+ href?: string;
4
+ isSemanticHost?: boolean;
5
+ onPress?: () => void;
6
+ }
7
+ export interface LinkState {
8
+ }
9
+ export interface LinkActions {
10
+ press(): void;
11
+ handleClick(event?: Pick<Event, 'preventDefault'>): void;
12
+ handleKeyDown(event: Pick<KeyboardEvent, 'key'> & {
13
+ preventDefault?: () => void;
14
+ }): void;
15
+ }
16
+ export interface LinkProps {
17
+ id: string;
18
+ role?: 'link';
19
+ href?: string;
20
+ tabindex?: '0';
21
+ onClick: (event?: Pick<Event, 'preventDefault'>) => void;
22
+ onKeyDown: (event: Pick<KeyboardEvent, 'key'> & {
23
+ preventDefault?: () => void;
24
+ }) => void;
25
+ }
26
+ export interface LinkContracts {
27
+ getLinkProps(): LinkProps;
28
+ }
29
+ export interface LinkModel {
30
+ readonly state: LinkState;
31
+ readonly actions: LinkActions;
32
+ readonly contracts: LinkContracts;
33
+ }
34
+ export declare function createLink(options?: CreateLinkOptions): LinkModel;
@@ -0,0 +1,39 @@
1
+ import { action } from '@reatom/core';
2
+ export function createLink(options = {}) {
3
+ const idBase = options.idBase ?? 'link';
4
+ const isSemanticHost = options.isSemanticHost ?? false;
5
+ const press = action(() => {
6
+ options.onPress?.();
7
+ }, `${idBase}.press`);
8
+ const handleClick = action((_event) => {
9
+ press();
10
+ }, `${idBase}.handleClick`);
11
+ const handleKeyDown = action((event) => {
12
+ if (event.key === 'Enter') {
13
+ press();
14
+ }
15
+ }, `${idBase}.handleKeyDown`);
16
+ const actions = {
17
+ press,
18
+ handleClick,
19
+ handleKeyDown,
20
+ };
21
+ const contracts = {
22
+ getLinkProps() {
23
+ return {
24
+ id: `${idBase}-root`,
25
+ role: isSemanticHost ? undefined : 'link',
26
+ href: options.href,
27
+ tabindex: isSemanticHost ? undefined : '0',
28
+ onClick: handleClick,
29
+ onKeyDown: handleKeyDown,
30
+ };
31
+ },
32
+ };
33
+ const state = {};
34
+ return {
35
+ state,
36
+ actions,
37
+ contracts,
38
+ };
39
+ }
@@ -0,0 +1,92 @@
1
+ import { type Atom, type Computed } from '@reatom/core';
2
+ import type { FocusStrategy, RootA11yProps } from '../a11y-contracts/index.js';
3
+ export type ListboxSelectionMode = 'single' | 'multiple';
4
+ export type ListboxOrientation = 'vertical' | 'horizontal';
5
+ export interface ListboxOption {
6
+ id: string;
7
+ label?: string;
8
+ disabled?: boolean;
9
+ groupId?: string;
10
+ }
11
+ export interface ListboxGroup {
12
+ id: string;
13
+ label: string;
14
+ }
15
+ export interface ListboxTypeaheadOptions {
16
+ enabled?: boolean;
17
+ timeoutMs?: number;
18
+ }
19
+ export interface ListboxRangeSelectionOptions {
20
+ enabled?: boolean;
21
+ }
22
+ export interface CreateListboxOptions {
23
+ options: readonly ListboxOption[];
24
+ groups?: readonly ListboxGroup[];
25
+ selectionMode?: ListboxSelectionMode;
26
+ focusStrategy?: FocusStrategy;
27
+ selectionFollowsFocus?: boolean;
28
+ rangeSelection?: boolean | ListboxRangeSelectionOptions;
29
+ orientation?: ListboxOrientation;
30
+ typeahead?: boolean | ListboxTypeaheadOptions;
31
+ ariaLabel?: string;
32
+ idBase?: string;
33
+ initialActiveId?: string | null;
34
+ initialSelectedIds?: readonly string[];
35
+ }
36
+ export interface ListboxState {
37
+ activeId: Atom<string | null>;
38
+ selectedIds: Atom<string[]>;
39
+ isOpen: Atom<boolean>;
40
+ hasSelection: Computed<boolean>;
41
+ selectionMode: ListboxSelectionMode;
42
+ focusStrategy: FocusStrategy;
43
+ orientation: ListboxOrientation;
44
+ optionCount: number;
45
+ groups: readonly ListboxGroup[];
46
+ }
47
+ export interface ListboxActions {
48
+ open(): void;
49
+ close(): void;
50
+ setActive(id: string | null): void;
51
+ moveNext(): void;
52
+ movePrev(): void;
53
+ moveFirst(): void;
54
+ moveLast(): void;
55
+ toggleSelected(id: string): void;
56
+ selectOnly(id: string): void;
57
+ clearSelected(): void;
58
+ handleKeyDown(event: Pick<KeyboardEvent, 'key' | 'shiftKey' | 'ctrlKey' | 'metaKey' | 'altKey'>): void;
59
+ }
60
+ export interface ListboxOptionProps {
61
+ id: string;
62
+ role: 'option';
63
+ tabindex: '0' | '-1';
64
+ 'aria-disabled'?: 'true';
65
+ 'aria-selected': 'true' | 'false';
66
+ 'aria-setsize': string;
67
+ 'aria-posinset': string;
68
+ 'data-active': 'true' | 'false';
69
+ }
70
+ export interface ListboxGroupProps {
71
+ id: string;
72
+ role: 'group';
73
+ 'aria-labelledby': string;
74
+ }
75
+ export interface ListboxGroupLabelProps {
76
+ id: string;
77
+ role: 'presentation';
78
+ }
79
+ export interface ListboxContracts {
80
+ getRootProps(): RootA11yProps;
81
+ getOptionProps(id: string): ListboxOptionProps;
82
+ getGroupProps(groupId: string): ListboxGroupProps;
83
+ getGroupLabelProps(groupId: string): ListboxGroupLabelProps;
84
+ getGroupOptions(groupId: string): readonly ListboxOption[];
85
+ getUngroupedOptions(): readonly ListboxOption[];
86
+ }
87
+ export interface ListboxModel {
88
+ readonly state: ListboxState;
89
+ readonly actions: ListboxActions;
90
+ readonly contracts: ListboxContracts;
91
+ }
92
+ export declare function createListbox(options: CreateListboxOptions): ListboxModel;
@@ -0,0 +1,337 @@
1
+ import { action, atom, computed } from '@reatom/core';
2
+ import { normalizeSelection, selectOnly as selectOnlyPrimitive, selectRangeByOrder, toggleSelection, } from '../core/selection.js';
3
+ import { mapListboxKeyboardIntent } from '../interactions/keyboard-intents.js';
4
+ import { advanceTypeaheadState, createInitialTypeaheadState, findTypeaheadMatch, isTypeaheadEvent, normalizeTypeaheadText, } from '../interactions/typeahead.js';
5
+ export function createListbox(options) {
6
+ const selectionMode = options.selectionMode ?? 'single';
7
+ const focusStrategy = options.focusStrategy ?? 'aria-activedescendant';
8
+ const selectionFollowsFocus = options.selectionFollowsFocus ?? false;
9
+ const rangeSelectionEnabled = options.rangeSelection === true ||
10
+ (typeof options.rangeSelection === 'object' && options.rangeSelection.enabled !== false);
11
+ const orientation = options.orientation ?? 'vertical';
12
+ const idBase = options.idBase ?? 'lb';
13
+ const typeaheadEnabled = options.typeahead !== false &&
14
+ !(typeof options.typeahead === 'object' && options.typeahead.enabled === false);
15
+ const typeaheadTimeoutMs = typeof options.typeahead === 'object' && options.typeahead.timeoutMs != null
16
+ ? Math.max(0, options.typeahead.timeoutMs)
17
+ : 500;
18
+ const groups = options.groups ?? [];
19
+ const groupsById = new Map(groups.map((g) => [g.id, g]));
20
+ const optionsById = new Map(options.options.map((option) => [option.id, option]));
21
+ const optionSearchTextById = new Map(options.options.map((option) => [option.id, normalizeTypeaheadText(option.label ?? option.id)]));
22
+ const optionIds = options.options.map((option) => option.id);
23
+ const optionCount = optionIds.length;
24
+ const optionPositionById = new Map(optionIds.map((id, index) => [id, index + 1]));
25
+ const enabledOptionIds = options.options.filter((option) => !option.disabled).map((option) => option.id);
26
+ const selectableIds = new Set(enabledOptionIds);
27
+ const typeaheadItems = enabledOptionIds.map((id) => ({
28
+ id,
29
+ text: optionSearchTextById.get(id) ?? '',
30
+ }));
31
+ let typeaheadState = createInitialTypeaheadState();
32
+ const resolveInitialActive = () => {
33
+ if (options.initialActiveId != null && selectableIds.has(options.initialActiveId)) {
34
+ return options.initialActiveId;
35
+ }
36
+ const preselected = normalizeSelection(options.initialSelectedIds ?? [], selectableIds, selectionMode)[0];
37
+ if (preselected != null) {
38
+ return preselected;
39
+ }
40
+ return enabledOptionIds[0] ?? null;
41
+ };
42
+ const activeIdAtom = atom(resolveInitialActive(), `${idBase}.activeId`);
43
+ const selectedIdsAtom = atom(normalizeSelection(options.initialSelectedIds ?? [], selectableIds, selectionMode), `${idBase}.selectedIds`);
44
+ const isOpenAtom = atom(false, `${idBase}.isOpen`);
45
+ const hasSelectionAtom = computed(() => selectedIdsAtom().length > 0, `${idBase}.hasSelection`);
46
+ let rangeAnchorId = selectedIdsAtom()[0] ?? activeIdAtom();
47
+ const optionDomId = (id) => `${idBase}-option-${id}`;
48
+ const resetTypeahead = () => {
49
+ typeaheadState = createInitialTypeaheadState();
50
+ };
51
+ const setActive = (id) => {
52
+ if (id == null) {
53
+ activeIdAtom.set(null);
54
+ return;
55
+ }
56
+ const option = optionsById.get(id);
57
+ if (!option || option.disabled)
58
+ return;
59
+ activeIdAtom.set(id);
60
+ if (selectionMode === 'single' && selectionFollowsFocus) {
61
+ selectedIdsAtom.set([id]);
62
+ }
63
+ };
64
+ const selectRangeBetween = (fromId, toId) => {
65
+ const range = selectRangeByOrder(enabledOptionIds, fromId, toId);
66
+ if (range.length === 0)
67
+ return;
68
+ selectedIdsAtom.set(range);
69
+ };
70
+ const findNextEnabled = (fromIndex, step) => {
71
+ let index = fromIndex + step;
72
+ while (index >= 0 && index < optionIds.length) {
73
+ const id = optionIds[index];
74
+ if (id != null && selectableIds.has(id))
75
+ return id;
76
+ index += step;
77
+ }
78
+ return null;
79
+ };
80
+ const move = (direction) => {
81
+ const activeId = activeIdAtom();
82
+ if (activeId == null) {
83
+ setActive(enabledOptionIds[0] ?? null);
84
+ return;
85
+ }
86
+ const currentIndex = optionIds.indexOf(activeId);
87
+ if (currentIndex < 0) {
88
+ setActive(enabledOptionIds[0] ?? null);
89
+ return;
90
+ }
91
+ const nextId = findNextEnabled(currentIndex, direction);
92
+ if (nextId != null)
93
+ setActive(nextId);
94
+ };
95
+ const handleTypeahead = (event) => {
96
+ if (!typeaheadEnabled || !isTypeaheadEvent(event))
97
+ return false;
98
+ const now = Date.now();
99
+ const { query, next } = advanceTypeaheadState(typeaheadState, normalizeTypeaheadText(event.key), now, typeaheadTimeoutMs);
100
+ const activeId = activeIdAtom();
101
+ const activeEnabledIndex = activeId == null ? -1 : enabledOptionIds.indexOf(activeId);
102
+ const startIndex = activeEnabledIndex < 0 ? 0 : (activeEnabledIndex + 1) % enabledOptionIds.length;
103
+ const matchedId = findTypeaheadMatch(query, typeaheadItems, startIndex);
104
+ if (matchedId != null) {
105
+ setActive(matchedId);
106
+ }
107
+ typeaheadState = next;
108
+ return true;
109
+ };
110
+ const open = action(() => {
111
+ isOpenAtom.set(true);
112
+ resetTypeahead();
113
+ }, `${idBase}.open`);
114
+ const close = action(() => {
115
+ isOpenAtom.set(false);
116
+ resetTypeahead();
117
+ }, `${idBase}.close`);
118
+ const setActiveAction = action((id) => {
119
+ setActive(id);
120
+ }, `${idBase}.setActive`);
121
+ const moveNext = action(() => {
122
+ move(1);
123
+ }, `${idBase}.moveNext`);
124
+ const movePrev = action(() => {
125
+ move(-1);
126
+ }, `${idBase}.movePrev`);
127
+ const moveFirst = action(() => {
128
+ setActive(enabledOptionIds[0] ?? null);
129
+ }, `${idBase}.moveFirst`);
130
+ const moveLast = action(() => {
131
+ setActive(enabledOptionIds[enabledOptionIds.length - 1] ?? null);
132
+ }, `${idBase}.moveLast`);
133
+ const toggleSelectedAction = action((id) => {
134
+ if (!selectableIds.has(id))
135
+ return;
136
+ const nextSelected = toggleSelection(selectedIdsAtom(), id, selectionMode, selectableIds);
137
+ selectedIdsAtom.set(nextSelected);
138
+ rangeAnchorId = id;
139
+ }, `${idBase}.toggleSelected`);
140
+ const selectOnlyAction = action((id) => {
141
+ const nextSelected = selectOnlyPrimitive(id, selectableIds);
142
+ if (nextSelected.length === 0)
143
+ return;
144
+ selectedIdsAtom.set(nextSelected);
145
+ rangeAnchorId = id;
146
+ }, `${idBase}.selectOnly`);
147
+ const clearSelected = action(() => {
148
+ selectedIdsAtom.set([]);
149
+ rangeAnchorId = null;
150
+ }, `${idBase}.clearSelected`);
151
+ const handleKeyDown = action((event) => {
152
+ if (handleTypeahead(event)) {
153
+ return;
154
+ }
155
+ resetTypeahead();
156
+ const intent = mapListboxKeyboardIntent(event, {
157
+ orientation,
158
+ selectionMode,
159
+ rangeSelectionEnabled,
160
+ });
161
+ if (intent == null)
162
+ return;
163
+ const activeId = activeIdAtom();
164
+ switch (intent) {
165
+ case 'NAV_NEXT':
166
+ moveNext();
167
+ return;
168
+ case 'NAV_PREV':
169
+ movePrev();
170
+ return;
171
+ case 'NAV_FIRST':
172
+ moveFirst();
173
+ return;
174
+ case 'NAV_LAST':
175
+ moveLast();
176
+ return;
177
+ case 'DISMISS':
178
+ close();
179
+ return;
180
+ case 'RANGE_NEXT': {
181
+ const previousActive = activeIdAtom();
182
+ moveNext();
183
+ const currentActive = activeIdAtom();
184
+ if (currentActive == null)
185
+ return;
186
+ rangeAnchorId = rangeAnchorId ?? previousActive ?? currentActive;
187
+ if (rangeAnchorId != null) {
188
+ selectRangeBetween(rangeAnchorId, currentActive);
189
+ }
190
+ return;
191
+ }
192
+ case 'RANGE_PREV': {
193
+ const previousActive = activeIdAtom();
194
+ movePrev();
195
+ const currentActive = activeIdAtom();
196
+ if (currentActive == null)
197
+ return;
198
+ rangeAnchorId = rangeAnchorId ?? previousActive ?? currentActive;
199
+ if (rangeAnchorId != null) {
200
+ selectRangeBetween(rangeAnchorId, currentActive);
201
+ }
202
+ return;
203
+ }
204
+ case 'RANGE_SELECT_ACTIVE':
205
+ if (activeId == null)
206
+ return;
207
+ rangeAnchorId = rangeAnchorId ?? activeId;
208
+ if (rangeAnchorId != null) {
209
+ selectRangeBetween(rangeAnchorId, activeId);
210
+ }
211
+ return;
212
+ case 'TOGGLE_SELECTION':
213
+ if (activeId == null)
214
+ return;
215
+ toggleSelectedAction(activeId);
216
+ return;
217
+ case 'ACTIVATE':
218
+ if (activeId == null)
219
+ return;
220
+ if (selectionMode === 'multiple')
221
+ toggleSelectedAction(activeId);
222
+ else
223
+ selectOnlyAction(activeId);
224
+ return;
225
+ case 'SELECT_ALL':
226
+ selectedIdsAtom.set([...enabledOptionIds]);
227
+ rangeAnchorId = activeIdAtom() ?? enabledOptionIds[0] ?? null;
228
+ return;
229
+ }
230
+ }, `${idBase}.handleKeyDown`);
231
+ const actions = {
232
+ open,
233
+ close,
234
+ setActive: setActiveAction,
235
+ moveNext,
236
+ movePrev,
237
+ moveFirst,
238
+ moveLast,
239
+ toggleSelected: toggleSelectedAction,
240
+ selectOnly: selectOnlyAction,
241
+ clearSelected,
242
+ handleKeyDown,
243
+ };
244
+ const optionsByGroupId = new Map();
245
+ for (const option of options.options) {
246
+ if (option.groupId != null && groupsById.has(option.groupId)) {
247
+ let list = optionsByGroupId.get(option.groupId);
248
+ if (!list) {
249
+ list = [];
250
+ optionsByGroupId.set(option.groupId, list);
251
+ }
252
+ list.push(option);
253
+ }
254
+ }
255
+ const contracts = {
256
+ getRootProps() {
257
+ const activeId = activeIdAtom();
258
+ const base = {
259
+ role: 'listbox',
260
+ tabindex: focusStrategy === 'aria-activedescendant' ? '0' : '-1',
261
+ 'aria-label': options.ariaLabel,
262
+ 'aria-orientation': orientation,
263
+ };
264
+ if (selectionMode === 'multiple') {
265
+ base['aria-multiselectable'] = 'true';
266
+ }
267
+ if (focusStrategy === 'aria-activedescendant' && activeId != null) {
268
+ base['aria-activedescendant'] = optionDomId(activeId);
269
+ }
270
+ return base;
271
+ },
272
+ getOptionProps(id) {
273
+ const option = optionsById.get(id);
274
+ if (!option) {
275
+ throw new Error(`Unknown listbox option id: ${id}`);
276
+ }
277
+ const selectedIds = selectedIdsAtom();
278
+ const activeId = activeIdAtom();
279
+ const isSelected = selectedIds.includes(id);
280
+ const isActive = activeId === id;
281
+ const tabindex = focusStrategy === 'roving-tabindex' && isActive ? '0' : '-1';
282
+ return {
283
+ id: optionDomId(id),
284
+ role: 'option',
285
+ tabindex,
286
+ 'aria-disabled': option.disabled ? 'true' : undefined,
287
+ 'aria-selected': isSelected ? 'true' : 'false',
288
+ 'aria-setsize': String(optionCount),
289
+ 'aria-posinset': String(optionPositionById.get(id)),
290
+ 'data-active': isActive ? 'true' : 'false',
291
+ };
292
+ },
293
+ getGroupProps(groupId) {
294
+ const group = groupsById.get(groupId);
295
+ if (!group) {
296
+ throw new Error(`Unknown listbox group id: ${groupId}`);
297
+ }
298
+ return {
299
+ id: groupId,
300
+ role: 'group',
301
+ 'aria-labelledby': `${idBase}-group-${groupId}-label`,
302
+ };
303
+ },
304
+ getGroupLabelProps(groupId) {
305
+ const group = groupsById.get(groupId);
306
+ if (!group) {
307
+ throw new Error(`Unknown listbox group id: ${groupId}`);
308
+ }
309
+ return {
310
+ id: `${idBase}-group-${groupId}-label`,
311
+ role: 'presentation',
312
+ };
313
+ },
314
+ getGroupOptions(groupId) {
315
+ return optionsByGroupId.get(groupId) ?? [];
316
+ },
317
+ getUngroupedOptions() {
318
+ return options.options.filter((option) => option.groupId == null || !groupsById.has(option.groupId));
319
+ },
320
+ };
321
+ const state = {
322
+ activeId: activeIdAtom,
323
+ selectedIds: selectedIdsAtom,
324
+ isOpen: isOpenAtom,
325
+ hasSelection: hasSelectionAtom,
326
+ selectionMode,
327
+ focusStrategy,
328
+ orientation,
329
+ optionCount,
330
+ groups,
331
+ };
332
+ return {
333
+ state,
334
+ actions,
335
+ contracts,
336
+ };
337
+ }