svelte-5-select 1.0.2 → 2.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 (39) hide show
  1. package/README.md +255 -115
  2. package/dist/ChevronIcon.svelte +14 -13
  3. package/dist/ChevronIcon.svelte.d.ts +6 -14
  4. package/dist/ClearIcon.svelte +9 -11
  5. package/dist/ClearIcon.svelte.d.ts +6 -14
  6. package/dist/LoadingIcon.svelte +15 -2
  7. package/dist/LoadingIcon.svelte.d.ts +6 -14
  8. package/dist/Select.svelte +1048 -442
  9. package/dist/Select.svelte.d.ts +37 -5
  10. package/dist/aria-handlers.svelte.d.ts +5 -2
  11. package/dist/aria-handlers.svelte.js +30 -8
  12. package/dist/filter.d.ts +10 -2
  13. package/dist/filter.js +15 -8
  14. package/dist/index.d.ts +3 -4
  15. package/dist/index.js +2 -3
  16. package/dist/keyboard-navigation.svelte.d.ts +7 -2
  17. package/dist/keyboard-navigation.svelte.js +293 -47
  18. package/dist/no-styles/ChevronIcon.svelte +17 -0
  19. package/dist/no-styles/ChevronIcon.svelte.d.ts +18 -0
  20. package/dist/no-styles/ClearIcon.svelte +14 -0
  21. package/dist/no-styles/ClearIcon.svelte.d.ts +18 -0
  22. package/dist/no-styles/LoadingIcon.svelte +19 -0
  23. package/dist/no-styles/LoadingIcon.svelte.d.ts +18 -0
  24. package/dist/no-styles/Select.svelte +1452 -0
  25. package/dist/no-styles/Select.svelte.d.ts +38 -0
  26. package/dist/select-state.svelte.d.ts +15 -0
  27. package/dist/select-state.svelte.js +161 -0
  28. package/dist/styles/default.css +131 -71
  29. package/dist/tailwind.css +139 -17
  30. package/dist/types.d.ts +488 -129
  31. package/dist/use-hover.svelte.d.ts +3 -7
  32. package/dist/use-hover.svelte.js +110 -36
  33. package/dist/use-load-options.svelte.d.ts +18 -3
  34. package/dist/use-load-options.svelte.js +362 -42
  35. package/dist/use-value.svelte.d.ts +2 -11
  36. package/dist/use-value.svelte.js +334 -111
  37. package/dist/utils.d.ts +19 -8
  38. package/dist/utils.js +52 -8
  39. package/package.json +121 -94
@@ -1,6 +1,38 @@
1
- import type { SelectProps } from './types';
2
- declare const Select: import("svelte").Component<SelectProps, {
3
- reset: () => void;
4
- }, "loading" | "value" | "hoverItemIndex" | "listOpen" | "filterText" | "items" | "focused" | "justValue">;
5
- type Select = ReturnType<typeof Select>;
1
+ import type { ItemLike, SelectProps, SelectRow } from './types.js';
2
+ import type { SelectItem } from './types.js';
3
+ declare function $$render<Item extends ItemLike = SelectItem, Multiple extends boolean = false>(): {
4
+ props: SelectProps<Item, Multiple>;
5
+ exports: {
6
+ getFilteredItems: () => SelectRow<Item>[];
7
+ reset: () => void;
8
+ };
9
+ bindings: "loading" | "value" | "hoverItemIndex" | "listOpen" | "filterText" | "items" | "justValue" | "focused" | "container" | "input";
10
+ slots: {};
11
+ events: {};
12
+ };
13
+ declare class __sveltets_Render<Item extends ItemLike = SelectItem, Multiple extends boolean = false> {
14
+ props(): ReturnType<typeof $$render<Item, Multiple>>['props'];
15
+ events(): ReturnType<typeof $$render<Item, Multiple>>['events'];
16
+ slots(): ReturnType<typeof $$render<Item, Multiple>>['slots'];
17
+ bindings(): "loading" | "value" | "hoverItemIndex" | "listOpen" | "filterText" | "items" | "justValue" | "focused" | "container" | "input";
18
+ exports(): {
19
+ getFilteredItems: () => SelectRow<Item>[];
20
+ reset: () => void;
21
+ };
22
+ }
23
+ interface $$IsomorphicComponent {
24
+ new <Item extends ItemLike = SelectItem, Multiple extends boolean = false>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Item, Multiple>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Item, Multiple>['props']>, ReturnType<__sveltets_Render<Item, Multiple>['events']>, ReturnType<__sveltets_Render<Item, Multiple>['slots']>> & {
25
+ $$bindings?: ReturnType<__sveltets_Render<Item, Multiple>['bindings']>;
26
+ } & ReturnType<__sveltets_Render<Item, Multiple>['exports']>;
27
+ <Item extends ItemLike = SelectItem, Multiple extends boolean = false>(internal: unknown, props: ReturnType<__sveltets_Render<Item, Multiple>['props']> & {}): ReturnType<__sveltets_Render<Item, Multiple>['exports']>;
28
+ z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
29
+ }
30
+ /**
31
+ * A select/autocomplete/typeahead control: a WAI-ARIA combobox with a floating
32
+ * listbox popup. Generic over your item type; supports multi-select, async
33
+ * loading (`loadOptions`), grouping (`groupBy`), and snippet-based customization.
34
+ * Bind `value` (and optionally `justValue`, `filterText`, `listOpen`, `focused`).
35
+ */
36
+ declare const Select: $$IsomorphicComponent;
37
+ type Select<Item extends ItemLike = SelectItem, Multiple extends boolean = false> = InstanceType<typeof Select<Item, Multiple>>;
6
38
  export default Select;
@@ -1,16 +1,19 @@
1
- import type { SelectItem } from './types';
1
+ import type { SelectItem } from './types.js';
2
2
  interface AriaHandlersConfig {
3
3
  ariaValues: (values: string) => string;
4
4
  ariaListOpen: (label: string, count: number) => string;
5
5
  ariaFocused: () => string;
6
+ ariaEmpty?: () => string;
7
+ ariaLoading?: () => string;
6
8
  }
7
9
  interface AriaHandlersContext {
8
- value: string | SelectItem | SelectItem[] | null | undefined;
10
+ value: string | SelectItem | (SelectItem | string)[] | null | undefined;
9
11
  filteredItems: SelectItem[];
10
12
  hoverItemIndex: number;
11
13
  listOpen: boolean;
12
14
  multiple: boolean;
13
15
  label: string;
16
+ loading?: boolean;
14
17
  }
15
18
  export declare function useAriaHandlers(config: AriaHandlersConfig): {
16
19
  handleAriaSelection: (context: AriaHandlersContext) => string;
@@ -1,8 +1,10 @@
1
+ import { isItemSelectableCheck } from './utils.js';
1
2
  export function useAriaHandlers(config) {
2
- const { ariaValues, ariaListOpen, ariaFocused } = config;
3
+ // Read the config properties at call time so live getters (see Select.svelte) stay reactive
3
4
  function handleAriaSelection(context) {
4
5
  const { value, multiple, label } = context;
5
- if (!value)
6
+ // An empty array is no selection: announcing it would claim one exists
7
+ if (!value || (Array.isArray(value) && value.length === 0))
6
8
  return '';
7
9
  let selected = undefined;
8
10
  if (typeof value === 'string') {
@@ -14,19 +16,39 @@ export function useAriaHandlers(config) {
14
16
  else if (!multiple && value) {
15
17
  selected = value[label];
16
18
  }
17
- return ariaValues(selected || '');
19
+ return config.ariaValues(selected || '');
18
20
  }
19
21
  function handleAriaContent(context) {
20
- const { filteredItems, hoverItemIndex, listOpen, label } = context;
21
- if (!filteredItems || filteredItems.length === 0)
22
+ const { filteredItems, hoverItemIndex, listOpen, label, loading } = context;
23
+ if (!filteredItems || filteredItems.length === 0) {
24
+ // The visible list shows a loading/empty state here; silence would leave
25
+ // screen-reader users unable to tell pending results from no results
26
+ if (listOpen) {
27
+ return loading ? (config.ariaLoading?.() ?? 'Loading Data') : (config.ariaEmpty?.() ?? 'No options');
28
+ }
22
29
  return '';
30
+ }
23
31
  const _item = filteredItems[hoverItemIndex];
24
32
  if (listOpen && _item) {
25
- const count = filteredItems.length;
26
- return ariaListOpen(_item[label], count);
33
+ // Presentational group headers are aria-hidden and unreachable by the
34
+ // cursor, so counting them would announce more "results" than a
35
+ // screen-reader user can ever reach. Selectable headers
36
+ // (groupHeaderSelectable) are real options and do count.
37
+ const count = filteredItems.filter((item) => !(item.groupHeader && !item.selectable)).length;
38
+ // This text renders before use-hover's keep-hover-selectable
39
+ // correction runs, so on the first open of a grouped list the cursor
40
+ // is still parked on a non-selectable header. Announce the option the
41
+ // correction is about to land on (the first selectable row — the same
42
+ // index checkHoverSelectable computes) instead of a row the cursor can
43
+ // never reach; the region won't recompute after the correction because
44
+ // hoverItemIndex is deliberately untracked (see Select.svelte).
45
+ const announced = isItemSelectableCheck(_item)
46
+ ? _item
47
+ : (filteredItems.find(isItemSelectableCheck) ?? _item);
48
+ return config.ariaListOpen(announced[label], count);
27
49
  }
28
50
  else {
29
- return ariaFocused();
51
+ return config.ariaFocused();
30
52
  }
31
53
  }
32
54
  return {
package/dist/filter.d.ts CHANGED
@@ -1,2 +1,10 @@
1
- import type { FilterConfig, SelectItem } from './types';
2
- export default function filter({ loadOptions, filterText, items, multiple, value, itemId, groupBy, filterSelectedItems, itemFilter, convertStringItemsToObjects, filterGroupedItems, label, }: FilterConfig): SelectItem[];
1
+ import type { FilterConfig, ItemLike, SelectItem } from './types.js';
2
+ /**
3
+ * The built-in filtering pipeline: converts raw string items, applies
4
+ * `itemFilter` against the filter text (skipped when `loadOptions` is set —
5
+ * loader results are already filtered remotely), hides selected options in
6
+ * multiple mode (`filterSelectedItems`), and applies grouping. Exported so a
7
+ * custom `filter` prop can delegate to it before or after its own logic; see
8
+ * {@link FilterConfig} for what each field carries.
9
+ */
10
+ export default function filter<Item extends ItemLike = SelectItem>({ loadOptions, filterText, items, multiple, value, itemId, groupBy, filterSelectedItems, itemFilter, convertStringItemsToObjects, applyGrouping, label, }: FilterConfig<Item>): SelectItem[];
package/dist/filter.js CHANGED
@@ -1,8 +1,13 @@
1
- import { areItemsEqual, isStringArray } from './utils';
2
- export default function filter({ loadOptions, filterText = '', items, multiple, value, itemId, groupBy, filterSelectedItems, itemFilter, convertStringItemsToObjects, filterGroupedItems, label, }) {
3
- if (items && loadOptions) {
4
- return items;
5
- }
1
+ import { areItemsEqual, isStringArray } from './utils.js';
2
+ /**
3
+ * The built-in filtering pipeline: converts raw string items, applies
4
+ * `itemFilter` against the filter text (skipped when `loadOptions` is set —
5
+ * loader results are already filtered remotely), hides selected options in
6
+ * multiple mode (`filterSelectedItems`), and applies grouping. Exported so a
7
+ * custom `filter` prop can delegate to it before or after its own logic; see
8
+ * {@link FilterConfig} for what each field carries.
9
+ */
10
+ export default function filter({ loadOptions, filterText = '', items, multiple, value, itemId, groupBy, filterSelectedItems, itemFilter, convertStringItemsToObjects, applyGrouping, label, }) {
6
11
  // If no items, return empty array
7
12
  if (!items) {
8
13
  return [];
@@ -12,9 +17,11 @@ export default function filter({ loadOptions, filterText = '', items, multiple,
12
17
  if (aStringArray) {
13
18
  typedItems = convertStringItemsToObjects(typedItems);
14
19
  }
15
- // Filter items based on filter text and selection
20
+ // Filter items based on filter text and selection.
21
+ // With loadOptions the results are already filtered remotely, so skip itemFilter.
22
+ // String items were converted to plain SelectItems above, hence the Item cast.
16
23
  let filterResults = typedItems.filter((item) => {
17
- let matchesFilter = itemFilter(item[label], filterText, item);
24
+ let matchesFilter = loadOptions ? true : itemFilter(item[label], filterText, item);
18
25
  if (matchesFilter && multiple && Array.isArray(value) && value.length > 0) {
19
26
  matchesFilter = !value.some((x) => {
20
27
  return filterSelectedItems ? areItemsEqual(x, item, itemId) : false;
@@ -24,7 +31,7 @@ export default function filter({ loadOptions, filterText = '', items, multiple,
24
31
  });
25
32
  // Apply grouping if groupBy function is provided
26
33
  if (groupBy) {
27
- filterResults = filterGroupedItems(filterResults);
34
+ filterResults = applyGrouping(filterResults);
28
35
  }
29
36
  return filterResults;
30
37
  }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,6 @@ export { default as Select } from './Select.svelte';
2
2
  export { default as ChevronIcon } from './ChevronIcon.svelte';
3
3
  export { default as ClearIcon } from './ClearIcon.svelte';
4
4
  export { default as LoadingIcon } from './LoadingIcon.svelte';
5
- export { default as filter } from './filter';
6
- export { useKeyboardNavigation } from './keyboard-navigation.svelte';
7
- export { isStringArray, isCancelled, areItemsEqual } from './utils';
8
- export type { SelectItem, SelectProps, SelectValue, JustValue, FloatingConfig, FilterConfig, KeyboardNavigationContext, ErrorEvent, } from './types';
5
+ export { default as filter } from './filter.js';
6
+ export { areItemsEqual, isGroupHeader, normalizeItem } from './utils.js';
7
+ export type { ItemLike, SelectItem, SelectGroupHeader, SelectRow, SelectProps, SelectValue, SelectValueProp, SelectClearValue, JustValue, FloatingConfig, FilterConfig, SelectErrorEvent, } from './types.js';
package/dist/index.js CHANGED
@@ -5,6 +5,5 @@ export { default as ChevronIcon } from './ChevronIcon.svelte';
5
5
  export { default as ClearIcon } from './ClearIcon.svelte';
6
6
  export { default as LoadingIcon } from './LoadingIcon.svelte';
7
7
  // Utility functions
8
- export { default as filter } from './filter';
9
- export { useKeyboardNavigation } from './keyboard-navigation.svelte';
10
- export { isStringArray, isCancelled, areItemsEqual } from './utils';
8
+ export { default as filter } from './filter.js';
9
+ export { areItemsEqual, isGroupHeader, normalizeItem } from './utils.js';
@@ -1,12 +1,17 @@
1
- import type { KeyboardNavigationContext } from './types';
2
- export declare function useKeyboardNavigation(context: KeyboardNavigationContext): {
1
+ import type { ItemLike, KeyboardNavigationActions, KeyboardNavigationState, SelectItem } from './types.js';
2
+ export declare function useKeyboardNavigation<Item extends ItemLike = SelectItem>(state: KeyboardNavigationState<Item>, actions: KeyboardNavigationActions): {
3
3
  handleKeyDown: (e: KeyboardEvent) => void;
4
4
  handleEscapeKey: (e: KeyboardEvent) => void;
5
5
  handleEnterKey: (e: KeyboardEvent) => void;
6
+ handleSpaceKey: (e: KeyboardEvent) => void;
6
7
  handleArrowDownKey: (e: KeyboardEvent) => void;
7
8
  handleArrowUpKey: (e: KeyboardEvent) => void;
8
9
  handleTabKey: (e: KeyboardEvent) => void;
9
10
  handleBackspaceKey: (e: KeyboardEvent) => void;
10
11
  handleArrowLeftKey: (e: KeyboardEvent) => void;
11
12
  handleArrowRightKey: (e: KeyboardEvent) => void;
13
+ handleHomeKey: (e: KeyboardEvent) => void;
14
+ handleEndKey: (e: KeyboardEvent) => void;
15
+ handlePageUpKey: (e: KeyboardEvent) => void;
16
+ handlePageDownKey: (e: KeyboardEvent) => void;
12
17
  };
@@ -1,124 +1,370 @@
1
- import { areItemsEqual } from './utils';
2
- export function useKeyboardNavigation(context) {
1
+ import { areItemsEqual, getItemProperty, isItemSelectableCheck } from './utils.js';
2
+ export function useKeyboardNavigation(state, actions) {
3
+ // Handlers claim an event (preventDefault/stopPropagation) only on branches
4
+ // that actually act on it. Unclaimed keys keep bubbling so ancestors still
5
+ // see them — Escape can close a surrounding dialog, Enter can submit a form.
6
+ // Claimed keys must stop propagation so the component's window listener does
7
+ // not handle the same bubbled event a second time.
3
8
  function handleKeyDown(e) {
4
- e.stopPropagation();
5
- const { focused } = context.getState();
6
- if (!focused)
9
+ // The disabled gate is defence in depth: disabling releases focus, but
10
+ // `focused` can lag DOM focus (e.g. the deferred-blur window), and a
11
+ // disabled control must never mutate its value from the keyboard.
12
+ if (!state.focused || state.disabled)
13
+ return;
14
+ // While an IME composition is active every key belongs to the IME:
15
+ // Enter commits the conversion, arrows move through the candidate
16
+ // window, Escape cancels it. Acting on those presses would select the
17
+ // hovered option and wipe the half-composed filter text. keyCode 229
18
+ // catches the WebKit/legacy path where the composition keydown
19
+ // reports isComposing === false.
20
+ if (e.isComposing || e.keyCode === 229)
7
21
  return;
8
22
  const handlers = {
9
- 'Escape': handleEscapeKey,
10
- 'Enter': handleEnterKey,
11
- 'ArrowDown': handleArrowDownKey,
12
- 'ArrowUp': handleArrowUpKey,
13
- 'Tab': handleTabKey,
14
- 'Backspace': handleBackspaceKey,
15
- 'ArrowLeft': handleArrowLeftKey,
16
- 'ArrowRight': handleArrowRightKey,
23
+ Escape: handleEscapeKey,
24
+ Enter: handleEnterKey,
25
+ ' ': handleSpaceKey,
26
+ ArrowDown: handleArrowDownKey,
27
+ ArrowUp: handleArrowUpKey,
28
+ Tab: handleTabKey,
29
+ Backspace: handleBackspaceKey,
30
+ ArrowLeft: handleArrowLeftKey,
31
+ ArrowRight: handleArrowRightKey,
32
+ Home: handleHomeKey,
33
+ End: handleEndKey,
34
+ PageUp: handlePageUpKey,
35
+ PageDown: handlePageDownKey,
17
36
  };
18
37
  const handler = handlers[e.key];
19
38
  if (handler) {
20
39
  handler(e);
40
+ return;
41
+ }
42
+ handleTypeAheadKey(e);
43
+ }
44
+ // Type-ahead for select-only mode (APG combobox pattern): the input is
45
+ // readonly when searchable is false, so printable characters move hover to
46
+ // the next option whose label starts with what was typed. Searchable
47
+ // selects filter by typing instead and never enter this path.
48
+ let typeAheadQuery = '';
49
+ let typeAheadLastKeyTime = 0;
50
+ const TYPE_AHEAD_RESET_MS = 700;
51
+ function handleTypeAheadKey(e) {
52
+ if (state.searchable)
53
+ return;
54
+ if (e.key.length !== 1 || e.ctrlKey || e.metaKey || e.altKey)
55
+ return;
56
+ if (e.key === ' ' && typeAheadQuery === '')
57
+ return; // a bare Space is not a query
58
+ e.preventDefault();
59
+ e.stopPropagation();
60
+ // Event timestamps instead of a reset timer: nothing to clean up on destroy
61
+ if (e.timeStamp - typeAheadLastKeyTime > TYPE_AHEAD_RESET_MS)
62
+ typeAheadQuery = '';
63
+ typeAheadLastKeyTime = e.timeStamp;
64
+ typeAheadQuery += e.key.toLowerCase();
65
+ let openedByThisKey = false;
66
+ if (!state.listOpen) {
67
+ state.listOpen = true;
68
+ state.activeValue = undefined;
69
+ openedByThisKey = true;
70
+ }
71
+ const { filteredItems, hoverItemIndex, label } = state;
72
+ if (filteredItems.length === 0)
73
+ return;
74
+ // Repeating one character cycles through the options with that initial;
75
+ // a growing query refines the match starting from the current option
76
+ const isSameCharRun = typeAheadQuery.length > 1 && typeAheadQuery.split('').every((ch) => ch === typeAheadQuery[0]);
77
+ const query = isSameCharRun ? typeAheadQuery[0] : typeAheadQuery;
78
+ const startOffset = isSameCharRun || typeAheadQuery.length === 1 ? 1 : 0;
79
+ for (let step = 0; step < filteredItems.length; step++) {
80
+ const i = (hoverItemIndex + startOffset + step) % filteredItems.length;
81
+ const item = filteredItems[i];
82
+ if (!isItemSelectableCheck(item))
83
+ continue;
84
+ const itemLabel = String(getItemProperty(item, label) ?? '').toLowerCase();
85
+ if (itemLabel.startsWith(query)) {
86
+ state.hoverItemIndex = i;
87
+ state.userNavigatedSinceOpen = true;
88
+ // Handlers run before effects flush: when this keypress also
89
+ // opened the list, the open-with-value hover effect would fire
90
+ // next and snap hover back to the selected value, clobbering
91
+ // the match we just parked it on. Only an open that found a
92
+ // match suppresses the snap — Arrow/Space opens keep it.
93
+ if (openedByThisKey)
94
+ state.suppressValueHoverSnap = true;
95
+ return;
96
+ }
97
+ }
98
+ }
99
+ // Space has combobox semantics (open, then select the current option) only in
100
+ // select-only mode. In a searchable/editable combobox it is an ordinary
101
+ // character for the filter text, so we leave it entirely alone there.
102
+ function handleSpaceKey(e) {
103
+ if (state.searchable)
104
+ return;
105
+ if (e.ctrlKey || e.metaKey || e.altKey)
106
+ return;
107
+ // While a type-ahead query is still live, Space belongs to it so labels
108
+ // that contain spaces ("New York") stay reachable by typing.
109
+ const typeAheadLive = typeAheadQuery !== '' && e.timeStamp - typeAheadLastKeyTime <= TYPE_AHEAD_RESET_MS;
110
+ if (typeAheadLive) {
111
+ handleTypeAheadKey(e);
112
+ return;
113
+ }
114
+ e.preventDefault();
115
+ e.stopPropagation();
116
+ if (!state.listOpen) {
117
+ state.listOpen = true;
118
+ state.activeValue = undefined;
119
+ return;
120
+ }
121
+ // List open: behave like Enter — select the current option, or close if it
122
+ // is already the (single) selected value.
123
+ const { filteredItems, hoverItemIndex, value, multiple, itemId } = state;
124
+ if (filteredItems.length === 0)
125
+ return;
126
+ const hoverItem = filteredItems[hoverItemIndex];
127
+ if (!multiple && areItemsEqual(value, hoverItem, itemId)) {
128
+ actions.closeList();
129
+ }
130
+ else {
131
+ actions.handleSelect(hoverItem);
21
132
  }
22
133
  }
23
134
  function handleEscapeKey(e) {
135
+ if (!state.listOpen)
136
+ return;
24
137
  e.preventDefault();
25
- context.closeList();
138
+ e.stopPropagation();
139
+ actions.closeList();
26
140
  }
27
141
  function handleEnterKey(e) {
28
- e.preventDefault();
29
- const { listOpen, filteredItems, hoverItemIndex, value, multiple, itemId } = context.getState();
30
- if (!listOpen)
142
+ const { listOpen, filteredItems, hoverItemIndex, value, multiple, itemId } = state;
143
+ if (!listOpen) {
144
+ // Searchable mode leaves Enter alone on a closed list (implicit form
145
+ // submission must keep working), but the select-only combobox
146
+ // pattern (APG, searchable={false}) specifies Enter opens the
147
+ // listbox — a native-<select>-like control must not submit the form
148
+ // instead of opening.
149
+ if (state.searchable)
150
+ return;
151
+ e.preventDefault();
152
+ e.stopPropagation();
153
+ state.listOpen = true;
154
+ state.activeValue = undefined;
31
155
  return;
156
+ }
157
+ e.preventDefault();
158
+ e.stopPropagation();
32
159
  if (filteredItems.length === 0)
33
160
  return;
34
161
  const hoverItem = filteredItems[hoverItemIndex];
35
162
  if (!multiple && areItemsEqual(value, hoverItem, itemId)) {
36
- context.closeList();
163
+ actions.closeList();
37
164
  }
38
165
  else {
39
- context.handleSelect(filteredItems[hoverItemIndex]);
166
+ actions.handleSelect(filteredItems[hoverItemIndex]);
40
167
  }
41
168
  }
42
169
  function handleArrowDownKey(e) {
43
170
  e.preventDefault();
44
- const { listOpen } = context.getState();
45
- if (listOpen) {
46
- context.setHoverIndex(1);
171
+ e.stopPropagation();
172
+ // APG: Alt+Down opens the list without moving the visual cursor.
173
+ if (e.altKey) {
174
+ if (!state.listOpen) {
175
+ state.listOpen = true;
176
+ state.activeValue = undefined;
177
+ }
178
+ return;
179
+ }
180
+ if (state.listOpen) {
181
+ actions.setHoverIndex(1);
182
+ state.userNavigatedSinceOpen = true;
47
183
  }
48
184
  else {
49
- context.setListOpen(true);
50
- context.setActiveValue(undefined);
185
+ state.listOpen = true;
186
+ state.activeValue = undefined;
51
187
  }
52
188
  }
53
189
  function handleArrowUpKey(e) {
54
190
  e.preventDefault();
55
- const { listOpen } = context.getState();
56
- if (listOpen) {
57
- context.setHoverIndex(-1);
191
+ e.stopPropagation();
192
+ // APG: Alt+Up closes the list without changing the selection.
193
+ if (e.altKey) {
194
+ if (state.listOpen)
195
+ actions.closeList();
196
+ return;
197
+ }
198
+ if (state.listOpen) {
199
+ actions.setHoverIndex(-1);
200
+ state.userNavigatedSinceOpen = true;
58
201
  }
59
202
  else {
60
- context.setListOpen(true);
61
- context.setActiveValue(undefined);
203
+ state.listOpen = true;
204
+ state.activeValue = undefined;
62
205
  }
63
206
  }
64
207
  function handleTabKey(e) {
65
- const { listOpen, focused, filteredItems, hoverItemIndex, value, itemId } = context.getState();
208
+ const { listOpen, focused, filteredItems, hoverItemIndex, value, itemId } = state;
66
209
  if (!listOpen || !focused)
67
210
  return;
68
- if (filteredItems.length === 0 || areItemsEqual(value, filteredItems[hoverItemIndex], itemId)) {
69
- context.closeList();
211
+ // Tab is never claimed: committing must close the popup and move focus in
212
+ // the same press (APG), so no preventDefault, and the event keeps bubbling
213
+ // for focus traps and ancestor handlers. Re-entry via the window listener
214
+ // is safe because the list is closed by then.
215
+ //
216
+ // Committing also requires expressed intent: the cursor auto-parks on an
217
+ // option the moment the list opens, so a bare open-then-Tab (click the
218
+ // control, decide against picking, tab away) must close without
219
+ // selecting. Intent is navigating (keys, or real pointer movement over
220
+ // the list), type-ahead, or typing — all of which set the flag at their
221
+ // event sites — and a completed selection consumes it (see
222
+ // itemSelected): with closeListOnChange={false} the list stays open and
223
+ // the cursor re-parks on a neighbour, which tabbing away must not
224
+ // commit. Deliberately NOT the current filterText: a seeded initial
225
+ // value or text retained across a close (clearFilterTextOnBlur={false})
226
+ // is not something the user typed this open.
227
+ if (e.shiftKey || // Tabbing backwards leaves the field; it must never commit
228
+ !state.userNavigatedSinceOpen ||
229
+ filteredItems.length === 0 ||
230
+ areItemsEqual(value, filteredItems[hoverItemIndex], itemId)) {
231
+ actions.closeList();
70
232
  return;
71
233
  }
72
- e.preventDefault();
73
- context.handleSelect(filteredItems[hoverItemIndex]);
74
- context.closeList();
234
+ actions.handleSelect(filteredItems[hoverItemIndex]);
235
+ actions.closeList();
75
236
  }
76
237
  function handleBackspaceKey(e) {
77
- const { multiple, filterText, value, activeValue } = context.getState();
238
+ const { multiple, filterText, value, activeValue } = state;
78
239
  if (!multiple || filterText.length > 0)
79
240
  return;
80
- if (multiple && value && value.length > 0) {
241
+ if (Array.isArray(value) && value.length > 0) {
242
+ e.stopPropagation();
243
+ // A cursor left stale by a mouse removal's reindexing points at
244
+ // nothing: reset it and consume the press, rather than remove a tag
245
+ // the user never targeted
246
+ if (activeValue !== undefined && activeValue >= value.length) {
247
+ state.activeValue = undefined;
248
+ return;
249
+ }
81
250
  const indexToRemove = activeValue !== undefined ? activeValue : value.length - 1;
82
- context.handleMultiItemClear(indexToRemove);
251
+ actions.handleMultiItemClear(indexToRemove);
83
252
  if (activeValue === 0 || activeValue === undefined)
84
253
  return;
85
254
  const newActiveValue = value.length > activeValue ? activeValue - 1 : undefined;
86
- context.setActiveValue(newActiveValue);
255
+ state.activeValue = newActiveValue;
87
256
  }
88
257
  }
89
258
  function handleArrowLeftKey(e) {
90
- const { value, multiple, filterText, activeValue } = context.getState();
259
+ const { value, multiple, filterText, activeValue } = state;
91
260
  if (!value || !multiple || filterText.length > 0)
92
261
  return;
93
262
  if (Array.isArray(value)) {
94
- if (activeValue === undefined) {
95
- context.setActiveValue(value.length - 1);
263
+ e.stopPropagation();
264
+ // `>= length` re-enters like undefined: a stale cursor (mouse
265
+ // removal reindexed the tags) restarts from the last tag
266
+ if (activeValue === undefined || activeValue >= value.length) {
267
+ state.activeValue = value.length - 1;
268
+ }
269
+ else if (activeValue !== 0) {
270
+ state.activeValue = activeValue - 1;
96
271
  }
97
- else if (value.length > activeValue && activeValue !== 0) {
98
- context.setActiveValue(activeValue - 1);
272
+ }
273
+ }
274
+ // Home/End only take over list navigation while no filter text is
275
+ // entered, so text-caret movement in the input keeps working
276
+ function handleHomeKey(e) {
277
+ const { listOpen, filteredItems, filterText } = state;
278
+ if (!listOpen || filterText.length > 0)
279
+ return;
280
+ e.preventDefault();
281
+ e.stopPropagation();
282
+ const firstSelectable = filteredItems.findIndex((item) => isItemSelectableCheck(item));
283
+ if (firstSelectable >= 0) {
284
+ state.hoverItemIndex = firstSelectable;
285
+ state.userNavigatedSinceOpen = true;
286
+ }
287
+ }
288
+ function handleEndKey(e) {
289
+ const { listOpen, filteredItems, filterText } = state;
290
+ if (!listOpen || filterText.length > 0)
291
+ return;
292
+ e.preventDefault();
293
+ e.stopPropagation();
294
+ for (let i = filteredItems.length - 1; i >= 0; i--) {
295
+ if (isItemSelectableCheck(filteredItems[i])) {
296
+ state.hoverItemIndex = i;
297
+ state.userNavigatedSinceOpen = true;
298
+ return;
99
299
  }
100
300
  }
101
301
  }
302
+ // PageUp/PageDown jump the hover by a page of selectable options, clamped to
303
+ // the first/last selectable (no wrap, unlike the Arrow keys). They stay active
304
+ // while filtering: PageUp/PageDown do not move the caret in a single-line
305
+ // input, so there is nothing to conflict with the way Home/End would.
306
+ const PAGE_SIZE = 10;
307
+ function pageHover(direction) {
308
+ const { listOpen, filteredItems } = state;
309
+ if (!listOpen)
310
+ return false;
311
+ const selectable = [];
312
+ for (let i = 0; i < filteredItems.length; i++) {
313
+ if (isItemSelectableCheck(filteredItems[i]))
314
+ selectable.push(i);
315
+ }
316
+ if (selectable.length === 0)
317
+ return false;
318
+ // Where the current hover sits among selectable rows; if it is parked on a
319
+ // non-selectable header, anchor just outside so a full page still moves.
320
+ let pos = selectable.indexOf(state.hoverItemIndex);
321
+ if (pos === -1)
322
+ pos = direction > 0 ? -1 : selectable.length;
323
+ const targetPos = Math.min(selectable.length - 1, Math.max(0, pos + direction * PAGE_SIZE));
324
+ state.hoverItemIndex = selectable[targetPos];
325
+ state.userNavigatedSinceOpen = true;
326
+ return true;
327
+ }
328
+ function handlePageDownKey(e) {
329
+ if (!pageHover(1))
330
+ return;
331
+ e.preventDefault();
332
+ e.stopPropagation();
333
+ }
334
+ function handlePageUpKey(e) {
335
+ if (!pageHover(-1))
336
+ return;
337
+ e.preventDefault();
338
+ e.stopPropagation();
339
+ }
102
340
  function handleArrowRightKey(e) {
103
- const { value, multiple, filterText, activeValue } = context.getState();
341
+ const { value, multiple, filterText, activeValue } = state;
104
342
  if (!value || !multiple || filterText.length > 0 || activeValue === undefined)
105
343
  return;
344
+ if (!Array.isArray(value))
345
+ return;
346
+ e.stopPropagation();
106
347
  if (activeValue === value.length - 1) {
107
- context.setActiveValue(undefined);
348
+ state.activeValue = undefined;
108
349
  }
109
350
  else if (activeValue < value.length - 1) {
110
- context.setActiveValue(activeValue + 1);
351
+ state.activeValue = activeValue + 1;
111
352
  }
112
353
  }
113
354
  return {
114
355
  handleKeyDown,
115
356
  handleEscapeKey,
116
357
  handleEnterKey,
358
+ handleSpaceKey,
117
359
  handleArrowDownKey,
118
360
  handleArrowUpKey,
119
361
  handleTabKey,
120
362
  handleBackspaceKey,
121
363
  handleArrowLeftKey,
122
364
  handleArrowRightKey,
365
+ handleHomeKey,
366
+ handleEndKey,
367
+ handlePageUpKey,
368
+ handlePageDownKey,
123
369
  };
124
370
  }