@lyeve-labs/ui-kit 0.12.1 → 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.
- package/dist/components/Autocomplete.svelte +191 -125
- package/dist/components/Autocomplete.svelte.d.ts +29 -8
- package/dist/components/Card.svelte +43 -2
- package/dist/components/Card.svelte.d.ts +24 -2
- package/dist/components/Checkbox.svelte +174 -63
- package/dist/components/Checkbox.svelte.d.ts +20 -3
- package/dist/components/CheckboxGroup.svelte +162 -0
- package/dist/components/CheckboxGroup.svelte.d.ts +51 -0
- package/dist/components/Collapsible.svelte +142 -0
- package/dist/components/Collapsible.svelte.d.ts +32 -0
- package/dist/components/CopyButton.svelte +126 -0
- package/dist/components/CopyButton.svelte.d.ts +14 -0
- package/dist/components/DatePicker.svelte +48 -6
- package/dist/components/DateTimePicker.svelte +337 -0
- package/dist/components/DateTimePicker.svelte.d.ts +26 -0
- package/dist/components/DescriptionList.svelte +78 -0
- package/dist/components/DescriptionList.svelte.d.ts +34 -0
- package/dist/components/Field.svelte +104 -0
- package/dist/components/Field.svelte.d.ts +46 -0
- package/dist/components/FileInput.svelte +5 -2
- package/dist/components/FormMessage.svelte +85 -0
- package/dist/components/FormMessage.svelte.d.ts +11 -0
- package/dist/components/Input.svelte +1 -1
- package/dist/components/Label.svelte +7 -1
- package/dist/components/Label.svelte.d.ts +6 -0
- package/dist/components/MultiSelect.svelte +199 -109
- package/dist/components/MultiSelect.svelte.d.ts +22 -9
- package/dist/components/NumberInput.svelte +8 -4
- package/dist/components/PageHeader.svelte +37 -4
- package/dist/components/PageHeader.svelte.d.ts +15 -0
- package/dist/components/PageShell.svelte +85 -0
- package/dist/components/PageShell.svelte.d.ts +38 -0
- package/dist/components/Pagination.svelte +43 -7
- package/dist/components/Panel.svelte +101 -0
- package/dist/components/Panel.svelte.d.ts +39 -0
- package/dist/components/PasswordInput.svelte +139 -0
- package/dist/components/PasswordInput.svelte.d.ts +29 -0
- package/dist/components/Radio.svelte +152 -35
- package/dist/components/Radio.svelte.d.ts +16 -1
- package/dist/components/RadioGroup.svelte +118 -71
- package/dist/components/RadioGroup.svelte.d.ts +39 -9
- package/dist/components/SectionHeading.svelte +39 -0
- package/dist/components/SectionHeading.svelte.d.ts +21 -0
- package/dist/components/SegmentedControl.svelte +194 -0
- package/dist/components/SegmentedControl.svelte.d.ts +55 -0
- package/dist/components/Select.svelte +471 -46
- package/dist/components/Select.svelte.d.ts +95 -6
- package/dist/components/SidebarNav.svelte +259 -0
- package/dist/components/SidebarNav.svelte.d.ts +17 -0
- package/dist/components/Stat.svelte +53 -2
- package/dist/components/Stat.svelte.d.ts +31 -0
- package/dist/components/Textarea.svelte +1 -1
- package/dist/components/TimePicker.svelte +480 -0
- package/dist/components/TimePicker.svelte.d.ts +23 -0
- package/dist/components/Toolbar.svelte +39 -0
- package/dist/components/Toolbar.svelte.d.ts +26 -0
- package/dist/components/TreeView.svelte +339 -0
- package/dist/components/TreeView.svelte.d.ts +37 -0
- package/dist/index.d.ts +25 -1
- package/dist/index.js +20 -1
- package/dist/internal/calendar.d.ts +119 -0
- package/dist/internal/calendar.js +225 -0
- package/dist/internal/choice.d.ts +136 -0
- package/dist/internal/choice.js +179 -0
- package/dist/internal/field.d.ts +31 -0
- package/dist/internal/field.js +38 -0
- package/dist/internal/filter.d.ts +80 -0
- package/dist/internal/filter.js +80 -0
- package/dist/internal/layout.d.ts +119 -0
- package/dist/internal/layout.js +132 -0
- package/dist/internal/listbox.svelte.d.ts +77 -0
- package/dist/internal/listbox.svelte.js +438 -0
- package/dist/internal/nav-expansion.svelte.d.ts +36 -0
- package/dist/internal/nav-expansion.svelte.js +144 -0
- package/dist/internal/nav-tree.d.ts +68 -0
- package/dist/internal/nav-tree.js +102 -0
- package/dist/internal/panel.d.ts +100 -0
- package/dist/internal/panel.js +109 -0
- package/dist/internal/rollup.d.ts +52 -0
- package/dist/internal/rollup.js +67 -0
- package/dist/internal/time.d.ts +103 -0
- package/dist/internal/time.js +166 -0
- package/dist/internal/tree.d.ts +86 -0
- package/dist/internal/tree.js +111 -0
- package/dist/styles/theme.css +18 -10
- package/package.json +4 -2
- package/src/lib/styles/theme.css +18 -10
|
@@ -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;
|
|
@@ -0,0 +1,144 @@
|
|
|
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 { activeTrail, flattenNav } from './nav-tree.js';
|
|
20
|
+
/**
|
|
21
|
+
* localStorage is absent on the server and throws on access in a private
|
|
22
|
+
* window and wherever the reader has blocked site data, so it is reached for
|
|
23
|
+
* behind both a typeof guard and a catch. Expansion is a convenience; nothing
|
|
24
|
+
* here may be the reason a sidebar fails to render.
|
|
25
|
+
*/
|
|
26
|
+
function storage() {
|
|
27
|
+
try {
|
|
28
|
+
return typeof localStorage === 'undefined' ? undefined : localStorage;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function readStored(key) {
|
|
35
|
+
if (!key)
|
|
36
|
+
return {};
|
|
37
|
+
const store = storage();
|
|
38
|
+
if (!store)
|
|
39
|
+
return {};
|
|
40
|
+
try {
|
|
41
|
+
const raw = store.getItem(key);
|
|
42
|
+
if (!raw)
|
|
43
|
+
return {};
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
46
|
+
return {};
|
|
47
|
+
// Another version of the app, or a reader editing the value by hand, can
|
|
48
|
+
// leave anything at all under this key. Only booleans survive the read.
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const [id, open] of Object.entries(parsed)) {
|
|
51
|
+
if (typeof open === 'boolean')
|
|
52
|
+
out[id] = open;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function writeStored(key, value) {
|
|
61
|
+
if (!key)
|
|
62
|
+
return;
|
|
63
|
+
const store = storage();
|
|
64
|
+
if (!store)
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
store.setItem(key, JSON.stringify(value));
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// A private window answers a write with a quota error. The session keeps
|
|
71
|
+
// its expansion in memory instead.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function hasChildren(node) {
|
|
75
|
+
return Array.isArray(node.children) && node.children.length > 0;
|
|
76
|
+
}
|
|
77
|
+
export function createNavExpansion(options) {
|
|
78
|
+
// Read once, with the key the sidebar mounted with. A key that changes later
|
|
79
|
+
// names a different sidebar, which is a different component instance.
|
|
80
|
+
const key = options.storageKey();
|
|
81
|
+
/** The reader's own decisions. An id absent here has not been decided. */
|
|
82
|
+
let overrides = $state(readStored(key));
|
|
83
|
+
const trail = $derived(options.expandActive()
|
|
84
|
+
? new Set(activeTrail(options.items(), options.activePath()))
|
|
85
|
+
: new Set());
|
|
86
|
+
const nodes = $derived(flattenNav(options.items()));
|
|
87
|
+
function commit(next) {
|
|
88
|
+
overrides = next;
|
|
89
|
+
writeStored(key, next);
|
|
90
|
+
}
|
|
91
|
+
function ancestorsOf(id) {
|
|
92
|
+
const walk = (branch, chain) => {
|
|
93
|
+
for (const node of branch) {
|
|
94
|
+
if (node.id === id)
|
|
95
|
+
return chain;
|
|
96
|
+
if (node.children && node.children.length > 0) {
|
|
97
|
+
const found = walk(node.children, [...chain, node.id]);
|
|
98
|
+
if (found)
|
|
99
|
+
return found;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
};
|
|
104
|
+
return walk(options.items(), []) ?? [];
|
|
105
|
+
}
|
|
106
|
+
function isExpanded(id) {
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(overrides, id))
|
|
108
|
+
return overrides[id];
|
|
109
|
+
if (nodes.some((entry) => entry.node.id === id && entry.node.defaultExpanded))
|
|
110
|
+
return true;
|
|
111
|
+
return trail.has(id);
|
|
112
|
+
}
|
|
113
|
+
function expand(id) {
|
|
114
|
+
if (!options.exclusive()) {
|
|
115
|
+
commit({ ...overrides, [id]: true });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Exclusive closes every other group, except the ones the opened node sits
|
|
119
|
+
// inside: collapsing an ancestor would hide the group that was just asked
|
|
120
|
+
// for. Ids the tree no longer holds keep whatever they had, so a stored
|
|
121
|
+
// key survives a tree that has not finished loading.
|
|
122
|
+
const keep = new Set([id, ...ancestorsOf(id)]);
|
|
123
|
+
const next = { ...overrides };
|
|
124
|
+
for (const { node } of nodes) {
|
|
125
|
+
if (hasChildren(node))
|
|
126
|
+
next[node.id] = keep.has(node.id);
|
|
127
|
+
}
|
|
128
|
+
commit(next);
|
|
129
|
+
}
|
|
130
|
+
function collapse(id) {
|
|
131
|
+
// Descendants keep their own decisions. They are not on screen while this
|
|
132
|
+
// group is shut, and reopening it should restore what the reader left.
|
|
133
|
+
commit({ ...overrides, [id]: false });
|
|
134
|
+
}
|
|
135
|
+
function toggle(id) {
|
|
136
|
+
if (isExpanded(id)) {
|
|
137
|
+
collapse(id);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
expand(id);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { isExpanded, toggle, expand, collapse };
|
|
144
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a sidebar decides which of its links is the current page.
|
|
3
|
+
*
|
|
4
|
+
* The rule every app shell reaches for is
|
|
5
|
+
*
|
|
6
|
+
* pathname === href || pathname.startsWith(href + '/')
|
|
7
|
+
*
|
|
8
|
+
* applied to every entry in the list. It is wrong in two ways that only show
|
|
9
|
+
* up once the list has more than one level. A parent link keeps claiming the
|
|
10
|
+
* page while the reader is on one of its children, so a settings sub-page
|
|
11
|
+
* lights two rows at once and neither of them is where the reader is. And two
|
|
12
|
+
* leaves where one href is a prefix of the other, /settings beside
|
|
13
|
+
* /settings/team, mark themselves together for the same reason, even though
|
|
14
|
+
* they are siblings and only one of them can be open.
|
|
15
|
+
*
|
|
16
|
+
* The fix is that prefix matching is a property of a node that owns a section,
|
|
17
|
+
* not of every node. A leaf answers for its own path and nothing below it; a
|
|
18
|
+
* node with children answers for its whole subtree, because that is what makes
|
|
19
|
+
* an ancestor able to say "you are somewhere in here" while its child says
|
|
20
|
+
* "you are here". Either default is overridable per node, and 'none' opts a
|
|
21
|
+
* node out of path matching entirely.
|
|
22
|
+
*
|
|
23
|
+
* Not exported from the package entry point - this is an implementation detail.
|
|
24
|
+
*/
|
|
25
|
+
import type { Component } from 'svelte';
|
|
26
|
+
import type { AccentTone } from './tone.js';
|
|
27
|
+
export interface NavNode {
|
|
28
|
+
/** Stable across renders. aria-controls is built from it and an each is keyed by it. */
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
href?: string;
|
|
32
|
+
icon?: Component<{
|
|
33
|
+
size?: number;
|
|
34
|
+
class?: string;
|
|
35
|
+
}>;
|
|
36
|
+
children?: NavNode[];
|
|
37
|
+
/** A count or status beside the label. */
|
|
38
|
+
badge?: string | number;
|
|
39
|
+
badgeTone?: AccentTone;
|
|
40
|
+
/** Open on first render. */
|
|
41
|
+
defaultExpanded?: boolean;
|
|
42
|
+
/** How activePath matches. Defaults to 'exact' for a leaf and 'prefix' for a node with children. */
|
|
43
|
+
match?: 'exact' | 'prefix' | 'none';
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export type NavTree = NavNode[];
|
|
47
|
+
/**
|
|
48
|
+
* True when this node is the current page.
|
|
49
|
+
*
|
|
50
|
+
* Path only. A disabled node still reports the truth about its href; whether
|
|
51
|
+
* it is rendered as a link is a separate decision the component makes.
|
|
52
|
+
*/
|
|
53
|
+
export declare function isActive(node: NavNode, activePath: string): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* The ids from the root down to the matched leaf, so its ancestors can open
|
|
56
|
+
* and mark themselves.
|
|
57
|
+
*
|
|
58
|
+
* Children are searched before the node itself, so a group never shadows the
|
|
59
|
+
* child that holds the more specific answer. The first match in document order
|
|
60
|
+
* wins: two nodes claiming one path is a tree the author has to fix, and
|
|
61
|
+
* silently picking one of them by length would hide it.
|
|
62
|
+
*/
|
|
63
|
+
export declare function activeTrail(items: NavTree, activePath: string): string[];
|
|
64
|
+
/** Depth-first flatten, for tests and for keyboard order. */
|
|
65
|
+
export declare function flattenNav(items: NavTree): {
|
|
66
|
+
node: NavNode;
|
|
67
|
+
depth: number;
|
|
68
|
+
}[];
|