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.
- package/README.md +255 -115
- package/dist/ChevronIcon.svelte +14 -13
- package/dist/ChevronIcon.svelte.d.ts +6 -14
- package/dist/ClearIcon.svelte +9 -11
- package/dist/ClearIcon.svelte.d.ts +6 -14
- package/dist/LoadingIcon.svelte +15 -2
- package/dist/LoadingIcon.svelte.d.ts +6 -14
- package/dist/Select.svelte +1048 -442
- package/dist/Select.svelte.d.ts +37 -5
- package/dist/aria-handlers.svelte.d.ts +5 -2
- package/dist/aria-handlers.svelte.js +30 -8
- package/dist/filter.d.ts +10 -2
- package/dist/filter.js +15 -8
- package/dist/index.d.ts +3 -4
- package/dist/index.js +2 -3
- package/dist/keyboard-navigation.svelte.d.ts +7 -2
- package/dist/keyboard-navigation.svelte.js +293 -47
- package/dist/no-styles/ChevronIcon.svelte +17 -0
- package/dist/no-styles/ChevronIcon.svelte.d.ts +18 -0
- package/dist/no-styles/ClearIcon.svelte +14 -0
- package/dist/no-styles/ClearIcon.svelte.d.ts +18 -0
- package/dist/no-styles/LoadingIcon.svelte +19 -0
- package/dist/no-styles/LoadingIcon.svelte.d.ts +18 -0
- package/dist/no-styles/Select.svelte +1452 -0
- package/dist/no-styles/Select.svelte.d.ts +38 -0
- package/dist/select-state.svelte.d.ts +15 -0
- package/dist/select-state.svelte.js +161 -0
- package/dist/styles/default.css +131 -71
- package/dist/tailwind.css +139 -17
- package/dist/types.d.ts +488 -129
- package/dist/use-hover.svelte.d.ts +3 -7
- package/dist/use-hover.svelte.js +110 -36
- package/dist/use-load-options.svelte.d.ts +18 -3
- package/dist/use-load-options.svelte.js +362 -42
- package/dist/use-value.svelte.d.ts +2 -11
- package/dist/use-value.svelte.js +334 -111
- package/dist/utils.d.ts +19 -8
- package/dist/utils.js +52 -8
- package/package.json +121 -94
package/dist/Select.svelte.d.ts
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
|
-
import type { SelectProps } from './types';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 =
|
|
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 {
|
|
7
|
-
export {
|
|
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 {
|
|
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 {
|
|
2
|
-
export declare function useKeyboardNavigation(
|
|
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(
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
'
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
138
|
+
e.stopPropagation();
|
|
139
|
+
actions.closeList();
|
|
26
140
|
}
|
|
27
141
|
function handleEnterKey(e) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
163
|
+
actions.closeList();
|
|
37
164
|
}
|
|
38
165
|
else {
|
|
39
|
-
|
|
166
|
+
actions.handleSelect(filteredItems[hoverItemIndex]);
|
|
40
167
|
}
|
|
41
168
|
}
|
|
42
169
|
function handleArrowDownKey(e) {
|
|
43
170
|
e.preventDefault();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
185
|
+
state.listOpen = true;
|
|
186
|
+
state.activeValue = undefined;
|
|
51
187
|
}
|
|
52
188
|
}
|
|
53
189
|
function handleArrowUpKey(e) {
|
|
54
190
|
e.preventDefault();
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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 } =
|
|
208
|
+
const { listOpen, focused, filteredItems, hoverItemIndex, value, itemId } = state;
|
|
66
209
|
if (!listOpen || !focused)
|
|
67
210
|
return;
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
context.closeList();
|
|
234
|
+
actions.handleSelect(filteredItems[hoverItemIndex]);
|
|
235
|
+
actions.closeList();
|
|
75
236
|
}
|
|
76
237
|
function handleBackspaceKey(e) {
|
|
77
|
-
const { multiple, filterText, value, activeValue } =
|
|
238
|
+
const { multiple, filterText, value, activeValue } = state;
|
|
78
239
|
if (!multiple || filterText.length > 0)
|
|
79
240
|
return;
|
|
80
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
255
|
+
state.activeValue = newActiveValue;
|
|
87
256
|
}
|
|
88
257
|
}
|
|
89
258
|
function handleArrowLeftKey(e) {
|
|
90
|
-
const { value, multiple, filterText, activeValue } =
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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 } =
|
|
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
|
-
|
|
348
|
+
state.activeValue = undefined;
|
|
108
349
|
}
|
|
109
350
|
else if (activeValue < value.length - 1) {
|
|
110
|
-
|
|
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
|
}
|