@poodle64/librarian 2026.10.1 → 2026.10.3

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,418 @@
1
+ <!--
2
+ Where the next question looks, as one control: the choice's title on a quiet
3
+ button, and a list over the composer to change it, searchable once it is
4
+ long. It replaced a chip per collection, which stood taller than the box for
5
+ a person holding 27 (caspo#177).
6
+
7
+ The WAI-ARIA listbox pattern: the button says what it opens, the arrows move
8
+ through the choices, Enter takes one and Escape hands focus back. The list
9
+ is placed against the composer's box rather than this button, so it keeps
10
+ the box's width at 390, and opens towards whichever side has more room
11
+ inside whatever clips it: up from a docked box, either way from a centred
12
+ one.
13
+ -->
14
+ <script lang="ts">
15
+ import { tick } from 'svelte';
16
+ import CheckIcon from '@lucide/svelte/icons/check';
17
+ import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
18
+ import type { ChatCopy } from '../../copy';
19
+ import { titleOf, type CollectionChoice } from './choices';
20
+
21
+ /** From this many choices a search field heads the list. */
22
+ const SEARCH_FROM = 8;
23
+ /** The list's tallest, in px, before it scrolls. */
24
+ const TALLEST = 320;
25
+
26
+ interface Props {
27
+ collections: CollectionChoice[];
28
+ /** The chosen collection's name; undefined is all of them. */
29
+ collection?: string;
30
+ words: ChatCopy;
31
+ /** The composer's box, which the list is placed against. */
32
+ anchor: HTMLElement | null;
33
+ onchoose: (collection: string | undefined) => void;
34
+ }
35
+
36
+ let { collections, collection, words, anchor, onchoose }: Props = $props();
37
+
38
+ const id = $props.id();
39
+ const listId = `${id}-list`;
40
+
41
+ let open = $state(false);
42
+ let query = $state('');
43
+ let active = $state(0);
44
+ let place = $state({ up: true, left: 0, width: 0, height: TALLEST });
45
+
46
+ let root = $state<HTMLElement | null>(null);
47
+ let trigger = $state<HTMLButtonElement | null>(null);
48
+ let search = $state<HTMLInputElement | null>(null);
49
+ let list = $state<HTMLUListElement | null>(null);
50
+
51
+ const searchable = $derived(collections.length >= SEARCH_FROM);
52
+ const current = $derived.by(() => {
53
+ const chosen = collections.find((c) => c.name === collection);
54
+ return chosen ? titleOf(chosen) : words.allCollections;
55
+ });
56
+
57
+ interface Option {
58
+ name: string | undefined;
59
+ title: string;
60
+ description?: string;
61
+ }
62
+
63
+ // While a search is typed, "all of them" steps aside so Enter takes the
64
+ // first match rather than the reset.
65
+ const options = $derived.by((): Option[] => {
66
+ const each = collections.map((c) => ({
67
+ name: c.name,
68
+ title: titleOf(c),
69
+ description: c.description?.trim() || undefined
70
+ }));
71
+ const typed = query.trim().toLocaleLowerCase();
72
+ if (!typed) return [{ name: undefined, title: words.allCollections }, ...each];
73
+ return each.filter((o) =>
74
+ [o.title, o.name, o.description ?? ''].some((text) => text.toLocaleLowerCase().includes(typed))
75
+ );
76
+ });
77
+ const activeId = $derived(options[active] ? `${listId}-${active}` : undefined);
78
+
79
+ /** How far the anchor can open each way inside whatever clips it. */
80
+ function room(el: HTMLElement) {
81
+ const box = el.getBoundingClientRect();
82
+ let top = 0;
83
+ let bottom = window.innerHeight;
84
+ for (let parent = el.parentElement; parent; parent = parent.parentElement) {
85
+ if (getComputedStyle(parent).overflowY === 'visible') continue;
86
+ const clip = parent.getBoundingClientRect();
87
+ top = Math.max(top, clip.top);
88
+ bottom = Math.min(bottom, clip.bottom);
89
+ }
90
+ return { box, above: box.top - top, below: bottom - box.bottom };
91
+ }
92
+
93
+ async function show() {
94
+ if (anchor && trigger) {
95
+ const { box, above, below } = room(anchor);
96
+ const at = trigger.getBoundingClientRect();
97
+ const width = Math.min(box.width - 16, 352);
98
+ const up = above >= below;
99
+ place = {
100
+ up,
101
+ width,
102
+ left: Math.max(8, Math.min(at.left - box.left - anchor.clientLeft, box.width - width - 8)),
103
+ height: Math.max(160, Math.min(TALLEST, (up ? above : below) - 12))
104
+ };
105
+ }
106
+ query = '';
107
+ active = Math.max(0, options.findIndex((o) => o.name === collection));
108
+ open = true;
109
+ await tick();
110
+ (search ?? list)?.focus();
111
+ reveal();
112
+ }
113
+
114
+ function close(refocus: boolean) {
115
+ open = false;
116
+ if (refocus) trigger?.focus();
117
+ }
118
+
119
+ function choose(option: Option | undefined) {
120
+ if (!option) return;
121
+ open = false;
122
+ onchoose(option.name);
123
+ }
124
+
125
+ /** The active option in view, scrolling the list alone: `scrollIntoView`
126
+ * would scroll a centred stage or the page under it as well. */
127
+ function reveal() {
128
+ const option = activeId ? document.getElementById(activeId) : null;
129
+ if (!option || !list) return;
130
+ const bottom = option.offsetTop + option.offsetHeight;
131
+ if (option.offsetTop < list.scrollTop) list.scrollTop = option.offsetTop;
132
+ else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
133
+ }
134
+
135
+ function move(to: number) {
136
+ active = Math.max(0, Math.min(to, options.length - 1));
137
+ reveal();
138
+ }
139
+
140
+ function key(event: KeyboardEvent) {
141
+ // Home and End move the caret in a search field, so only the bare list takes them.
142
+ const inList = event.currentTarget === list;
143
+ if (event.key === 'ArrowDown') move(active + 1);
144
+ else if (event.key === 'ArrowUp') move(active - 1);
145
+ else if (event.key === 'Home' && inList) move(0);
146
+ else if (event.key === 'End' && inList) move(options.length - 1);
147
+ else if (event.key === 'Enter') choose(options[active]);
148
+ else if (event.key === 'Escape') {
149
+ // Stopped here: a sheet or a page around the composer listens for
150
+ // Escape too, and this one was only ever closing the list.
151
+ event.stopPropagation();
152
+ close(true);
153
+ } else if (event.key === 'Tab') {
154
+ close(false);
155
+ return;
156
+ } else return;
157
+ event.preventDefault();
158
+ }
159
+
160
+ function triggerKey(event: KeyboardEvent) {
161
+ if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && !open) {
162
+ event.preventDefault();
163
+ void show();
164
+ }
165
+ }
166
+
167
+ // A soft keyboard that resizes the layout viewport (Firefox on Android)
168
+ // fires `resize` the moment the search field takes focus; only a change of
169
+ // width moves the box the list was placed against.
170
+ $effect(() => {
171
+ if (!open) return;
172
+ const width = window.innerWidth;
173
+ const outside = (event: PointerEvent) => {
174
+ if (!root?.contains(event.target as Node)) close(false);
175
+ };
176
+ const resized = () => {
177
+ if (window.innerWidth !== width) close(false);
178
+ };
179
+ document.addEventListener('pointerdown', outside, true);
180
+ window.addEventListener('resize', resized);
181
+ return () => {
182
+ document.removeEventListener('pointerdown', outside, true);
183
+ window.removeEventListener('resize', resized);
184
+ };
185
+ });
186
+ </script>
187
+
188
+ <div
189
+ class="ds-lib-scope-picker"
190
+ bind:this={root}
191
+ onfocusout={(event) => {
192
+ if (open && !root?.contains(event.relatedTarget as Node | null)) close(false);
193
+ }}
194
+ >
195
+ <button
196
+ bind:this={trigger}
197
+ type="button"
198
+ class="ds-lib-scope-trigger"
199
+ class:is-narrowed={collection !== undefined}
200
+ aria-haspopup="listbox"
201
+ aria-expanded={open}
202
+ aria-controls={open ? listId : undefined}
203
+ aria-label="{words.collectionsLabel}: {current}"
204
+ onclick={() => (open ? close(false) : void show())}
205
+ onkeydown={triggerKey}
206
+ >
207
+ <span class="ds-lib-scope-current">{current}</span>
208
+ <ChevronDownIcon size={14} />
209
+ </button>
210
+
211
+ {#if open}
212
+ <div
213
+ class="ds-lib-scope-pop"
214
+ class:is-up={place.up}
215
+ style="left: {place.left}px; width: {place.width}px; max-height: {place.height}px"
216
+ >
217
+ {#if searchable}
218
+ <input
219
+ bind:this={search}
220
+ bind:value={query}
221
+ oninput={() => (active = 0)}
222
+ onkeydown={key}
223
+ type="search"
224
+ role="combobox"
225
+ aria-expanded="true"
226
+ aria-controls={listId}
227
+ aria-autocomplete="list"
228
+ aria-activedescendant={activeId}
229
+ aria-label={words.collectionsLabel}
230
+ placeholder={words.findCollection}
231
+ autocomplete="off"
232
+ class="ds-lib-scope-search"
233
+ />
234
+ {/if}
235
+ <!-- Focus stays in the field or on the list: a pressed option would
236
+ otherwise take it, and the active one is announced by reference. -->
237
+ <ul
238
+ bind:this={list}
239
+ id={listId}
240
+ role="listbox"
241
+ aria-label={words.collectionsLabel}
242
+ tabindex={searchable ? -1 : 0}
243
+ aria-activedescendant={searchable ? undefined : activeId}
244
+ onkeydown={searchable ? undefined : key}
245
+ onmousedown={(event) => event.preventDefault()}
246
+ class="ds-lib-scope-list"
247
+ >
248
+ {#each options as option, index (option.name ?? '')}
249
+ <!-- The keyboard drives the list from the field or the listbox
250
+ itself, by reference; an option never takes focus. -->
251
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
252
+ <li
253
+ id="{listId}-{index}"
254
+ role="option"
255
+ tabindex="-1"
256
+ aria-selected={option.name === collection}
257
+ class="ds-lib-scope-option"
258
+ class:is-active={index === active}
259
+ onclick={() => choose(option)}
260
+ onpointermove={() => (active = index)}
261
+ >
262
+ <span class="ds-lib-scope-option-text">
263
+ <span class="ds-lib-scope-option-title">{option.title}</span>
264
+ {#if option.description}
265
+ <span class="ds-lib-scope-option-description">{option.description}</span>
266
+ {/if}
267
+ </span>
268
+ {#if option.name === collection}<CheckIcon size={14} />{/if}
269
+ </li>
270
+ {/each}
271
+ </ul>
272
+ {#if options.length === 0}
273
+ <p class="ds-lib-scope-none" role="status">{words.noCollectionMatch}</p>
274
+ {/if}
275
+ </div>
276
+ {/if}
277
+ </div>
278
+
279
+ <style>
280
+ /* Not positioned: the list is placed against the composer's box. */
281
+ .ds-lib-scope-picker {
282
+ display: flex;
283
+ min-width: 0;
284
+ }
285
+
286
+ .ds-lib-scope-trigger {
287
+ display: flex;
288
+ min-width: 0;
289
+ max-width: 16rem;
290
+ align-items: center;
291
+ gap: 0.25rem;
292
+ border: 1px solid transparent;
293
+ border-radius: var(--ds-radius-full);
294
+ background: none;
295
+ padding: 0.25rem 0.5rem 0.25rem 0.625rem;
296
+ color: var(--ds-color-muted-foreground);
297
+ font: inherit;
298
+ font-size: var(--ds-text-2xs);
299
+ cursor: pointer;
300
+ transition:
301
+ color 150ms ease,
302
+ background-color 150ms ease;
303
+ }
304
+
305
+ .ds-lib-scope-trigger:hover,
306
+ .ds-lib-scope-trigger[aria-expanded='true'] {
307
+ background: var(--ds-color-surface-2);
308
+ color: var(--ds-color-foreground);
309
+ }
310
+
311
+ .ds-lib-scope-trigger.is-narrowed {
312
+ border-color: color-mix(in oklab, var(--ds-color-primary) 40%, transparent);
313
+ background: color-mix(in oklab, var(--ds-color-primary) 15%, transparent);
314
+ color: var(--ds-color-foreground);
315
+ }
316
+
317
+ .ds-lib-scope-trigger:focus-visible,
318
+ .ds-lib-scope-list:focus-visible {
319
+ outline: 2px solid var(--ds-color-ring);
320
+ outline-offset: 2px;
321
+ }
322
+
323
+ .ds-lib-scope-current {
324
+ min-width: 0;
325
+ overflow: hidden;
326
+ text-overflow: ellipsis;
327
+ white-space: nowrap;
328
+ }
329
+
330
+ .ds-lib-scope-pop {
331
+ position: absolute;
332
+ z-index: 20;
333
+ top: calc(100% + 0.375rem);
334
+ display: flex;
335
+ flex-direction: column;
336
+ overflow: hidden;
337
+ border: 1px solid var(--ds-color-border);
338
+ border-radius: var(--ds-radius-lg);
339
+ background: var(--ds-color-surface-1);
340
+ box-shadow: 0 12px 28px rgb(0 0 0 / 0.2);
341
+ }
342
+
343
+ .ds-lib-scope-pop.is-up {
344
+ top: auto;
345
+ bottom: calc(100% + 0.375rem);
346
+ }
347
+
348
+ .ds-lib-scope-search {
349
+ flex: none;
350
+ border: 0;
351
+ border-bottom: 1px solid var(--ds-color-border);
352
+ background: transparent;
353
+ padding: 0.625rem 0.75rem;
354
+ color: var(--ds-color-foreground);
355
+ font: inherit;
356
+ font-size: 0.875rem;
357
+ outline: none;
358
+ }
359
+
360
+ .ds-lib-scope-search::placeholder {
361
+ color: var(--ds-color-muted-foreground);
362
+ }
363
+
364
+ /* Positioned: each option's offset is measured from it. */
365
+ .ds-lib-scope-list {
366
+ position: relative;
367
+ min-height: 0;
368
+ flex: 1;
369
+ overflow-y: auto;
370
+ margin: 0;
371
+ padding: 0.25rem;
372
+ list-style: none;
373
+ outline: none;
374
+ }
375
+
376
+ /* 44px: an option is a thumb's target on a phone. */
377
+ .ds-lib-scope-option {
378
+ display: flex;
379
+ min-height: 2.75rem;
380
+ align-items: center;
381
+ gap: 0.5rem;
382
+ border-radius: var(--ds-radius-md);
383
+ padding: 0.375rem 0.625rem;
384
+ color: var(--ds-color-foreground);
385
+ font-size: 0.875rem;
386
+ cursor: pointer;
387
+ }
388
+
389
+ .ds-lib-scope-option.is-active {
390
+ background: var(--ds-color-surface-2);
391
+ }
392
+
393
+ .ds-lib-scope-option-text {
394
+ display: flex;
395
+ min-width: 0;
396
+ flex: 1;
397
+ flex-direction: column;
398
+ }
399
+
400
+ .ds-lib-scope-option-title,
401
+ .ds-lib-scope-option-description {
402
+ overflow: hidden;
403
+ text-overflow: ellipsis;
404
+ white-space: nowrap;
405
+ }
406
+
407
+ .ds-lib-scope-option-description {
408
+ color: var(--ds-color-muted-foreground);
409
+ font-size: var(--ds-text-2xs);
410
+ }
411
+
412
+ .ds-lib-scope-none {
413
+ margin: 0;
414
+ padding: 0.75rem;
415
+ color: var(--ds-color-muted-foreground);
416
+ font-size: 0.875rem;
417
+ }
418
+ </style>
@@ -0,0 +1,14 @@
1
+ import type { ChatCopy } from '../../copy';
2
+ import { type CollectionChoice } from './choices';
3
+ interface Props {
4
+ collections: CollectionChoice[];
5
+ /** The chosen collection's name; undefined is all of them. */
6
+ collection?: string;
7
+ words: ChatCopy;
8
+ /** The composer's box, which the list is placed against. */
9
+ anchor: HTMLElement | null;
10
+ onchoose: (collection: string | undefined) => void;
11
+ }
12
+ declare const ScopePicker: import("svelte").Component<Props, {}, "">;
13
+ type ScopePicker = ReturnType<typeof ScopePicker>;
14
+ export default ScopePicker;