@primitiv-ui/core 0.1.62 → 0.2.1

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.
@@ -0,0 +1,62 @@
1
+ /**
2
+ * A key press in a combobox's text field, with what the rules read. Each
3
+ * surface builds it from wherever it keeps state — React state, or a custom
4
+ * element's attributes — and applies the outcome back the same way.
5
+ */
6
+ export type ComboboxKeyState = {
7
+ /** The key pressed. */
8
+ key: string;
9
+ /** Whether the popup is open. */
10
+ open: boolean;
11
+ /** The options the cursor can land on, in order: shown and enabled. */
12
+ values: readonly string[];
13
+ /** The option under the cursor (`aria-activedescendant`), if any. */
14
+ active: string | undefined;
15
+ };
16
+ /**
17
+ * What a handled key asks for: open the popup, move the cursor, commit the
18
+ * option under it, or dismiss — close and put the committed text back.
19
+ */
20
+ export type ComboboxKeyOutcome = {
21
+ open: true;
22
+ } | {
23
+ cursor: string;
24
+ } | {
25
+ commit: string;
26
+ } | {
27
+ dismiss: true;
28
+ };
29
+ /**
30
+ * The keyboard contract of a combobox's text field (WAI-ARIA APG combobox).
31
+ * Real focus never leaves the field; the cursor is virtual.
32
+ *
33
+ * - **Closed** — ArrowDown and ArrowUp open the popup without placing a
34
+ * cursor. Every other key, Escape and Enter included, is left to the field
35
+ * and the page, so an enclosing dialog or form still hears them.
36
+ * - **Open** — ArrowDown and ArrowUp move the cursor and wrap; with no cursor
37
+ * (or one on an option that has gone) they enter from the near end. Home and
38
+ * End jump to the ends. Enter commits the option under the cursor, and
39
+ * Escape dismisses.
40
+ *
41
+ * Returns `null` for a key the combobox leaves alone (no `preventDefault`):
42
+ * text keys and the horizontal arrows belong to the field.
43
+ */
44
+ export declare function comboboxKeydown(state: ComboboxKeyState): ComboboxKeyOutcome | null;
45
+ /** The text the user has typed, and the value committed before it. */
46
+ export type ComboboxQueryState = {
47
+ /** The field's text after the keystroke. */
48
+ query: string;
49
+ /** The committed value, `""` when nothing is chosen. */
50
+ value: string;
51
+ };
52
+ /**
53
+ * What a keystroke in the field does. Typing always opens the popup: the user
54
+ * is narrowing a list, so the list has to be on screen to be narrowed. And
55
+ * emptying the field is a deselect, so `clear` asks for the value to be cleared
56
+ * too — only when there is one, so an already-empty field never reports a
57
+ * change the user did not make.
58
+ */
59
+ export declare function comboboxQuery(state: ComboboxQueryState): {
60
+ open: true;
61
+ clear: boolean;
62
+ };
@@ -0,0 +1,50 @@
1
+ import { getKeyToActionMap } from "../keyboard/getKeyToActionMap.js";
2
+ /**
3
+ * The keyboard contract of a combobox's text field (WAI-ARIA APG combobox).
4
+ * Real focus never leaves the field; the cursor is virtual.
5
+ *
6
+ * - **Closed** — ArrowDown and ArrowUp open the popup without placing a
7
+ * cursor. Every other key, Escape and Enter included, is left to the field
8
+ * and the page, so an enclosing dialog or form still hears them.
9
+ * - **Open** — ArrowDown and ArrowUp move the cursor and wrap; with no cursor
10
+ * (or one on an option that has gone) they enter from the near end. Home and
11
+ * End jump to the ends. Enter commits the option under the cursor, and
12
+ * Escape dismisses.
13
+ *
14
+ * Returns `null` for a key the combobox leaves alone (no `preventDefault`):
15
+ * text keys and the horizontal arrows belong to the field.
16
+ */
17
+ export function comboboxKeydown(state) {
18
+ const { key, open, values, active } = state;
19
+ const action = getKeyToActionMap({ orientation: "vertical", homeEnd: true })[key];
20
+ if (!open)
21
+ return action === "next" || action === "prev" ? { open: true } : null;
22
+ if (key === "Escape")
23
+ return { dismiss: true };
24
+ const current = values.indexOf(active);
25
+ if (key === "Enter")
26
+ return current === -1 ? null : { commit: values[current] };
27
+ if (!action || values.length === 0)
28
+ return null;
29
+ const last = values.length - 1;
30
+ let target;
31
+ if (action === "first")
32
+ target = 0;
33
+ else if (action === "last")
34
+ target = last;
35
+ else if (current === -1)
36
+ target = action === "next" ? 0 : last;
37
+ else
38
+ target = action === "next" ? (current + 1) % values.length : (current + last) % values.length;
39
+ return { cursor: values[target] };
40
+ }
41
+ /**
42
+ * What a keystroke in the field does. Typing always opens the popup: the user
43
+ * is narrowing a list, so the list has to be on screen to be narrowed. And
44
+ * emptying the field is a deselect, so `clear` asks for the value to be cleared
45
+ * too — only when there is one, so an already-empty field never reports a
46
+ * change the user did not make.
47
+ */
48
+ export function comboboxQuery(state) {
49
+ return { open: true, clear: state.query === "" && state.value !== "" };
50
+ }
package/dist/index.d.ts CHANGED
@@ -16,3 +16,6 @@ export { modalPointerOutside, modalTabKeydown, type ModalBox, type ModalTabOutco
16
16
  export { panelPlacement, type PanelPlacement, type PlacementBox } from "./placement/placement.js";
17
17
  export { popoverKeydown, popoverPressDismisses, type PopoverKeyOutcome, type PopoverPress, } from "./popover/policy.js";
18
18
  export { TOOLTIP_GRACE_MS, tooltipOpenDelay, type TooltipDelayState } from "./tooltip/policy.js";
19
+ export { LISTBOX_TYPEAHEAD_RESET_MS, listboxKeydown, listboxSeed, listboxSelect, listboxTypeahead, type ListboxKey, type ListboxKeyOutcome, type ListboxOrientation, type ListboxReadingDirection, type ListboxState, type ListboxTypeaheadOutcome, type ListboxTypeaheadState, } from "./listbox/policy.js";
20
+ export { SELECT_OPTION_SELECTOR, selectKeydown } from "./select/policy.js";
21
+ export { comboboxKeydown, comboboxQuery, type ComboboxKeyOutcome, type ComboboxKeyState, type ComboboxQueryState, } from "./combobox/policy.js";
package/dist/index.js CHANGED
@@ -16,3 +16,6 @@ export { modalPointerOutside, modalTabKeydown, } from "./modal/policy.js";
16
16
  export { panelPlacement } from "./placement/placement.js";
17
17
  export { popoverKeydown, popoverPressDismisses, } from "./popover/policy.js";
18
18
  export { TOOLTIP_GRACE_MS, tooltipOpenDelay } from "./tooltip/policy.js";
19
+ export { LISTBOX_TYPEAHEAD_RESET_MS, listboxKeydown, listboxSeed, listboxSelect, listboxTypeahead, } from "./listbox/policy.js";
20
+ export { SELECT_OPTION_SELECTOR, selectKeydown } from "./select/policy.js";
21
+ export { comboboxKeydown, comboboxQuery, } from "./combobox/policy.js";
@@ -0,0 +1,93 @@
1
+ /** Layout axis of the list: which arrow pair moves the cursor. */
2
+ export type ListboxOrientation = "horizontal" | "vertical";
3
+ /** Reading direction. Under `"rtl"` the horizontal arrows are mirrored. */
4
+ export type ListboxReadingDirection = "ltr" | "rtl";
5
+ /**
6
+ * Everything Listbox's selection and keyboard rules depend on, as plain data.
7
+ * Each surface builds it from wherever it keeps state — React state, or a
8
+ * custom element's attributes — and applies the outcome back the same way.
9
+ */
10
+ export type ListboxState = {
11
+ /** The enabled options' values, in DOM order. Disabled options are not navigable. */
12
+ values: readonly string[];
13
+ /** The selected values, in the order they were selected. */
14
+ selected: readonly string[];
15
+ /** The option under the cursor (`aria-activedescendant`), if any. */
16
+ active: string | undefined;
17
+ /** Whether more than one option may be selected (`type="multiple"`). */
18
+ multiple: boolean;
19
+ /** Whether moving the cursor also selects the option it lands on. */
20
+ selectionFollowsFocus: boolean;
21
+ orientation: ListboxOrientation;
22
+ dir: ListboxReadingDirection;
23
+ };
24
+ /** A key press, with the modifiers the keymap reads. */
25
+ export type ListboxKey = {
26
+ key: string;
27
+ shiftKey: boolean;
28
+ ctrlKey: boolean;
29
+ metaKey: boolean;
30
+ altKey: boolean;
31
+ };
32
+ /**
33
+ * What a handled key asks for: where the cursor moves (`null` to leave it) and
34
+ * the selection after it (`null` to leave it). Both `null` is a key the list
35
+ * takes — the caller still prevents its default — that changes nothing, such
36
+ * as Shift+ArrowDown on the last option.
37
+ */
38
+ export type ListboxKeyOutcome = {
39
+ cursor: string | null;
40
+ selected: string[] | null;
41
+ };
42
+ /**
43
+ * The selection after choosing `value`. Single mode replaces the selection —
44
+ * choosing the selected option again keeps it, since a listbox is not a toggle.
45
+ * Multiple mode toggles `value` in place and keeps the rest.
46
+ */
47
+ export declare function listboxSelect(state: Pick<ListboxState, "selected" | "multiple">, value: string): string[];
48
+ /**
49
+ * Where the cursor lands when the list takes focus (APG): the first selected
50
+ * option in list order, else the first option. `undefined` in an empty list.
51
+ */
52
+ export declare function listboxSeed(state: Pick<ListboxState, "values" | "selected">): string | undefined;
53
+ /**
54
+ * Listbox's keyboard contract. The arrows follow `orientation`, mirrored under
55
+ * RTL, and wrap; Home / End jump to the ends; Enter / Space choose the option
56
+ * under the cursor. In multiple mode Shift+Arrow moves and adds without
57
+ * wrapping, Ctrl/Cmd+A selects every option (or none once all are), and
58
+ * Ctrl/Cmd+Shift+Home/End selects from the cursor to that end. Every other
59
+ * chord, and Alt, belongs to the browser and the page. Returns `null` for a key
60
+ * the list leaves alone (no `preventDefault`), including printable characters,
61
+ * which go on to {@link listboxTypeahead}.
62
+ */
63
+ export declare function listboxKeydown(state: ListboxState, press: ListboxKey): ListboxKeyOutcome | null;
64
+ /** How long, in milliseconds, a typeahead query lasts after the last key before it starts afresh. */
65
+ export declare const LISTBOX_TYPEAHEAD_RESET_MS: number;
66
+ /** A key press to search the options by, with the query typed so far. */
67
+ export type ListboxTypeaheadState = {
68
+ /** The query typed so far, lower-cased; empty once it has lapsed. */
69
+ query: string;
70
+ /** The key pressed. */
71
+ key: string;
72
+ /** Each enabled option's text, in order. */
73
+ labels: readonly string[];
74
+ /** The index of the option under the cursor, or `-1` when there is none. */
75
+ active: number;
76
+ };
77
+ /** The query after a typeahead key, and the option to move to (`null` when none matches). */
78
+ export type ListboxTypeaheadOutcome = {
79
+ query: string;
80
+ focus: number | null;
81
+ };
82
+ /**
83
+ * Typeahead (APG): a one-character key extends the query and moves to the
84
+ * first option whose text starts with it, ignoring case and surrounding space.
85
+ * A single character, or one character repeated, searches after the cursor so
86
+ * repeating it cycles through the matches; a longer query may match the option
87
+ * under the cursor. With no cursor the search starts at the top. The search
88
+ * wraps. Returns `null` for a key longer than one character, or when there are
89
+ * no options, which tells the caller to leave the event alone; the caller
90
+ * prevents the default only when an option matched, and lapses the query after
91
+ * {@link LISTBOX_TYPEAHEAD_RESET_MS}.
92
+ */
93
+ export declare function listboxTypeahead({ query, key, labels, active }: ListboxTypeaheadState): ListboxTypeaheadOutcome | null;
@@ -0,0 +1,120 @@
1
+ import { getKeyToActionMap } from "../keyboard/getKeyToActionMap.js";
2
+ /**
3
+ * The selection after choosing `value`. Single mode replaces the selection —
4
+ * choosing the selected option again keeps it, since a listbox is not a toggle.
5
+ * Multiple mode toggles `value` in place and keeps the rest.
6
+ */
7
+ export function listboxSelect(state, value) {
8
+ if (!state.multiple)
9
+ return [value];
10
+ return state.selected.includes(value)
11
+ ? state.selected.filter((selected) => selected !== value)
12
+ : [...state.selected, value];
13
+ }
14
+ /**
15
+ * Where the cursor lands when the list takes focus (APG): the first selected
16
+ * option in list order, else the first option. `undefined` in an empty list.
17
+ */
18
+ export function listboxSeed(state) {
19
+ return state.values.find((value) => state.selected.includes(value)) ?? state.values[0];
20
+ }
21
+ /**
22
+ * Listbox's keyboard contract. The arrows follow `orientation`, mirrored under
23
+ * RTL, and wrap; Home / End jump to the ends; Enter / Space choose the option
24
+ * under the cursor. In multiple mode Shift+Arrow moves and adds without
25
+ * wrapping, Ctrl/Cmd+A selects every option (or none once all are), and
26
+ * Ctrl/Cmd+Shift+Home/End selects from the cursor to that end. Every other
27
+ * chord, and Alt, belongs to the browser and the page. Returns `null` for a key
28
+ * the list leaves alone (no `preventDefault`), including printable characters,
29
+ * which go on to {@link listboxTypeahead}.
30
+ */
31
+ export function listboxKeydown(state, press) {
32
+ const chord = press.ctrlKey || press.metaKey;
33
+ if (chord && !press.altKey && press.key.toLowerCase() === "a") {
34
+ if (!state.multiple)
35
+ return null;
36
+ // An empty list counts as all selected, which selects the same nothing.
37
+ const all = state.values.every((value) => state.selected.includes(value));
38
+ return { cursor: null, selected: all ? [] : [...state.values] };
39
+ }
40
+ if (chord && press.shiftKey && (press.key === "Home" || press.key === "End")) {
41
+ if (!state.multiple)
42
+ return null;
43
+ return extendToEdge(state, press.key === "Home");
44
+ }
45
+ if (chord || press.altKey)
46
+ return null;
47
+ const action = getKeyToActionMap({
48
+ orientation: state.orientation,
49
+ dir: state.dir,
50
+ homeEnd: true,
51
+ activate: true,
52
+ })[press.key];
53
+ if (press.shiftKey && state.multiple && (action === "next" || action === "prev")) {
54
+ return extendSelection(state, action === "next" ? 1 : -1);
55
+ }
56
+ if (!action || state.values.length === 0)
57
+ return null;
58
+ if (action === "activate") {
59
+ if (state.active === undefined)
60
+ return null;
61
+ return { cursor: null, selected: listboxSelect(state, state.active) };
62
+ }
63
+ const last = state.values.length - 1;
64
+ const current = state.values.indexOf(state.active);
65
+ let target;
66
+ if (action === "first")
67
+ target = 0;
68
+ else if (action === "last")
69
+ target = last;
70
+ // No cursor yet (or it sits on an option that has gone): enter from the near end.
71
+ else if (current === -1)
72
+ target = action === "next" ? 0 : last;
73
+ else
74
+ target = action === "next" ? (current + 1) % state.values.length : (current + last) % state.values.length;
75
+ const cursor = state.values[target];
76
+ return { cursor, selected: state.selectionFollowsFocus ? listboxSelect(state, cursor) : null };
77
+ }
78
+ /** Shift+Arrow: move one option and add it, never wrapping past either end. */
79
+ function extendSelection(state, step) {
80
+ const current = state.values.indexOf(state.active);
81
+ const cursor = current === -1 ? undefined : state.values[current + step];
82
+ if (cursor === undefined)
83
+ return { cursor: null, selected: null };
84
+ return { cursor, selected: state.selected.includes(cursor) ? null : [...state.selected, cursor] };
85
+ }
86
+ /** Ctrl+Shift+Home/End: add every option from the cursor to that end, and move there. */
87
+ function extendToEdge(state, toStart) {
88
+ const current = state.values.indexOf(state.active);
89
+ if (current === -1)
90
+ return { cursor: null, selected: null };
91
+ const range = toStart ? state.values.slice(0, current + 1) : state.values.slice(current);
92
+ return {
93
+ cursor: toStart ? state.values[0] : state.values[state.values.length - 1],
94
+ selected: [...state.selected, ...range.filter((value) => !state.selected.includes(value))],
95
+ };
96
+ }
97
+ /** How long, in milliseconds, a typeahead query lasts after the last key before it starts afresh. */
98
+ export const LISTBOX_TYPEAHEAD_RESET_MS = 500;
99
+ /**
100
+ * Typeahead (APG): a one-character key extends the query and moves to the
101
+ * first option whose text starts with it, ignoring case and surrounding space.
102
+ * A single character, or one character repeated, searches after the cursor so
103
+ * repeating it cycles through the matches; a longer query may match the option
104
+ * under the cursor. With no cursor the search starts at the top. The search
105
+ * wraps. Returns `null` for a key longer than one character, or when there are
106
+ * no options, which tells the caller to leave the event alone; the caller
107
+ * prevents the default only when an option matched, and lapses the query after
108
+ * {@link LISTBOX_TYPEAHEAD_RESET_MS}.
109
+ */
110
+ export function listboxTypeahead({ query, key, labels, active }) {
111
+ if (key.length !== 1 || labels.length === 0)
112
+ return null;
113
+ const next = (query + key).toLowerCase();
114
+ const repeated = [...next].every((char) => char === next[0]);
115
+ const search = repeated ? next[0] : next;
116
+ const start = active === -1 ? 0 : active + (search.length === 1 ? 1 : 0);
117
+ const order = [...labels.keys()].map((i) => (start + i) % labels.length);
118
+ const focus = order.find((index) => labels[index].trim().toLowerCase().startsWith(search));
119
+ return { query: next, focus: focus ?? null };
120
+ }
@@ -0,0 +1,11 @@
1
+ import { type MenuKeyOutcome, type MenuKeyState } from "../menu/policy.js";
2
+ /** A select's enabled options, as the selector a host finds them with. */
3
+ export declare const SELECT_OPTION_SELECTOR: string;
4
+ /**
5
+ * The keyboard contract of an open select's listbox: a menu's
6
+ * ({@link menuKeydown}) — the arrows move through the options and wrap, Home
7
+ * and End jump to the ends, Enter and Space choose the focused option — except
8
+ * that Escape closes even when there are no options to move through. Typeahead
9
+ * is `menuTypeahead`'s. Returns `null` for a key the select leaves alone.
10
+ */
11
+ export declare function selectKeydown(state: MenuKeyState): MenuKeyOutcome | null;
@@ -0,0 +1,15 @@
1
+ import { menuKeydown } from "../menu/policy.js";
2
+ /** A select's enabled options, as the selector a host finds them with. */
3
+ export const SELECT_OPTION_SELECTOR = '[role="option"]:not([aria-disabled="true"])';
4
+ /**
5
+ * The keyboard contract of an open select's listbox: a menu's
6
+ * ({@link menuKeydown}) — the arrows move through the options and wrap, Home
7
+ * and End jump to the ends, Enter and Space choose the focused option — except
8
+ * that Escape closes even when there are no options to move through. Typeahead
9
+ * is `menuTypeahead`'s. Returns `null` for a key the select leaves alone.
10
+ */
11
+ export function selectKeydown(state) {
12
+ if (state.key === "Escape")
13
+ return { open: false };
14
+ return menuKeydown(state);
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitiv-ui/core",
3
- "version": "0.1.62",
3
+ "version": "0.2.1",
4
4
  "description": "The framework-free behaviour core shared by every Primitiv surface — keyboard maps, roving-focus targets, id derivation and per-component policy, as pure functions with no DOM and no dependencies.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",