@joewinke/jatui 0.22.0 → 0.23.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 (45) hide show
  1. package/CLAUDE.md +41 -1
  2. package/dist/actions/reveal.d.ts +37 -0
  3. package/dist/actions/reveal.js +42 -0
  4. package/dist/components/AudioRecorder.svelte +749 -0
  5. package/dist/components/AudioRecorder.svelte.d.ts +36 -0
  6. package/dist/components/IconSelector.svelte +417 -0
  7. package/dist/components/IconSelector.svelte.d.ts +55 -0
  8. package/dist/components/InfiniteGrid.svelte +305 -0
  9. package/dist/components/InfiniteGrid.svelte.d.ts +71 -0
  10. package/dist/components/InfiniteSearchGrid.svelte +131 -0
  11. package/dist/components/InfiniteSearchGrid.svelte.d.ts +67 -0
  12. package/dist/components/KeyboardShortcutsOverlay.svelte +40 -3
  13. package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +31 -16
  14. package/dist/components/MediaCaptureIcon.svelte +23 -0
  15. package/dist/components/MediaCaptureIcon.svelte.d.ts +1 -1
  16. package/dist/components/ThumbnailGrid.svelte +137 -0
  17. package/dist/components/ThumbnailGrid.svelte.d.ts +42 -0
  18. package/dist/components/batch/BatchJobTable.svelte +549 -0
  19. package/dist/components/batch/BatchJobTable.svelte.d.ts +114 -0
  20. package/dist/components/batch/QuotaMeter.svelte +78 -0
  21. package/dist/components/batch/QuotaMeter.svelte.d.ts +16 -0
  22. package/dist/components/batch/batchQueue.d.ts +56 -0
  23. package/dist/components/batch/batchQueue.js +81 -0
  24. package/dist/components/batch/elapsedEstimate.d.ts +24 -0
  25. package/dist/components/batch/elapsedEstimate.js +25 -0
  26. package/dist/components/batch/pollOperation.d.ts +45 -0
  27. package/dist/components/batch/pollOperation.js +80 -0
  28. package/dist/components/batch/useElapsedEstimate.d.ts +10 -0
  29. package/dist/components/batch/useElapsedEstimate.js +38 -0
  30. package/dist/components/infiniteGridLoader.d.ts +70 -0
  31. package/dist/components/infiniteGridLoader.js +141 -0
  32. package/dist/components/table/columnResize.d.ts +38 -0
  33. package/dist/components/table/columnResize.js +117 -0
  34. package/dist/components/table/index.d.ts +2 -0
  35. package/dist/components/table/index.js +1 -0
  36. package/dist/components/variant/VariantMatrixPicker.svelte +181 -0
  37. package/dist/components/variant/VariantMatrixPicker.svelte.d.ts +37 -0
  38. package/dist/components/variant/variantCombinations.d.ts +75 -0
  39. package/dist/components/variant/variantCombinations.js +100 -0
  40. package/dist/index.d.ts +28 -0
  41. package/dist/index.js +25 -0
  42. package/dist/styles/motion.css +100 -0
  43. package/dist/utils/keyboardShortcutRegistry.d.ts +152 -0
  44. package/dist/utils/keyboardShortcutRegistry.js +365 -0
  45. package/package.json +1 -1
@@ -0,0 +1,305 @@
1
+ <!--
2
+ InfiniteGrid — cursor-paged infinite-scroll grid (jatui-m4uz3.4).
3
+
4
+ Harvested from chimaro's InfiniteMediaGrid. The Supabase query, GalleryItem
5
+ and media types are gone; what's left is the paging machinery behind a
6
+ two-prop API:
7
+
8
+ <InfiniteGrid loadMore={(cursor, { signal }) => api.list({ cursor, signal })}>
9
+ {#snippet renderItem(item)}<MyCard {item} />{/snippet}
10
+ </InfiniteGrid>
11
+
12
+ `loadMore(cursor)` resolves `{ items, nextCursor, total? }`; `nextCursor: null`
13
+ means the end. Change `resetKey` (a query, a filter object…) to start over —
14
+ the in-flight request is aborted and a late page for the old key is dropped
15
+ (see infiniteGridLoader.ts for the race/dedupe/empty-vs-exhausted rules).
16
+
17
+ Sharp edges handled here, in the DOM half:
18
+ - Short pages: if the sentinel is still on screen after a page renders, the
19
+ next page loads immediately (an IntersectionObserver won't re-fire for a
20
+ target whose visibility never changed, so chimaro stalled on tall screens).
21
+ - Scroll anchoring: every non-item row (skeletons, spinner, sentinel, footer)
22
+ is `overflow-anchor: none`, so the browser anchors to a real item and the
23
+ "loading more" row can't drag the viewport down with it as items land
24
+ above it (which re-triggered the sentinel → runaway loads).
25
+ - Scroll root: `root` if given, else the nearest scrollable ancestor, else
26
+ the viewport — no more `.content-area` class sniffing.
27
+ -->
28
+ <script lang="ts" module>
29
+ export type { InfinitePage, InfiniteLoadContext, InfiniteLoadFn, InfiniteLoaderState } from './infiniteGridLoader';
30
+ </script>
31
+
32
+ <script lang="ts" generics="T, C = unknown">
33
+ import { onDestroy, tick, untrack, type Snippet } from 'svelte';
34
+ import Button from './Button.svelte';
35
+ import Empty from './basic/Empty.svelte';
36
+ import LoadingSpinner from './LoadingSpinner.svelte';
37
+ import {
38
+ createInfiniteLoader,
39
+ type InfiniteLoadFn,
40
+ type InfiniteLoaderState
41
+ } from './infiniteGridLoader';
42
+
43
+ interface Props {
44
+ /** Fetch one page. `cursor` is null for the first page. Honour `signal` to cancel. */
45
+ loadMore: InfiniteLoadFn<T, C>;
46
+ /** Render one cell. */
47
+ renderItem: Snippet<[T, number]>;
48
+ /** Stable identity for keyed rendering + cross-page de-dupe. Default: `item.id`. */
49
+ getKey?: (item: T) => unknown;
50
+ /** Change to reset and reload from the first page (query, filters, sort…). */
51
+ resetKey?: unknown;
52
+ /** Fixed column count. Omit for responsive auto-fill driven by `minItemWidth`. */
53
+ columns?: number;
54
+ /** Minimum cell width for auto-fill columns (CSS length or px number). */
55
+ minItemWidth?: string | number;
56
+ /** Grid gap (CSS length). */
57
+ gap?: string;
58
+ /** Scroll container. Default: nearest scrollable ancestor, else the viewport. */
59
+ root?: HTMLElement | null;
60
+ /** Start loading this many px before the sentinel scrolls into view. */
61
+ loadAhead?: number;
62
+ /** Placeholder cells shown while the first page loads. */
63
+ skeletonCount?: number;
64
+ /** Custom placeholder cell. */
65
+ skeleton?: Snippet;
66
+ /** Custom empty state (loaded, zero items). */
67
+ empty?: Snippet;
68
+ /** Custom end-of-list footer (loaded, some items, no more pages). */
69
+ end?: Snippet<[number, number | undefined]>;
70
+ /** Custom error row. Call `retry` to re-request the failed page. */
71
+ error?: Snippet<[unknown, () => void]>;
72
+ emptyText?: string;
73
+ /** Footer text for the exhausted state. Return '' to hide it. */
74
+ endText?: (count: number, total: number | undefined) => string;
75
+ /** Accessible name for the list. */
76
+ ariaLabel?: string;
77
+ class?: string;
78
+ itemClass?: string;
79
+ /** Fires on every loader transition (items, status, empty/exhausted…). */
80
+ onStateChange?: (state: InfiniteLoaderState<T, C>) => void;
81
+ }
82
+
83
+ let {
84
+ loadMore,
85
+ renderItem,
86
+ getKey,
87
+ resetKey = undefined,
88
+ columns,
89
+ minItemWidth = '10rem',
90
+ gap = 'var(--jatui-space-base, 0.5rem)',
91
+ root,
92
+ loadAhead = 400,
93
+ skeletonCount = 12,
94
+ skeleton,
95
+ empty,
96
+ end,
97
+ error,
98
+ emptyText = 'Nothing here yet.',
99
+ endText = (count, total) => `Showing all ${total ?? count} items`,
100
+ ariaLabel = 'Results',
101
+ class: className = '',
102
+ itemClass = '',
103
+ onStateChange
104
+ }: Props = $props();
105
+
106
+ function keyOf(item: T): unknown {
107
+ if (getKey) return getKey(item);
108
+ if (item && typeof item === 'object' && 'id' in item) return (item as { id: unknown }).id;
109
+ return item;
110
+ }
111
+
112
+ // The closures read the CURRENT props, so a new loadMore/getKey is picked up
113
+ // on the next request without rebuilding the loader.
114
+ const loader = createInfiniteLoader<T, C>({
115
+ load: (cursor, ctx) => loadMore(cursor, ctx),
116
+ getKey: (item) => keyOf(item),
117
+ onChange: (s) => {
118
+ view = s;
119
+ onStateChange?.(s);
120
+ }
121
+ });
122
+ let view = $state.raw<InfiniteLoaderState<T, C>>(loader.state);
123
+
124
+ let sentinelEl = $state<HTMLElement>();
125
+ let scrollRoot: HTMLElement | null = null;
126
+ let destroyed = false;
127
+
128
+ const loading = $derived(view.status === 'loading');
129
+ const showSkeleton = $derived(!view.initialized && view.status !== 'error');
130
+ const trackStyle = $derived(
131
+ columns
132
+ ? `--ig-cols: repeat(${columns}, minmax(0, 1fr)); --ig-gap: ${gap};`
133
+ : `--ig-cols: repeat(auto-fill, minmax(min(${typeof minItemWidth === 'number' ? `${minItemWidth}px` : minItemWidth}, 100%), 1fr)); --ig-gap: ${gap};`
134
+ );
135
+ const announcement = $derived.by(() => {
136
+ if (view.status === 'error') return 'Failed to load more items.';
137
+ if (view.empty) return emptyText;
138
+ if (view.exhausted) return `All ${view.items.length} items loaded.`;
139
+ if (view.initialized && view.status === 'idle' && view.lastPageCount > 0)
140
+ return `Loaded ${view.lastPageCount} more items.`;
141
+ return '';
142
+ });
143
+
144
+ function findScrollRoot(el: HTMLElement): HTMLElement | null {
145
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
146
+ const oy = getComputedStyle(p).overflowY;
147
+ if (oy === 'auto' || oy === 'scroll' || oy === 'overlay') return p;
148
+ }
149
+ return null;
150
+ }
151
+
152
+ /** Geometric check, not the IO flag: the flag lags a frame behind fresh DOM. */
153
+ function sentinelInRange(): boolean {
154
+ if (!sentinelEl) return false;
155
+ const top = sentinelEl.getBoundingClientRect().top;
156
+ const bottom = scrollRoot ? scrollRoot.getBoundingClientRect().bottom : window.innerHeight;
157
+ return top <= bottom + loadAhead;
158
+ }
159
+
160
+ /** Load pages until the sentinel is off screen, the list ends, or it errors. */
161
+ async function pump() {
162
+ while (!destroyed) {
163
+ const generation = loader.state.generation;
164
+ await loader.loadNext();
165
+ if (destroyed || loader.state.generation !== generation || loader.state.status !== 'idle') return;
166
+ await tick();
167
+ if (!sentinelInRange()) return;
168
+ }
169
+ }
170
+
171
+ /** Start over from the first page. */
172
+ export function reset() {
173
+ loader.reset();
174
+ void pump();
175
+ }
176
+
177
+ /** Re-request the page that failed. */
178
+ export function retry() {
179
+ void loader.retry().then(() => {
180
+ if (loader.state.status === 'idle') void pump();
181
+ });
182
+ }
183
+
184
+ // First load + reload whenever resetKey changes.
185
+ $effect(() => {
186
+ void resetKey;
187
+ untrack(reset);
188
+ });
189
+
190
+ $effect(() => {
191
+ const el = sentinelEl;
192
+ if (!el) return;
193
+ scrollRoot = root === undefined ? findScrollRoot(el) : root;
194
+ const io = new IntersectionObserver(
195
+ (entries) => {
196
+ if (entries.some((e) => e.isIntersecting)) void pump();
197
+ },
198
+ { root: scrollRoot, rootMargin: `0px 0px ${loadAhead}px 0px` }
199
+ );
200
+ io.observe(el);
201
+ return () => io.disconnect();
202
+ });
203
+
204
+ onDestroy(() => {
205
+ destroyed = true;
206
+ loader.destroy();
207
+ });
208
+ </script>
209
+
210
+ <div class="jatui-infinite-grid {className}">
211
+ <ul class="ig-track" style={trackStyle} aria-label={ariaLabel} aria-busy={loading}>
212
+ {#if showSkeleton}
213
+ {#each { length: skeletonCount } as _, i (i)}
214
+ <li class="ig-placeholder" aria-hidden="true">
215
+ {#if skeleton}{@render skeleton()}{:else}<div class="skeleton aspect-square w-full"></div>{/if}
216
+ </li>
217
+ {/each}
218
+ {:else}
219
+ {#each view.items as item, i (keyOf(item))}
220
+ <li class="ig-item min-w-0 {itemClass}">{@render renderItem(item, i)}</li>
221
+ {/each}
222
+ {/if}
223
+ </ul>
224
+
225
+ {#if view.initialized && loading}
226
+ <div class="ig-row flex justify-center py-4">
227
+ <LoadingSpinner size="sm" text="Loading more…" textPosition="right" />
228
+ </div>
229
+ {:else if view.status === 'error'}
230
+ <div class="ig-row">
231
+ {#if error}
232
+ {@render error(view.error, retry)}
233
+ {:else}
234
+ <div class="flex flex-col items-center gap-2 py-6 text-sm" role="alert">
235
+ <span class="text-error">
236
+ {view.items.length ? "Couldn't load more items." : "Couldn't load items."}
237
+ </span>
238
+ <Button size="sm" variant="outline" onclick={retry}>Retry</Button>
239
+ </div>
240
+ {/if}
241
+ </div>
242
+ {:else if view.empty}
243
+ <div class="ig-row">
244
+ {#if empty}{@render empty()}{:else}<Empty copy={emptyText} />{/if}
245
+ </div>
246
+ {:else if view.exhausted}
247
+ <div class="ig-row">
248
+ {#if end}
249
+ {@render end(view.items.length, view.total)}
250
+ {:else if endText(view.items.length, view.total)}
251
+ <p class="text-base-content/50 py-4 text-center text-sm">
252
+ {endText(view.items.length, view.total)}
253
+ </p>
254
+ {/if}
255
+ </div>
256
+ {/if}
257
+
258
+ <!-- Always mounted so the observer is set up once; pump() decides whether to load. -->
259
+ <div bind:this={sentinelEl} class="ig-row ig-sentinel" aria-hidden="true"></div>
260
+
261
+ <div class="sr-only" aria-live="polite">{announcement}</div>
262
+ </div>
263
+
264
+ <style>
265
+ .jatui-infinite-grid {
266
+ min-width: 0;
267
+ }
268
+
269
+ .ig-track {
270
+ display: grid;
271
+ grid-template-columns: var(--ig-cols);
272
+ gap: var(--ig-gap);
273
+ list-style: none;
274
+ margin: 0;
275
+ padding: 0;
276
+ }
277
+
278
+ .ig-item {
279
+ animation: ig-enter var(--motion-enter, 200ms) var(--motion-glide, ease-out) both;
280
+ }
281
+
282
+ /* Scroll anchoring: only real items may be chosen as the anchor node. */
283
+ .ig-placeholder,
284
+ .ig-row {
285
+ overflow-anchor: none;
286
+ }
287
+
288
+ .ig-sentinel {
289
+ height: 1px;
290
+ pointer-events: none;
291
+ }
292
+
293
+ @keyframes ig-enter {
294
+ from {
295
+ opacity: 0;
296
+ transform: translateY(4px);
297
+ }
298
+ }
299
+
300
+ @media (prefers-reduced-motion: reduce) {
301
+ .ig-item {
302
+ animation: none;
303
+ }
304
+ }
305
+ </style>
@@ -0,0 +1,71 @@
1
+ export type { InfinitePage, InfiniteLoadContext, InfiniteLoadFn, InfiniteLoaderState } from './infiniteGridLoader';
2
+ import { type Snippet } from 'svelte';
3
+ import { type InfiniteLoadFn, type InfiniteLoaderState } from './infiniteGridLoader';
4
+ declare function $$render<T, C = unknown>(): {
5
+ props: {
6
+ /** Fetch one page. `cursor` is null for the first page. Honour `signal` to cancel. */
7
+ loadMore: InfiniteLoadFn<T, C>;
8
+ /** Render one cell. */
9
+ renderItem: Snippet<[T, number]>;
10
+ /** Stable identity for keyed rendering + cross-page de-dupe. Default: `item.id`. */
11
+ getKey?: (item: T) => unknown;
12
+ /** Change to reset and reload from the first page (query, filters, sort…). */
13
+ resetKey?: unknown;
14
+ /** Fixed column count. Omit for responsive auto-fill driven by `minItemWidth`. */
15
+ columns?: number;
16
+ /** Minimum cell width for auto-fill columns (CSS length or px number). */
17
+ minItemWidth?: string | number;
18
+ /** Grid gap (CSS length). */
19
+ gap?: string;
20
+ /** Scroll container. Default: nearest scrollable ancestor, else the viewport. */
21
+ root?: HTMLElement | null;
22
+ /** Start loading this many px before the sentinel scrolls into view. */
23
+ loadAhead?: number;
24
+ /** Placeholder cells shown while the first page loads. */
25
+ skeletonCount?: number;
26
+ /** Custom placeholder cell. */
27
+ skeleton?: Snippet;
28
+ /** Custom empty state (loaded, zero items). */
29
+ empty?: Snippet;
30
+ /** Custom end-of-list footer (loaded, some items, no more pages). */
31
+ end?: Snippet<[number, number | undefined]>;
32
+ /** Custom error row. Call `retry` to re-request the failed page. */
33
+ error?: Snippet<[unknown, () => void]>;
34
+ emptyText?: string;
35
+ /** Footer text for the exhausted state. Return '' to hide it. */
36
+ endText?: (count: number, total: number | undefined) => string;
37
+ /** Accessible name for the list. */
38
+ ariaLabel?: string;
39
+ class?: string;
40
+ itemClass?: string;
41
+ /** Fires on every loader transition (items, status, empty/exhausted…). */
42
+ onStateChange?: (state: InfiniteLoaderState<T, C>) => void;
43
+ };
44
+ exports: {
45
+ reset: () => void;
46
+ retry: () => void;
47
+ };
48
+ bindings: "";
49
+ slots: {};
50
+ events: {};
51
+ };
52
+ declare class __sveltets_Render<T, C = unknown> {
53
+ props(): ReturnType<typeof $$render<T, C>>['props'];
54
+ events(): ReturnType<typeof $$render<T, C>>['events'];
55
+ slots(): ReturnType<typeof $$render<T, C>>['slots'];
56
+ bindings(): "";
57
+ exports(): {
58
+ reset: () => void;
59
+ retry: () => void;
60
+ };
61
+ }
62
+ interface $$IsomorphicComponent {
63
+ new <T, C = unknown>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T, C>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T, C>['props']>, ReturnType<__sveltets_Render<T, C>['events']>, ReturnType<__sveltets_Render<T, C>['slots']>> & {
64
+ $$bindings?: ReturnType<__sveltets_Render<T, C>['bindings']>;
65
+ } & ReturnType<__sveltets_Render<T, C>['exports']>;
66
+ <T, C = unknown>(internal: unknown, props: ReturnType<__sveltets_Render<T, C>['props']> & {}): ReturnType<__sveltets_Render<T, C>['exports']>;
67
+ z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
68
+ }
69
+ declare const InfiniteGrid: $$IsomorphicComponent;
70
+ type InfiniteGrid<T, C = unknown> = InstanceType<typeof InfiniteGrid<T, C>>;
71
+ export default InfiniteGrid;
@@ -0,0 +1,131 @@
1
+ <!--
2
+ InfiniteSearchGrid — search box + InfiniteGrid (jatui-m4uz3.4).
3
+
4
+ Harvested from chimaro's InfiniteSearchGrid, minus its global searchStore,
5
+ Supabase client and media types. The query is debounced, then becomes the
6
+ grid's `resetKey`: each new query aborts the in-flight page and drops any late
7
+ page for the previous one, so typing fast never interleaves result sets.
8
+
9
+ <InfiniteSearchGrid search={(q, cursor, { signal }) => api.search({ q, cursor, signal })}>
10
+ {#snippet renderItem(item)}<MyCard {item} />{/snippet}
11
+ </InfiniteSearchGrid>
12
+
13
+ Every other InfiniteGrid prop (columns, minItemWidth, getKey, root, skeleton,
14
+ end, error…) passes straight through.
15
+ -->
16
+ <script lang="ts" generics="T, C = unknown">
17
+ import { onDestroy, type ComponentProps, type Snippet } from 'svelte';
18
+ import InfiniteGrid from './InfiniteGrid.svelte';
19
+ import Empty from './basic/Empty.svelte';
20
+ import type { InfiniteLoadContext, InfinitePage } from './infiniteGridLoader';
21
+
22
+ type GridProps = ComponentProps<typeof InfiniteGrid<T, C>>;
23
+
24
+ interface Props extends Omit<GridProps, 'loadMore' | 'resetKey' | 'empty' | 'emptyText'> {
25
+ /** Fetch one page of results for `query`. `cursor` is null for the first page. */
26
+ search: (query: string, cursor: C | null, ctx: InfiniteLoadContext) => Promise<InfinitePage<T, C>>;
27
+ /** The (raw, undebounced) query text. */
28
+ query?: string;
29
+ /** Debounce between the last keystroke and the request. */
30
+ debounceMs?: number;
31
+ /** Below this many (trimmed) characters no request is made and `prompt` shows. 0 = browse with an empty query. */
32
+ minQueryLength?: number;
33
+ placeholder?: string;
34
+ /** Accessible label for the search field. */
35
+ searchLabel?: string;
36
+ /** Shown while the query is shorter than `minQueryLength`. */
37
+ promptText?: string;
38
+ prompt?: Snippet;
39
+ /** Empty-state text for a query with zero results. */
40
+ noResultsText?: (query: string) => string;
41
+ noResults?: Snippet<[string]>;
42
+ /** Extra controls rendered beside the search field (filters, sort…). */
43
+ toolbar?: Snippet;
44
+ }
45
+
46
+ let {
47
+ search,
48
+ query = $bindable(''),
49
+ debounceMs = 300,
50
+ minQueryLength = 0,
51
+ placeholder = 'Search…',
52
+ searchLabel = 'Search',
53
+ promptText = 'Start typing to search.',
54
+ prompt,
55
+ noResultsText = (q) => (q ? `No results for “${q}”.` : 'Nothing here yet.'),
56
+ noResults,
57
+ toolbar,
58
+ ariaLabel = 'Search results',
59
+ ...gridProps
60
+ }: Props = $props();
61
+
62
+ let committed = $state(query.trim());
63
+ let timer: ReturnType<typeof setTimeout> | undefined;
64
+
65
+ function commit() {
66
+ clearTimeout(timer);
67
+ committed = query.trim();
68
+ }
69
+
70
+ // Debounce: external writes to `query` (bind:query) go through the same path.
71
+ $effect(() => {
72
+ const next = query.trim();
73
+ clearTimeout(timer);
74
+ if (next === committed) return;
75
+ timer = setTimeout(() => (committed = next), next === '' ? 0 : debounceMs);
76
+ });
77
+
78
+ onDestroy(() => clearTimeout(timer));
79
+
80
+ const ready = $derived(committed.length >= minQueryLength);
81
+
82
+ function onkeydown(e: KeyboardEvent) {
83
+ if (e.key === 'Enter') {
84
+ e.preventDefault();
85
+ commit();
86
+ } else if (e.key === 'Escape' && query) {
87
+ e.preventDefault();
88
+ e.stopPropagation();
89
+ query = '';
90
+ commit();
91
+ }
92
+ }
93
+ </script>
94
+
95
+ <div class="flex flex-col gap-3">
96
+ <div class="flex flex-wrap items-center gap-2">
97
+ <label class="input input-bordered flex min-w-0 flex-1 items-center gap-2">
98
+ <svg class="size-4 opacity-50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
99
+ <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" stroke-linecap="round" />
100
+ </svg>
101
+ <input
102
+ type="search"
103
+ class="grow"
104
+ bind:value={query}
105
+ {placeholder}
106
+ aria-label={searchLabel}
107
+ autocomplete="off"
108
+ {onkeydown}
109
+ />
110
+ </label>
111
+ {#if toolbar}{@render toolbar()}{/if}
112
+ </div>
113
+
114
+ {#if ready}
115
+ <InfiniteGrid
116
+ {...gridProps}
117
+ {ariaLabel}
118
+ resetKey={committed}
119
+ emptyText={noResultsText(committed)}
120
+ loadMore={(cursor, ctx) => search(committed, cursor, ctx)}
121
+ >
122
+ {#snippet empty()}
123
+ {#if noResults}{@render noResults(committed)}{:else}<Empty copy={noResultsText(committed)} />{/if}
124
+ {/snippet}
125
+ </InfiniteGrid>
126
+ {:else if prompt}
127
+ {@render prompt()}
128
+ {:else}
129
+ <Empty copy={promptText} />
130
+ {/if}
131
+ </div>
@@ -0,0 +1,67 @@
1
+ import { type Snippet } from 'svelte';
2
+ import type { InfiniteLoadContext, InfinitePage } from './infiniteGridLoader';
3
+ declare function $$render<T, C = unknown>(): {
4
+ props: Omit<{
5
+ loadMore: import("./infiniteGridLoader").InfiniteLoadFn<T, C>;
6
+ renderItem: Snippet<[T, number]>;
7
+ getKey?: ((item: T) => unknown) | undefined;
8
+ resetKey?: unknown;
9
+ columns?: number;
10
+ minItemWidth?: string | number;
11
+ gap?: string;
12
+ root?: HTMLElement | null;
13
+ loadAhead?: number;
14
+ skeletonCount?: number;
15
+ skeleton?: Snippet;
16
+ empty?: Snippet;
17
+ end?: Snippet<[number, number | undefined]>;
18
+ error?: Snippet<[unknown, () => void]> | undefined;
19
+ emptyText?: string;
20
+ endText?: ((count: number, total: number | undefined) => string) | undefined;
21
+ ariaLabel?: string;
22
+ class?: string;
23
+ itemClass?: string;
24
+ onStateChange?: ((state: import("./infiniteGridLoader").InfiniteLoaderState<T, C>) => void) | undefined;
25
+ }, "empty" | "loadMore" | "resetKey" | "emptyText"> & {
26
+ /** Fetch one page of results for `query`. `cursor` is null for the first page. */
27
+ search: (query: string, cursor: C | null, ctx: InfiniteLoadContext) => Promise<InfinitePage<T, C>>;
28
+ /** The (raw, undebounced) query text. */
29
+ query?: string;
30
+ /** Debounce between the last keystroke and the request. */
31
+ debounceMs?: number;
32
+ /** Below this many (trimmed) characters no request is made and `prompt` shows. 0 = browse with an empty query. */
33
+ minQueryLength?: number;
34
+ placeholder?: string;
35
+ /** Accessible label for the search field. */
36
+ searchLabel?: string;
37
+ /** Shown while the query is shorter than `minQueryLength`. */
38
+ promptText?: string;
39
+ prompt?: Snippet;
40
+ /** Empty-state text for a query with zero results. */
41
+ noResultsText?: (query: string) => string;
42
+ noResults?: Snippet<[string]>;
43
+ /** Extra controls rendered beside the search field (filters, sort…). */
44
+ toolbar?: Snippet;
45
+ };
46
+ exports: {};
47
+ bindings: "query";
48
+ slots: {};
49
+ events: {};
50
+ };
51
+ declare class __sveltets_Render<T, C = unknown> {
52
+ props(): ReturnType<typeof $$render<T, C>>['props'];
53
+ events(): ReturnType<typeof $$render<T, C>>['events'];
54
+ slots(): ReturnType<typeof $$render<T, C>>['slots'];
55
+ bindings(): "query";
56
+ exports(): {};
57
+ }
58
+ interface $$IsomorphicComponent {
59
+ new <T, C = unknown>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T, C>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T, C>['props']>, ReturnType<__sveltets_Render<T, C>['events']>, ReturnType<__sveltets_Render<T, C>['slots']>> & {
60
+ $$bindings?: ReturnType<__sveltets_Render<T, C>['bindings']>;
61
+ } & ReturnType<__sveltets_Render<T, C>['exports']>;
62
+ <T, C = unknown>(internal: unknown, props: ReturnType<__sveltets_Render<T, C>['props']> & {}): ReturnType<__sveltets_Render<T, C>['exports']>;
63
+ z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
64
+ }
65
+ declare const InfiniteSearchGrid: $$IsomorphicComponent;
66
+ type InfiniteSearchGrid<T, C = unknown> = InstanceType<typeof InfiniteSearchGrid<T, C>>;
67
+ export default InfiniteSearchGrid;
@@ -15,7 +15,17 @@
15
15
  * { key: 'k / ↑', description: 'Focus previous item' },
16
16
  * { key: 'Enter', description: 'Open detail' },
17
17
  * ]} />
18
+ *
19
+ * Or feed it from a shortcut registry (createShortcutRegistry) — the
20
+ * overlay lists whatever can fire right now, grouped by context, and
21
+ * pushes a modal context while open so page shortcuts don't fire behind it:
22
+ *
23
+ * <KeyboardShortcutsOverlay {registry} />
24
+ *
25
+ * Precedence: `sections` > `registry` > `shortcuts`. The overlay owns `?` —
26
+ * don't register it in the registry too.
18
27
  */
28
+ import type { ShortcutRegistry } from '../utils/keyboardShortcutRegistry';
19
29
 
20
30
  export interface KeyboardShortcut {
21
31
  key: string;
@@ -37,6 +47,7 @@
37
47
  let {
38
48
  shortcuts = [],
39
49
  sections,
50
+ registry,
40
51
  title = 'Keyboard Shortcuts',
41
52
  open = $bindable(false)
42
53
  }: {
@@ -47,6 +58,11 @@
47
58
  * precedence over the flat `shortcuts` prop.
48
59
  */
49
60
  sections?: ShortcutSection[];
61
+ /**
62
+ * Shortcut registry to read sections from (active contexts, top of
63
+ * the stack first). Ignored when `sections` is given.
64
+ */
65
+ registry?: ShortcutRegistry;
50
66
  title?: string;
51
67
  open?: boolean;
52
68
  } = $props();
@@ -60,6 +76,19 @@
60
76
  open = isOpen;
61
77
  });
62
78
 
79
+ // Registry sections are snapshotted on open — BEFORE pushing the overlay's
80
+ // own modal context, which would otherwise hide every page context.
81
+ let registrySections = $state<ShortcutSection[] | null>(null);
82
+ $effect(() => {
83
+ if (!isOpen || !registry) return;
84
+ registrySections = registry.toSections();
85
+ return registry.pushContext('keyboard-shortcuts-overlay', { modal: true });
86
+ });
87
+
88
+ const shownSections = $derived(
89
+ sections && sections.length > 0 ? sections : registry ? registrySections : null
90
+ );
91
+
63
92
  // Track which key label is currently depressed
64
93
  let depressedKey = $state<string | null>(null);
65
94
  let depressTimer: ReturnType<typeof setTimeout> | null = null;
@@ -104,7 +133,15 @@
104
133
  if (!pressed) return false;
105
134
  // Match single-char labels directly
106
135
  const parts = keyLabel.split(/\s*\/\s*/);
107
- return parts.some(p => p.trim() === pressed || p.trim().toLowerCase() === pressed.toLowerCase());
136
+ const p = pressed.toLowerCase();
137
+ return parts.some((part) => {
138
+ const t = part.trim();
139
+ if (t === pressed || t.toLowerCase() === p) return true;
140
+ // Combo labels ("Ctrl+K", "⇧⌘K") — match on the key itself.
141
+ const base = (t.length > 1 && t.includes('+') ? t.slice(t.lastIndexOf('+', t.length - 2) + 1) : t)
142
+ .replace(/^[⌃⌥⇧⌘]+/, '');
143
+ return base !== t && base.toLowerCase() === p;
144
+ });
108
145
  }
109
146
 
110
147
  $effect(() => {
@@ -143,8 +180,8 @@
143
180
  </button>
144
181
  </div>
145
182
  <div class="kso-list">
146
- {#if sections && sections.length > 0}
147
- {#each sections as section, sIdx (section.title + '::' + sIdx)}
183
+ {#if shownSections && shownSections.length > 0}
184
+ {#each shownSections as section, sIdx (section.title + '::' + sIdx)}
148
185
  <div class="kso-section-title">{section.title}</div>
149
186
  {#each section.shortcuts as { key, description, color }, i (section.title + '::' + key + '::' + i)}
150
187
  <div class="kso-row">