@lyeve-labs/ui-kit 0.11.2 → 0.13.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 (101) hide show
  1. package/README.md +1 -1
  2. package/dist/components/AccordionItem.svelte +1 -1
  3. package/dist/components/Autocomplete.svelte +191 -125
  4. package/dist/components/Autocomplete.svelte.d.ts +29 -8
  5. package/dist/components/Button.svelte +26 -4
  6. package/dist/components/Card.svelte +61 -3
  7. package/dist/components/Card.svelte.d.ts +24 -2
  8. package/dist/components/Checkbox.svelte +174 -59
  9. package/dist/components/Checkbox.svelte.d.ts +20 -3
  10. package/dist/components/CheckboxGroup.svelte +162 -0
  11. package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
  12. package/dist/components/Collapsible.svelte +142 -0
  13. package/dist/components/Collapsible.svelte.d.ts +32 -0
  14. package/dist/components/CopyButton.svelte +126 -0
  15. package/dist/components/CopyButton.svelte.d.ts +14 -0
  16. package/dist/components/DatePicker.svelte +48 -6
  17. package/dist/components/DateTimePicker.svelte +337 -0
  18. package/dist/components/DateTimePicker.svelte.d.ts +26 -0
  19. package/dist/components/DescriptionList.svelte +78 -0
  20. package/dist/components/DescriptionList.svelte.d.ts +34 -0
  21. package/dist/components/Drawer.svelte +15 -4
  22. package/dist/components/Field.svelte +104 -0
  23. package/dist/components/Field.svelte.d.ts +46 -0
  24. package/dist/components/FileInput.svelte +5 -2
  25. package/dist/components/FormMessage.svelte +85 -0
  26. package/dist/components/FormMessage.svelte.d.ts +11 -0
  27. package/dist/components/Input.svelte +1 -1
  28. package/dist/components/Label.svelte +7 -1
  29. package/dist/components/Label.svelte.d.ts +6 -0
  30. package/dist/components/Modal.svelte +25 -8
  31. package/dist/components/MultiSelect.svelte +199 -109
  32. package/dist/components/MultiSelect.svelte.d.ts +22 -9
  33. package/dist/components/NumberInput.svelte +8 -4
  34. package/dist/components/PageHeader.svelte +37 -4
  35. package/dist/components/PageHeader.svelte.d.ts +15 -0
  36. package/dist/components/PageShell.svelte +85 -0
  37. package/dist/components/PageShell.svelte.d.ts +38 -0
  38. package/dist/components/Pagination.svelte +58 -17
  39. package/dist/components/Panel.svelte +101 -0
  40. package/dist/components/Panel.svelte.d.ts +39 -0
  41. package/dist/components/PasswordInput.svelte +139 -0
  42. package/dist/components/PasswordInput.svelte.d.ts +29 -0
  43. package/dist/components/Radio.svelte +152 -32
  44. package/dist/components/Radio.svelte.d.ts +16 -1
  45. package/dist/components/RadioGroup.svelte +118 -71
  46. package/dist/components/RadioGroup.svelte.d.ts +39 -9
  47. package/dist/components/SectionHeading.svelte +39 -0
  48. package/dist/components/SectionHeading.svelte.d.ts +21 -0
  49. package/dist/components/SegmentedControl.svelte +194 -0
  50. package/dist/components/SegmentedControl.svelte.d.ts +55 -0
  51. package/dist/components/Select.svelte +471 -46
  52. package/dist/components/Select.svelte.d.ts +95 -6
  53. package/dist/components/SidebarNav.svelte +259 -0
  54. package/dist/components/SidebarNav.svelte.d.ts +17 -0
  55. package/dist/components/Stat.svelte +53 -2
  56. package/dist/components/Stat.svelte.d.ts +31 -0
  57. package/dist/components/Textarea.svelte +1 -1
  58. package/dist/components/TimePicker.svelte +480 -0
  59. package/dist/components/TimePicker.svelte.d.ts +23 -0
  60. package/dist/components/Toaster.svelte +9 -2
  61. package/dist/components/Toggle.svelte +5 -1
  62. package/dist/components/Toggle.svelte.d.ts +2 -0
  63. package/dist/components/Toolbar.svelte +39 -0
  64. package/dist/components/Toolbar.svelte.d.ts +26 -0
  65. package/dist/components/Tooltip.svelte +48 -12
  66. package/dist/components/TreeView.svelte +339 -0
  67. package/dist/components/TreeView.svelte.d.ts +37 -0
  68. package/dist/components/dialog/Dialog.svelte +15 -58
  69. package/dist/components/dialog/dialog-manager.svelte.d.ts +2 -2
  70. package/dist/components/dialog/dialog-manager.svelte.js +21 -21
  71. package/dist/index.d.ts +25 -1
  72. package/dist/index.js +20 -1
  73. package/dist/internal/calendar.d.ts +119 -0
  74. package/dist/internal/calendar.js +225 -0
  75. package/dist/internal/choice.d.ts +136 -0
  76. package/dist/internal/choice.js +179 -0
  77. package/dist/internal/field.d.ts +31 -0
  78. package/dist/internal/field.js +42 -1
  79. package/dist/internal/filter.d.ts +80 -0
  80. package/dist/internal/filter.js +80 -0
  81. package/dist/internal/layout.d.ts +119 -0
  82. package/dist/internal/layout.js +132 -0
  83. package/dist/internal/listbox.svelte.d.ts +77 -0
  84. package/dist/internal/listbox.svelte.js +438 -0
  85. package/dist/internal/nav-expansion.svelte.d.ts +36 -0
  86. package/dist/internal/nav-expansion.svelte.js +144 -0
  87. package/dist/internal/nav-tree.d.ts +68 -0
  88. package/dist/internal/nav-tree.js +102 -0
  89. package/dist/internal/overlay.d.ts +25 -0
  90. package/dist/internal/overlay.js +92 -0
  91. package/dist/internal/panel.d.ts +100 -0
  92. package/dist/internal/panel.js +109 -0
  93. package/dist/internal/rollup.d.ts +52 -0
  94. package/dist/internal/rollup.js +67 -0
  95. package/dist/internal/time.d.ts +103 -0
  96. package/dist/internal/time.js +166 -0
  97. package/dist/internal/tree.d.ts +86 -0
  98. package/dist/internal/tree.js +111 -0
  99. package/dist/styles/theme.css +66 -25
  100. package/package.json +4 -2
  101. package/src/lib/styles/theme.css +66 -25
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The single source of truth for how a page, a card, a table and a modal are
3
+ * spaced.
4
+ *
5
+ * Nothing in the library owned the page frame, so every page invented one. The
6
+ * same gutter ships in four spellings, five content caps are in use with no
7
+ * rule for picking between them, a section heading is spelled fourteen ways,
8
+ * and card surfaces are hand-rolled in nine paddings while Card itself goes
9
+ * unused. The components disagree with each other too: Card pads its header
10
+ * 16px down and its footer 12px down for no reason a reader can infer, and
11
+ * Modal insets its panel 20px where Dialog insets the same kind of panel 24px.
12
+ *
13
+ * Every value composes from a `--spacing-*` token rather than a Tailwind
14
+ * number. That is the only thing that makes the tokens real. Of the ten the
15
+ * theme declares, `--spacing-control` was the one with any uses, and it had
16
+ * them because the field contract composes from it.
17
+ *
18
+ * Not exported from the package entry point - this is an implementation detail.
19
+ */
20
+ /**
21
+ * The gutter a page sits in, stated once.
22
+ *
23
+ * Two pages in the same shell started their content at different distances
24
+ * from the edge because each spelled its own gutter. The horizontal and
25
+ * vertical tokens resolve to the same 24px and keep separate names, so a
26
+ * design that wants a taller page gutter changes one token, not every page.
27
+ */
28
+ export const PAGE_PAD = 'mx-auto w-full px-page-x py-page-y';
29
+ /**
30
+ * Content cap by name. Four named slots replace the five raw max-w values
31
+ * chosen per page with no rule.
32
+ *
33
+ * `narrow` is one column: a form, a settings pane, a page of prose. `default`
34
+ * is a page of stacked cards. `wide` is a data page whose table needs the
35
+ * room. `full` opts out, for a canvas or a split pane that owns the viewport.
36
+ * The names carry the decision, so a page picks a role rather than a number.
37
+ */
38
+ export const PAGE_WIDTH = {
39
+ narrow: 'max-w-3xl',
40
+ default: 'max-w-5xl',
41
+ wide: 'max-w-7xl',
42
+ full: 'max-w-full',
43
+ };
44
+ /**
45
+ * The vertical rhythm between a page's top-level sections. A property of the
46
+ * shell, so a page cannot choose its own.
47
+ *
48
+ * PageHeader already drops 32px below the title and every page that sets a
49
+ * section gap sets the same 32px, so the value was agreed and unstated. A gap
50
+ * on the shell also means adding a section is appending a child, rather than
51
+ * remembering to put a margin on it.
52
+ */
53
+ export const PAGE_STACK = 'flex flex-col gap-section';
54
+ /**
55
+ * The card surface, without its padding.
56
+ *
57
+ * Padding is separate because a card wrapping a table or a list wants its
58
+ * children flush to the border. `overflow-hidden` is deliberately absent: the
59
+ * focus ring sits 2px outside the element it belongs to, so a clipping surface
60
+ * crops the ring of every button inside it down to whichever edge fits.
61
+ */
62
+ export const CARD_SURFACE = 'bg-surface border border-line rounded-xl';
63
+ /**
64
+ * Card padding by name.
65
+ *
66
+ * `md` is the 20px the theme names `--spacing-card`: the measured mode across
67
+ * the card surfaces in use, and what Card itself paints. `lg` is the page
68
+ * gutter, so a card padded `lg` holds its content on the same rhythm as the
69
+ * page around it. Nine hand-rolled paddings collapse onto these four.
70
+ */
71
+ export const CARD_PAD = {
72
+ none: '',
73
+ sm: 'p-card-sm',
74
+ md: 'p-card',
75
+ lg: 'p-page-x',
76
+ };
77
+ /**
78
+ * The band above a card's content.
79
+ *
80
+ * Card insets its header 20px across and 16px down, and its footer 20px across
81
+ * and 12px down. Nothing tells the two bands apart, so they share one inset
82
+ * here and differ only in which edge carries the rule.
83
+ */
84
+ export const CARD_HEADER = 'px-card py-card-sm border-b border-line';
85
+ /** The band below a card's content. CARD_HEADER's inset, with the rule on top. */
86
+ export const CARD_FOOTER = 'px-card py-card-sm border-t border-line bg-surface-2/40';
87
+ /**
88
+ * A placeholder inside a card, where three spellings of the same centred muted
89
+ * line currently ship.
90
+ *
91
+ * An empty list is not an error, so it reads as muted body copy and not as a
92
+ * warning. The section gap above and below keeps a card holding nothing from
93
+ * collapsing to a single line of text.
94
+ */
95
+ export const CARD_EMPTY = 'py-section text-center text-sm text-muted';
96
+ /**
97
+ * The head cell of a table: its padding and the type treatment that marks it
98
+ * as a label rather than data.
99
+ *
100
+ * The hand-rolled tables split four ways on cell padding, so two tables on one
101
+ * page ran at different row heights. Horizontal is the compact card step, so a
102
+ * full-bleed table inside a card lines its first column up with the card's own
103
+ * text. Vertical is the control step: 12px and 8px were both already in use in
104
+ * near equal numbers, and 8px is the one the token scale names.
105
+ */
106
+ export const TABLE_CELL_HEAD = 'px-card-sm py-input-y text-xs font-medium uppercase tracking-wider whitespace-nowrap text-faint';
107
+ /** The body cell of a table. TABLE_CELL_HEAD's padding, at body weight and colour. */
108
+ export const TABLE_CELL_BODY = 'px-card-sm py-input-y text-fg align-middle';
109
+ /**
110
+ * The gutter every modal surface uses. Modal paints 20px and Dialog paints
111
+ * 24px for the same kind of surface.
112
+ *
113
+ * A dialog opened over a modal showed both insets at once. A modal panel is a
114
+ * card lifted off the page, so a banded modal takes CARD_HEADER and
115
+ * CARD_FOOTER, which resolve to this same inset.
116
+ */
117
+ export const MODAL_PAD = 'px-card py-card-sm';
118
+ /**
119
+ * A section heading below the page title. Level 2 sits under the title, level 3
120
+ * inside a card.
121
+ *
122
+ * Fourteen distinct class strings serve this role, so two sections on the same
123
+ * page can render at different sizes and weights. Taking the level rather than
124
+ * a free-form string means the class cannot disagree with the heading element
125
+ * the caller is already writing.
126
+ */
127
+ export function sectionHeading(level) {
128
+ // Level 3 drops a size rather than a weight. Inside a card it sits under the
129
+ // card's own semibold title, and two semibold lines at the same size read as
130
+ // one heading broken in half.
131
+ return level === 2 ? 'text-lg font-semibold text-fg' : 'text-sm font-semibold text-fg';
132
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * One owner for the open state, the active row, the keyboard model and the
3
+ * dismissal that every list-bearing control needs.
4
+ *
5
+ * MultiSelect, Autocomplete, DatePicker and Dropdown each hand-rolled all four,
6
+ * and every copy is wrong somewhere different. The dismiss effect is written
7
+ * out four times: MultiSelect.svelte:84-93, DatePicker.svelte:151-160 and
8
+ * Dropdown.svelte:45-54 are byte identical, and Autocomplete.svelte:117-120 is
9
+ * the same minus the keydown, so Escape does nothing there at all.
10
+ * Autocomplete.svelte:90-107 is the kit's only arrow-key implementation and it
11
+ * is incomplete: ArrowUp on a closed list decrements the index without opening
12
+ * anything, Home and End do nothing, there is no typeahead and there is no
13
+ * wrap. Nothing in the kit sets aria-activedescendant, so a screen reader is
14
+ * never told which row the keyboard is resting on, and Autocomplete marks that
15
+ * row with a background tint alone, which reads 1.09:1. The rows are buttons
16
+ * carrying role="option" and no tabindex, so Tab walks into the list instead of
17
+ * leaving the field. Autocomplete.svelte:146 closes on a 150ms blur timer, so
18
+ * clicking an option works only because mousedown-to-click beats the timer. And
19
+ * no copy stops the Escape event, so a listbox inside a Modal closes both.
20
+ *
21
+ * One thing deliberately stays at the call site: what a selection means. This
22
+ * fires onSelect and leaves the list open, because MultiSelect collects several
23
+ * values in one pass and a factory that closed on every pick could not serve
24
+ * it. A single-value control calls close('select') from its own onSelect, which
25
+ * is why that reason exists.
26
+ *
27
+ * Not exported from the package entry point - this is an implementation detail.
28
+ */
29
+ /** The least a row has to be for the keyboard model to work on it. */
30
+ export interface ListboxItem {
31
+ value: string;
32
+ label: string;
33
+ disabled?: boolean;
34
+ }
35
+ /**
36
+ * Why the list closed. A consumer that resets a search query on dismissal but
37
+ * keeps it on a pick needs to tell the two apart, and the four controls each
38
+ * guessed.
39
+ */
40
+ export type ListboxCloseReason = 'escape' | 'select' | 'outside' | 'focusout' | 'tab';
41
+ export interface ListboxConfig<T extends ListboxItem> {
42
+ items: () => readonly T[];
43
+ /** Stable across the SSR boundary. Pass $props.id(). */
44
+ baseId: () => string;
45
+ onSelect: (item: T, index: number) => void;
46
+ onOpenChange?: (open: boolean) => void;
47
+ onClose?: (reason: ListboxCloseReason) => void;
48
+ /** Typeahead jumps to the next item whose label starts with the typed run. Off for a control with its own text input, where letters are query text. */
49
+ typeahead?: () => boolean;
50
+ /** The active row wraps past the ends. */
51
+ loop?: () => boolean;
52
+ }
53
+ export interface Listbox {
54
+ readonly open: boolean;
55
+ readonly activeIndex: number;
56
+ /** Attributes for the trigger or the combobox input. */
57
+ readonly triggerAttrs: Record<string, string | undefined>;
58
+ /** Attributes for the panel. */
59
+ readonly listAttrs: Record<string, string | undefined>;
60
+ /** Attributes for one row. tabindex is -1: focus stays on the trigger and position travels by aria-activedescendant. */
61
+ optionAttrs(index: number): Record<string, string | number | undefined>;
62
+ openList(active?: number): void;
63
+ close(reason: ListboxCloseReason): void;
64
+ toggle(): void;
65
+ setActive(index: number): void;
66
+ /** Returns true when it consumed the event. */
67
+ onkeydown(event: KeyboardEvent): boolean;
68
+ /** use:listbox.anchor on the wrapper. Registers outside-click and focusout dismissal. */
69
+ anchor: (node: HTMLElement) => {
70
+ destroy(): void;
71
+ };
72
+ /** use:listbox.panel on the panel. Keeps the active row scrolled into view. */
73
+ panel: (node: HTMLElement) => {
74
+ destroy(): void;
75
+ };
76
+ }
77
+ export declare function createListbox<T extends ListboxItem>(config: ListboxConfig<T>): Listbox;
@@ -0,0 +1,438 @@
1
+ /**
2
+ * One owner for the open state, the active row, the keyboard model and the
3
+ * dismissal that every list-bearing control needs.
4
+ *
5
+ * MultiSelect, Autocomplete, DatePicker and Dropdown each hand-rolled all four,
6
+ * and every copy is wrong somewhere different. The dismiss effect is written
7
+ * out four times: MultiSelect.svelte:84-93, DatePicker.svelte:151-160 and
8
+ * Dropdown.svelte:45-54 are byte identical, and Autocomplete.svelte:117-120 is
9
+ * the same minus the keydown, so Escape does nothing there at all.
10
+ * Autocomplete.svelte:90-107 is the kit's only arrow-key implementation and it
11
+ * is incomplete: ArrowUp on a closed list decrements the index without opening
12
+ * anything, Home and End do nothing, there is no typeahead and there is no
13
+ * wrap. Nothing in the kit sets aria-activedescendant, so a screen reader is
14
+ * never told which row the keyboard is resting on, and Autocomplete marks that
15
+ * row with a background tint alone, which reads 1.09:1. The rows are buttons
16
+ * carrying role="option" and no tabindex, so Tab walks into the list instead of
17
+ * leaving the field. Autocomplete.svelte:146 closes on a 150ms blur timer, so
18
+ * clicking an option works only because mousedown-to-click beats the timer. And
19
+ * no copy stops the Escape event, so a listbox inside a Modal closes both.
20
+ *
21
+ * One thing deliberately stays at the call site: what a selection means. This
22
+ * fires onSelect and leaves the list open, because MultiSelect collects several
23
+ * values in one pass and a factory that closed on every pick could not serve
24
+ * it. A single-value control calls close('select') from its own onSelect, which
25
+ * is why that reason exists.
26
+ *
27
+ * Not exported from the package entry point - this is an implementation detail.
28
+ */
29
+ import { normalize } from './filter.js';
30
+ /**
31
+ * How long a typed run stays open for another character.
32
+ *
33
+ * A pause longer than this starts a new run, so "ne" pauses "n" reaches the
34
+ * first N again rather than searching for a label starting "nn".
35
+ */
36
+ const TYPEAHEAD_WINDOW_MS = 500;
37
+ /**
38
+ * What counts as the trigger inside an anchor: the first element in the tab
39
+ * sequence. Rows are excluded by construction, since optionAttrs gives every
40
+ * one of them tabindex -1.
41
+ */
42
+ const TRIGGER = 'input:not([disabled]), button:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
43
+ export function createListbox(config) {
44
+ let open = $state(false);
45
+ let activeIndex = $state(-1);
46
+ let anchorNode = null;
47
+ let panelNode = null;
48
+ let listening = false;
49
+ // The typed run and when it was last extended. Plain variables rather than
50
+ // $state: nothing renders them, and a reactive keystroke buffer would
51
+ // invalidate every reader of the list on every letter.
52
+ let typed = '';
53
+ let typedAt = 0;
54
+ const items = () => config.items();
55
+ const loop = () => config.loop?.() ?? false;
56
+ const typeahead = () => config.typeahead?.() ?? false;
57
+ const listId = () => `${config.baseId()}-list`;
58
+ const optionId = (index) => `${config.baseId()}-option-${index}`;
59
+ /**
60
+ * The active row, clamped into the list as it stands now.
61
+ *
62
+ * A filter that narrows the list under an active index leaves the index
63
+ * pointing past the end, and aria-activedescendant then names an element that
64
+ * was never rendered, which a screen reader reports as nothing at all.
65
+ * Clamping on read rather than on write means the answer is right even when
66
+ * the list changed without anyone telling the listbox.
67
+ */
68
+ function activeRow() {
69
+ const count = items().length;
70
+ if (count === 0 || activeIndex < 0)
71
+ return -1;
72
+ return activeIndex < count ? activeIndex : count - 1;
73
+ }
74
+ function selectable(index) {
75
+ const item = items()[index];
76
+ return item !== undefined && item.disabled !== true;
77
+ }
78
+ function firstSelectable() {
79
+ const count = items().length;
80
+ for (let i = 0; i < count; i++) {
81
+ if (selectable(i))
82
+ return i;
83
+ }
84
+ return -1;
85
+ }
86
+ function lastSelectable() {
87
+ for (let i = items().length - 1; i >= 0; i--) {
88
+ if (selectable(i))
89
+ return i;
90
+ }
91
+ return -1;
92
+ }
93
+ /**
94
+ * The next selectable row in one direction.
95
+ *
96
+ * Disabled rows are stepped over rather than landed on, because Enter refuses
97
+ * them: resting the active ring on a row that cannot be chosen tells the user
98
+ * the opposite of the truth. With loop off the move holds at the row it
99
+ * started from, so arrowing into the end of the list does not silently do
100
+ * nothing visible and then jump on the next press.
101
+ */
102
+ function step(from, direction) {
103
+ const count = items().length;
104
+ if (count === 0)
105
+ return -1;
106
+ if (from < 0)
107
+ return direction === 1 ? firstSelectable() : lastSelectable();
108
+ let cursor = from;
109
+ for (let taken = 0; taken < count; taken++) {
110
+ cursor += direction;
111
+ if (cursor < 0 || cursor >= count) {
112
+ if (!loop())
113
+ return from;
114
+ cursor = cursor < 0 ? count - 1 : 0;
115
+ }
116
+ if (selectable(cursor))
117
+ return cursor;
118
+ }
119
+ return from;
120
+ }
121
+ function moveTo(index) {
122
+ if (index < 0)
123
+ return;
124
+ activeIndex = index;
125
+ scrollActiveIntoView();
126
+ }
127
+ /**
128
+ * Scrolls the active row far enough to be seen and no further.
129
+ *
130
+ * block: 'nearest' rather than 'center', so arrowing one row down moves the
131
+ * list by one row instead of jumping the whole panel to put that row in the
132
+ * middle of it.
133
+ */
134
+ function scrollActiveIntoView() {
135
+ const node = panelNode;
136
+ if (node === null || !open || typeof document === 'undefined')
137
+ return;
138
+ const index = activeRow();
139
+ if (index < 0)
140
+ return;
141
+ const row = document.getElementById(optionId(index));
142
+ if (row === null || !node.contains(row))
143
+ return;
144
+ // A DOM without scrollIntoView must not take the keyboard down with it.
145
+ if (typeof row.scrollIntoView !== 'function')
146
+ return;
147
+ row.scrollIntoView({ block: 'nearest' });
148
+ }
149
+ function openList(index) {
150
+ const fallback = activeRow() >= 0 && selectable(activeRow()) ? activeRow() : firstSelectable();
151
+ const wanted = index === undefined ? fallback : index;
152
+ activeIndex = wanted >= 0 && wanted < items().length && selectable(wanted) ? wanted : fallback;
153
+ if (!open) {
154
+ open = true;
155
+ listenForOutside();
156
+ config.onOpenChange?.(true);
157
+ }
158
+ scrollActiveIntoView();
159
+ }
160
+ function closeList(reason) {
161
+ // Idempotent because two dismissals race on every outside click: the
162
+ // pointer closes the list and the focus leaving the field closes it again.
163
+ if (!open)
164
+ return;
165
+ open = false;
166
+ stopListeningForOutside();
167
+ config.onOpenChange?.(false);
168
+ config.onClose?.(reason);
169
+ }
170
+ function toggleList() {
171
+ // A trigger click that closes an open list is a dismissal with nothing
172
+ // chosen, which is what every consumer of 'outside' already does.
173
+ if (open)
174
+ closeList('outside');
175
+ else
176
+ openList();
177
+ }
178
+ function setActive(index) {
179
+ if (index < 0) {
180
+ activeIndex = -1;
181
+ return;
182
+ }
183
+ // A pointer must not put the active ring somewhere the keyboard refuses to
184
+ // go, or hovering a disabled row promises an Enter that will not fire.
185
+ if (index >= items().length || !selectable(index))
186
+ return;
187
+ moveTo(index);
188
+ }
189
+ function selectActive() {
190
+ const index = activeRow();
191
+ if (index < 0)
192
+ return false;
193
+ const item = items()[index];
194
+ if (item === undefined || item.disabled === true)
195
+ return false;
196
+ config.onSelect(item, index);
197
+ return true;
198
+ }
199
+ /**
200
+ * Returns focus to the field itself.
201
+ *
202
+ * Rows are never in the tab sequence, so focus is usually still on the
203
+ * trigger and this is a no-op. It matters after a pointer lands on a row,
204
+ * which focuses it in every browser that supports tabindex -1.
205
+ */
206
+ function focusTrigger() {
207
+ const node = anchorNode;
208
+ if (node === null)
209
+ return;
210
+ const trigger = node.querySelector(TRIGGER);
211
+ if (trigger !== null)
212
+ trigger.focus();
213
+ }
214
+ /**
215
+ * Matches the accumulated run against the labels.
216
+ *
217
+ * A one-character run searches from the row after the active one, so pressing
218
+ * N repeatedly cycles the N entries. A longer run searches from the active
219
+ * row itself, so typing "ne" lands on Netherlands rather than skipping past
220
+ * the row "n" just reached. Comparison goes through the filter contract's
221
+ * normalize, so an accent on either side folds away and typing "zu" reaches a
222
+ * label spelled with an umlaut.
223
+ */
224
+ function matchRun(run) {
225
+ const needle = normalize(run);
226
+ if (needle === '')
227
+ return -1;
228
+ const list = items();
229
+ if (list.length === 0)
230
+ return -1;
231
+ const current = activeRow();
232
+ const from = run.length === 1 ? Math.max(current, -1) + 1 : Math.max(current, 0);
233
+ for (let offset = 0; offset < list.length; offset++) {
234
+ const index = (from + offset) % list.length;
235
+ if (!selectable(index))
236
+ continue;
237
+ if (normalize(list[index].label).startsWith(needle))
238
+ return index;
239
+ }
240
+ return -1;
241
+ }
242
+ function handleTypeahead(event) {
243
+ if (!typeahead())
244
+ return false;
245
+ if (event.key.length !== 1)
246
+ return false;
247
+ if (event.altKey || event.ctrlKey || event.metaKey)
248
+ return false;
249
+ // Space opens or chooses on a trigger. It extends a run in progress and
250
+ // never starts one, so that meaning survives.
251
+ if (event.key === ' ' && typed === '')
252
+ return false;
253
+ // Date.now is a timestamp, not a timer: nothing is scheduled and nothing
254
+ // has to be cancelled when the control unmounts mid-run.
255
+ const now = Date.now();
256
+ typed = now - typedAt > TYPEAHEAD_WINDOW_MS ? event.key : typed + event.key;
257
+ typedAt = now;
258
+ if (!open)
259
+ openList();
260
+ const index = matchRun(typed);
261
+ if (index >= 0)
262
+ moveTo(index);
263
+ // Consumed either way. The run is open, so the next character extends it
264
+ // rather than reaching the trigger as a fresh key.
265
+ event.preventDefault();
266
+ return true;
267
+ }
268
+ function handleKeydown(event) {
269
+ switch (event.key) {
270
+ case 'ArrowDown':
271
+ event.preventDefault();
272
+ if (open)
273
+ moveTo(step(activeRow(), 1));
274
+ else
275
+ openList(firstSelectable());
276
+ return true;
277
+ case 'ArrowUp':
278
+ event.preventDefault();
279
+ // Opening on ArrowUp lands on the last row. Autocomplete decremented a
280
+ // hidden index instead, so the first ArrowUp opened nothing and the
281
+ // list, once opened, was already scrolled somewhere unexplained.
282
+ if (open)
283
+ moveTo(step(activeRow(), -1));
284
+ else
285
+ openList(lastSelectable());
286
+ return true;
287
+ case 'Home':
288
+ // Closed, Home and End belong to the caret in a combobox input.
289
+ if (!open)
290
+ return false;
291
+ event.preventDefault();
292
+ moveTo(firstSelectable());
293
+ return true;
294
+ case 'End':
295
+ if (!open)
296
+ return false;
297
+ event.preventDefault();
298
+ moveTo(lastSelectable());
299
+ return true;
300
+ case 'Enter':
301
+ // A closed list consumes nothing, so Enter still submits the form.
302
+ if (!open)
303
+ return false;
304
+ if (!selectActive())
305
+ return false;
306
+ event.preventDefault();
307
+ return true;
308
+ case 'Escape':
309
+ // Only an open list consumes Escape. Unstopped, one press closed both
310
+ // a listbox and the Modal holding it; consumed while closed, Escape
311
+ // never reached the Modal at all.
312
+ if (!open)
313
+ return false;
314
+ event.preventDefault();
315
+ event.stopPropagation();
316
+ closeList('escape');
317
+ focusTrigger();
318
+ return true;
319
+ case 'Tab':
320
+ // Never preventDefault: Tab is how focus leaves the field, and the rows
321
+ // are out of the tab sequence so there is nothing else for it to reach.
322
+ closeList('tab');
323
+ return false;
324
+ default:
325
+ return handleTypeahead(event);
326
+ }
327
+ }
328
+ function handlePointerDown(event) {
329
+ const target = event.target;
330
+ if (!(target instanceof Node))
331
+ return;
332
+ if (anchorNode !== null && anchorNode.contains(target))
333
+ return;
334
+ if (panelNode !== null && panelNode.contains(target))
335
+ return;
336
+ closeList('outside');
337
+ }
338
+ function handleFocusOut(event) {
339
+ if (!open)
340
+ return;
341
+ // relatedTarget is where focus went. A row inside the panel counts as
342
+ // staying, which is what the 150ms blur timer was standing in for.
343
+ const next = event.relatedTarget;
344
+ if (next instanceof Node) {
345
+ if (anchorNode !== null && anchorNode.contains(next))
346
+ return;
347
+ if (panelNode !== null && panelNode.contains(next))
348
+ return;
349
+ }
350
+ closeList('focusout');
351
+ }
352
+ function listenForOutside() {
353
+ if (listening || typeof document === 'undefined')
354
+ return;
355
+ // pointerdown, not click: a press that starts outside has already dismissed
356
+ // the list by the time the click lands, so the click reaches what the user
357
+ // aimed at instead of being spent closing the panel.
358
+ document.addEventListener('pointerdown', handlePointerDown, true);
359
+ listening = true;
360
+ }
361
+ function stopListeningForOutside() {
362
+ if (!listening || typeof document === 'undefined')
363
+ return;
364
+ document.removeEventListener('pointerdown', handlePointerDown, true);
365
+ listening = false;
366
+ }
367
+ function anchor(node) {
368
+ anchorNode = node;
369
+ node.addEventListener('focusout', handleFocusOut);
370
+ if (open)
371
+ listenForOutside();
372
+ return {
373
+ destroy() {
374
+ node.removeEventListener('focusout', handleFocusOut);
375
+ stopListeningForOutside();
376
+ if (anchorNode === node)
377
+ anchorNode = null;
378
+ },
379
+ };
380
+ }
381
+ function panel(node) {
382
+ panelNode = node;
383
+ // The rows exist for the first time here, so this is where a list opened at
384
+ // its last row gets scrolled to it.
385
+ scrollActiveIntoView();
386
+ return {
387
+ destroy() {
388
+ if (panelNode === node)
389
+ panelNode = null;
390
+ },
391
+ };
392
+ }
393
+ return {
394
+ get open() {
395
+ return open;
396
+ },
397
+ get activeIndex() {
398
+ return activeRow();
399
+ },
400
+ get triggerAttrs() {
401
+ const index = activeRow();
402
+ return {
403
+ 'aria-haspopup': 'listbox',
404
+ 'aria-expanded': open ? 'true' : 'false',
405
+ // Both point at elements that exist only while the panel is mounted. A
406
+ // dangling idref is announced as nothing, which reads to the user as a
407
+ // control that has stopped responding.
408
+ 'aria-controls': open ? listId() : undefined,
409
+ 'aria-activedescendant': open && index >= 0 ? optionId(index) : undefined,
410
+ };
411
+ },
412
+ get listAttrs() {
413
+ // A menu spreads these and then states role="menu" and its own
414
+ // aria-haspopup: the later attribute wins, and the id and the open state
415
+ // are the parts worth sharing.
416
+ return { id: listId(), role: 'listbox' };
417
+ },
418
+ optionAttrs(index) {
419
+ const item = items()[index];
420
+ return {
421
+ id: optionId(index),
422
+ role: 'option',
423
+ // Out of the tab sequence on purpose: focus stays on the trigger and
424
+ // the active row travels by aria-activedescendant. As plain buttons the
425
+ // rows put every option between the field and the next control.
426
+ tabindex: -1,
427
+ 'aria-disabled': item?.disabled === true ? 'true' : undefined,
428
+ };
429
+ },
430
+ openList,
431
+ close: closeList,
432
+ toggle: toggleList,
433
+ setActive,
434
+ onkeydown: handleKeydown,
435
+ anchor,
436
+ panel,
437
+ };
438
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Which groups in a sidebar are open, and why.
3
+ *
4
+ * Expansion looks like one boolean per group and is really three sources
5
+ * disagreeing: the tree says a group ships open, the current page says its
6
+ * ancestors have to be open or the reader cannot see where they are, and the
7
+ * reader says they closed that group and meant it. A component that keeps a
8
+ * flat set of open ids loses the third one the moment the second changes,
9
+ * which is how a group reopens itself every time the reader navigates inside
10
+ * it.
11
+ *
12
+ * So the state here is not "open ids". It is the decisions the reader has
13
+ * made, which are the only part worth persisting, and a default computed from
14
+ * the tree and the path underneath them. A reader decision always wins, and
15
+ * until there is one the group follows the page.
16
+ *
17
+ * Not exported from the package entry point - this is an implementation detail.
18
+ */
19
+ import { type NavTree } from './nav-tree.js';
20
+ export interface NavExpansionOptions {
21
+ items: () => NavTree;
22
+ activePath: () => string;
23
+ /** Open the ancestors of the current page. */
24
+ expandActive: () => boolean;
25
+ /** Only one group open at a time. */
26
+ exclusive: () => boolean;
27
+ /** localStorage key. Undefined keeps expansion in memory. */
28
+ storageKey: () => string | undefined;
29
+ }
30
+ export interface NavExpansion {
31
+ readonly isExpanded: (id: string) => boolean;
32
+ toggle(id: string): void;
33
+ expand(id: string): void;
34
+ collapse(id: string): void;
35
+ }
36
+ export declare function createNavExpansion(options: NavExpansionOptions): NavExpansion;