@aiaiai-pt/design-system 0.3.4 → 0.3.5

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,605 @@
1
+ <!--
2
+ @component CommandPalette
3
+
4
+ Modal search + command launcher triggered by keyboard shortcut.
5
+ Supports built-in fuzzy scoring, grouped results, keyboard navigation,
6
+ and both declarative (sections prop) and composable (children) APIs.
7
+ Consumes --palette-* tokens from components.css.
8
+
9
+ @example Declarative
10
+ <CommandPalette
11
+ bind:open={paletteOpen}
12
+ trigger="mod+k"
13
+ placeholder="Type a command or search..."
14
+ sections={[
15
+ { heading: 'Recent', items: [
16
+ { value: 'pipeline-1', label: 'Customer ETL', onselect: () => goto('/pipelines/1') },
17
+ ]},
18
+ { heading: 'Actions', items: [
19
+ { value: 'new-pipeline', label: 'Create pipeline', shortcut: '⌘N', onselect: createPipeline },
20
+ ]},
21
+ ]}
22
+ />
23
+
24
+ @example Composable
25
+ <CommandPalette.Root bind:open={paletteOpen} trigger="mod+k">
26
+ <CommandPalette.Input placeholder="Search..." />
27
+ <CommandPalette.List>
28
+ <CommandPalette.Group heading="Recent">
29
+ <CommandPalette.Item value="pipeline-1" onselect={...}>Customer ETL</CommandPalette.Item>
30
+ </CommandPalette.Group>
31
+ <CommandPalette.Empty>No results found</CommandPalette.Empty>
32
+ </CommandPalette.List>
33
+ </CommandPalette.Root>
34
+ -->
35
+ <script module>
36
+ let _paletteUid = 0;
37
+
38
+ /**
39
+ * Simple fuzzy score: returns 0-1 for how well query matches text.
40
+ * 0 = no match, 1 = exact prefix match. Based on command-score heuristics.
41
+ * @param {string} text
42
+ * @param {string} query
43
+ * @returns {number}
44
+ */
45
+ export function commandScore(text, query) {
46
+ if (!query) return 1;
47
+ const lower = text.toLowerCase();
48
+ const q = query.toLowerCase();
49
+
50
+ // Exact prefix
51
+ if (lower.startsWith(q)) return 1;
52
+
53
+ // Contains as substring
54
+ const idx = lower.indexOf(q);
55
+ if (idx >= 0) {
56
+ // Prefer word boundary matches
57
+ if (lower[idx - 1] === ' ' || lower[idx - 1] === '-' || lower[idx - 1] === '/') {
58
+ return 0.8;
59
+ }
60
+ return 0.6;
61
+ }
62
+
63
+ // Character-by-character fuzzy match
64
+ let qi = 0;
65
+ let matched = 0;
66
+ for (let i = 0; i < lower.length && qi < q.length; i++) {
67
+ if (lower[i] === q[qi]) {
68
+ matched++;
69
+ qi++;
70
+ }
71
+ }
72
+
73
+ if (qi < q.length) return 0; // Not all query chars found
74
+ return (matched / text.length) * 0.4;
75
+ }
76
+ </script>
77
+
78
+ <script>
79
+ /**
80
+ * @typedef {{ value: string, label: string, description?: string, shortcut?: string, keywords?: string[], disabled?: boolean, onselect?: (value: string) => void }} PaletteItem
81
+ * @typedef {{ heading: string, items: PaletteItem[] }} PaletteSection
82
+ */
83
+
84
+ let {
85
+ /** @type {boolean} */
86
+ open = $bindable(false),
87
+ /** @type {string} Keyboard shortcut trigger. "mod" = ⌘ on Mac, Ctrl on Windows. */
88
+ trigger = 'mod+k',
89
+ /** @type {string} */
90
+ placeholder = 'Type a command or search...',
91
+ /** @type {PaletteSection[]} Declarative sections and items */
92
+ sections = [],
93
+ /** @type {boolean} Use built-in fuzzy scoring */
94
+ shouldFilter = true,
95
+ /** @type {boolean} Arrow keys wrap around the list */
96
+ loop = true,
97
+ /** @type {((open: boolean) => void) | undefined} */
98
+ onopenchange = undefined,
99
+ /** @type {string} */
100
+ class: className = '',
101
+ /** @type {import('svelte').Snippet | undefined} */
102
+ children = undefined,
103
+ ...rest
104
+ } = $props();
105
+
106
+ const paletteId = `palette-${_paletteUid++}`;
107
+ const inputId = `${paletteId}-input`;
108
+
109
+ let query = $state('');
110
+ let activeIndex = $state(0);
111
+ /** @type {HTMLInputElement | undefined} */
112
+ let inputEl = $state();
113
+ /** @type {HTMLElement | undefined} */
114
+ let listEl = $state();
115
+
116
+ // Parse trigger shortcut
117
+ const triggerParts = $derived(trigger.split('+'));
118
+ const triggerKey = $derived(triggerParts[triggerParts.length - 1].toLowerCase());
119
+ const triggerMod = $derived(triggerParts.includes('mod'));
120
+ const triggerShift = $derived(triggerParts.includes('shift'));
121
+ const triggerAlt = $derived(triggerParts.includes('alt'));
122
+
123
+ // Filter items by query
124
+ const filteredSections = $derived.by(() => {
125
+ if (!shouldFilter || !query) return sections;
126
+
127
+ return sections
128
+ .map(section => {
129
+ const scored = section.items
130
+ .map(item => {
131
+ const textScore = commandScore(item.label, query);
132
+ const descScore = item.description ? commandScore(item.description, query) * 0.5 : 0;
133
+ const kwScore = item.keywords
134
+ ? Math.max(...item.keywords.map(kw => commandScore(kw, query))) * 0.7
135
+ : 0;
136
+ return { item, score: Math.max(textScore, descScore, kwScore) };
137
+ })
138
+ .filter(({ score }) => score > 0)
139
+ .sort((a, b) => b.score - a.score);
140
+
141
+ return { heading: section.heading, items: scored.map(s => s.item) };
142
+ })
143
+ .filter(section => section.items.length > 0);
144
+ });
145
+
146
+ // Flat list of all visible items for keyboard navigation
147
+ const flatItems = $derived(filteredSections.flatMap(s => s.items));
148
+ const totalItems = $derived(flatItems.length);
149
+ const isEmpty = $derived(query.length > 0 && totalItems === 0);
150
+
151
+ // Reset active index when results change
152
+ $effect(() => {
153
+ // Access flatItems to create dependency
154
+ flatItems;
155
+ activeIndex = 0;
156
+ });
157
+
158
+ function openPalette() {
159
+ open = true;
160
+ query = '';
161
+ activeIndex = 0;
162
+ onopenchange?.(true);
163
+ requestAnimationFrame(() => inputEl?.focus());
164
+ }
165
+
166
+ function closePalette() {
167
+ open = false;
168
+ query = '';
169
+ onopenchange?.(false);
170
+ }
171
+
172
+ function selectItem(/** @type {PaletteItem} */ item) {
173
+ if (item.disabled) return;
174
+ closePalette();
175
+ item.onselect?.(item.value);
176
+ }
177
+
178
+ /** @param {KeyboardEvent} e */
179
+ function handleInputKeydown(e) {
180
+ switch (e.key) {
181
+ case 'ArrowDown':
182
+ e.preventDefault();
183
+ if (loop) {
184
+ activeIndex = (activeIndex + 1) % Math.max(totalItems, 1);
185
+ } else {
186
+ activeIndex = Math.min(activeIndex + 1, totalItems - 1);
187
+ }
188
+ scrollActiveIntoView();
189
+ break;
190
+ case 'ArrowUp':
191
+ e.preventDefault();
192
+ if (loop) {
193
+ activeIndex = activeIndex <= 0 ? Math.max(totalItems - 1, 0) : activeIndex - 1;
194
+ } else {
195
+ activeIndex = Math.max(activeIndex - 1, 0);
196
+ }
197
+ scrollActiveIntoView();
198
+ break;
199
+ case 'Enter':
200
+ e.preventDefault();
201
+ if (activeIndex >= 0 && activeIndex < totalItems) {
202
+ selectItem(flatItems[activeIndex]);
203
+ }
204
+ break;
205
+ case 'Escape':
206
+ e.preventDefault();
207
+ closePalette();
208
+ break;
209
+ }
210
+ }
211
+
212
+ function scrollActiveIntoView() {
213
+ requestAnimationFrame(() => {
214
+ const el = listEl?.querySelector(`[data-palette-index="${activeIndex}"]`);
215
+ el?.scrollIntoView({ block: 'nearest' });
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Highlight matching text
221
+ * @param {string} text
222
+ * @param {string} q
223
+ * @returns {{ before: string, match: string, after: string } | null}
224
+ */
225
+ function getHighlight(text, q) {
226
+ if (!q) return null;
227
+ const lower = text.toLowerCase();
228
+ const idx = lower.indexOf(q.toLowerCase());
229
+ if (idx === -1) return null;
230
+ return {
231
+ before: text.slice(0, idx),
232
+ match: text.slice(idx, idx + q.length),
233
+ after: text.slice(idx + q.length),
234
+ };
235
+ }
236
+
237
+ // Global keyboard shortcut listener
238
+ $effect(() => {
239
+ /** @param {KeyboardEvent} e */
240
+ function handleGlobalKeydown(e) {
241
+ const modMatch = triggerMod ? (e.metaKey || e.ctrlKey) : true;
242
+ const shiftMatch = triggerShift ? e.shiftKey : !e.shiftKey;
243
+ const altMatch = triggerAlt ? e.altKey : !e.altKey;
244
+
245
+ if (modMatch && shiftMatch && altMatch && e.key.toLowerCase() === triggerKey) {
246
+ e.preventDefault();
247
+ if (open) {
248
+ closePalette();
249
+ } else {
250
+ openPalette();
251
+ }
252
+ }
253
+ }
254
+
255
+ document.addEventListener('keydown', handleGlobalKeydown);
256
+ return () => document.removeEventListener('keydown', handleGlobalKeydown);
257
+ });
258
+
259
+ // Precomputed index map: item → global index (M6 fix)
260
+ const itemIndexMap = $derived(new Map(flatItems.map((item, i) => [item, i])));
261
+
262
+ // Focus trap + scroll lock when open (C1 fix)
263
+ /** @type {HTMLElement | undefined} */
264
+ let paletteEl = $state();
265
+ const FOCUSABLE = 'input, button, [tabindex]:not([tabindex="-1"])';
266
+
267
+ $effect(() => {
268
+ if (!open || !paletteEl) return;
269
+ document.body.style.overflow = 'hidden';
270
+
271
+ /** @param {KeyboardEvent} e */
272
+ function handleTrapKeydown(e) {
273
+ if (e.key !== 'Tab') return;
274
+ const focusable = /** @type {NodeListOf<HTMLElement>} */ (paletteEl?.querySelectorAll(FOCUSABLE));
275
+ if (!focusable?.length) return;
276
+
277
+ const first = focusable[0];
278
+ const last = focusable[focusable.length - 1];
279
+
280
+ if (e.shiftKey && document.activeElement === first) {
281
+ e.preventDefault();
282
+ last.focus();
283
+ } else if (!e.shiftKey && document.activeElement === last) {
284
+ e.preventDefault();
285
+ first.focus();
286
+ }
287
+ }
288
+
289
+ document.addEventListener('keydown', handleTrapKeydown);
290
+ return () => {
291
+ document.removeEventListener('keydown', handleTrapKeydown);
292
+ document.body.style.overflow = '';
293
+ };
294
+ });
295
+ </script>
296
+
297
+ {#if open}
298
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
299
+ <div
300
+ class="palette-backdrop"
301
+ onclick={closePalette}
302
+ aria-hidden="true"
303
+ role="presentation"
304
+ ></div>
305
+
306
+ <div class="palette-container">
307
+ <div
308
+ bind:this={paletteEl}
309
+ class="palette {className}"
310
+ role="dialog"
311
+ aria-modal="true"
312
+ aria-label="Command palette"
313
+ {...rest}
314
+ >
315
+ {#if children}
316
+ {@render children()}
317
+ {:else}
318
+ <!-- Declarative API -->
319
+ <div class="palette-input-wrap">
320
+ <span class="palette-input-icon" aria-hidden="true">
321
+ <svg class="palette-icon" viewBox="0 0 256 256" fill="none">
322
+ <circle cx="115.5" cy="115.5" r="67.5" stroke="currentColor" stroke-width="16" fill="none"/>
323
+ <line x1="164.2" y1="164.2" x2="224" y2="224" stroke="currentColor" stroke-width="16" stroke-linecap="round"/>
324
+ </svg>
325
+ </span>
326
+ <input
327
+ bind:this={inputEl}
328
+ id={inputId}
329
+ type="text"
330
+ class="palette-input"
331
+ {placeholder}
332
+ bind:value={query}
333
+ onkeydown={handleInputKeydown}
334
+ autocomplete="off"
335
+ spellcheck="false"
336
+ role="combobox"
337
+ aria-expanded={totalItems > 0}
338
+ aria-controls="{paletteId}-list"
339
+ aria-activedescendant={totalItems > 0 ? `${paletteId}-item-${activeIndex}` : undefined}
340
+ />
341
+ </div>
342
+
343
+ <div
344
+ bind:this={listEl}
345
+ id="{paletteId}-list"
346
+ class="palette-list"
347
+ role="listbox"
348
+ aria-label="Command palette results"
349
+ >
350
+ {#if isEmpty}
351
+ <div class="palette-empty" role="status">
352
+ No results for "{query}"
353
+ </div>
354
+ {:else}
355
+ {#each filteredSections as section}
356
+ <div class="palette-group" role="group" aria-label={section.heading}>
357
+ <div class="palette-group-heading">{section.heading}</div>
358
+ {#each section.items as item}
359
+ {@const globalIndex = itemIndexMap.get(item) ?? -1}
360
+ {@const isActive = globalIndex === activeIndex}
361
+ {@const hl = getHighlight(item.label, query)}
362
+ <div
363
+ id="{paletteId}-item-{globalIndex}"
364
+ class="palette-item"
365
+ class:palette-item-active={isActive}
366
+ class:palette-item-disabled={item.disabled}
367
+ role="option"
368
+ aria-selected={isActive}
369
+ aria-disabled={item.disabled || undefined}
370
+ data-palette-index={globalIndex}
371
+ onmousedown={(e) => { if (!item.disabled) e.preventDefault(); selectItem(item); }}
372
+ onmouseenter={() => { activeIndex = globalIndex; }}
373
+ >
374
+ <div class="palette-item-content">
375
+ <span class="palette-item-label">
376
+ {#if hl}
377
+ {hl.before}<mark class="palette-highlight">{hl.match}</mark>{hl.after}
378
+ {:else}
379
+ {item.label}
380
+ {/if}
381
+ </span>
382
+ {#if item.description}
383
+ <span class="palette-item-description">{item.description}</span>
384
+ {/if}
385
+ </div>
386
+ {#if item.shortcut}
387
+ <kbd class="palette-shortcut">{item.shortcut}</kbd>
388
+ {/if}
389
+ </div>
390
+ {/each}
391
+ </div>
392
+ {/each}
393
+ {/if}
394
+ </div>
395
+ {/if}
396
+ </div>
397
+ </div>
398
+ {/if}
399
+
400
+ <style>
401
+ /* ─── Backdrop ─── */
402
+ .palette-backdrop {
403
+ position: fixed;
404
+ inset: 0;
405
+ background: var(--palette-backdrop);
406
+ z-index: 60;
407
+ animation: palette-fade-in var(--palette-transition);
408
+ }
409
+
410
+ .palette-container {
411
+ position: fixed;
412
+ inset: 0;
413
+ z-index: 61;
414
+ display: flex;
415
+ align-items: flex-start;
416
+ justify-content: center;
417
+ padding-top: var(--palette-top-offset);
418
+ pointer-events: none;
419
+ }
420
+
421
+ /* ─── Dialog ─── */
422
+ .palette {
423
+ pointer-events: auto;
424
+ width: var(--palette-width);
425
+ max-width: calc(100vw - var(--space-xl));
426
+ max-height: var(--palette-max-height);
427
+ background: var(--palette-bg);
428
+ border-radius: var(--palette-radius);
429
+ box-shadow: var(--palette-shadow);
430
+ display: flex;
431
+ flex-direction: column;
432
+ overflow: hidden;
433
+ animation: palette-scale-in var(--palette-transition);
434
+ }
435
+
436
+ /* ─── Input ─── */
437
+ .palette-input-wrap {
438
+ position: relative;
439
+ display: flex;
440
+ align-items: center;
441
+ border-bottom: var(--palette-input-border);
442
+ flex-shrink: 0;
443
+ }
444
+
445
+ .palette-input-icon {
446
+ position: absolute;
447
+ left: var(--space-md);
448
+ display: flex;
449
+ color: var(--search-icon-color);
450
+ pointer-events: none;
451
+ }
452
+
453
+ .palette-icon {
454
+ width: var(--search-icon-size-md);
455
+ height: var(--search-icon-size-md);
456
+ }
457
+
458
+ .palette-input {
459
+ width: 100%;
460
+ height: var(--palette-input-height);
461
+ padding: var(--palette-input-padding);
462
+ padding-left: calc(var(--space-md) + var(--search-icon-size-md) + var(--space-sm));
463
+ font-family: var(--palette-input-font);
464
+ font-size: var(--palette-input-size);
465
+ color: var(--color-text);
466
+ background: transparent;
467
+ border: none;
468
+ outline: none;
469
+ }
470
+
471
+ .palette-input::placeholder {
472
+ color: var(--input-placeholder);
473
+ }
474
+
475
+ /* ─── List ─── */
476
+ .palette-list {
477
+ flex: 1;
478
+ overflow-y: auto;
479
+ padding: var(--palette-list-padding);
480
+ max-height: var(--palette-list-max-height);
481
+ }
482
+
483
+ /* ─── Group ─── */
484
+ .palette-group-heading {
485
+ font-family: var(--palette-group-font);
486
+ font-size: var(--palette-group-size);
487
+ letter-spacing: var(--palette-group-tracking);
488
+ color: var(--palette-group-color);
489
+ padding: var(--palette-group-padding);
490
+ text-transform: uppercase;
491
+ }
492
+
493
+ /* ─── Item ─── */
494
+ .palette-item {
495
+ display: flex;
496
+ align-items: center;
497
+ justify-content: space-between;
498
+ gap: var(--space-sm);
499
+ padding: var(--palette-item-padding);
500
+ border-radius: var(--palette-item-radius);
501
+ cursor: pointer;
502
+ transition: background var(--duration-instant) var(--easing-default);
503
+ }
504
+
505
+ .palette-item:hover,
506
+ .palette-item-active {
507
+ background: var(--palette-item-hover-bg);
508
+ }
509
+
510
+ .palette-item-active {
511
+ background: var(--palette-item-active-bg);
512
+ }
513
+
514
+ .palette-item-disabled {
515
+ opacity: 0.5;
516
+ cursor: not-allowed;
517
+ }
518
+
519
+ .palette-item-content {
520
+ display: flex;
521
+ flex-direction: column;
522
+ gap: var(--space-2xs);
523
+ min-width: 0;
524
+ }
525
+
526
+ .palette-item-label {
527
+ font-family: var(--input-font);
528
+ font-size: var(--input-font-size);
529
+ color: var(--color-text);
530
+ white-space: nowrap;
531
+ overflow: hidden;
532
+ text-overflow: ellipsis;
533
+ }
534
+
535
+ .palette-item-description {
536
+ font-family: var(--type-caption-font);
537
+ font-size: var(--type-caption-size);
538
+ color: var(--color-text-muted);
539
+ white-space: nowrap;
540
+ overflow: hidden;
541
+ text-overflow: ellipsis;
542
+ }
543
+
544
+ .palette-highlight {
545
+ background: transparent;
546
+ color: var(--palette-highlight-color);
547
+ font-weight: var(--type-overline-weight);
548
+ }
549
+
550
+ /* ─── Shortcut badge ─── */
551
+ .palette-shortcut {
552
+ flex-shrink: 0;
553
+ font-family: var(--palette-shortcut-font);
554
+ font-size: var(--palette-shortcut-size);
555
+ color: var(--palette-shortcut-color);
556
+ background: var(--palette-shortcut-bg);
557
+ border-radius: var(--palette-shortcut-radius);
558
+ padding: var(--palette-shortcut-padding);
559
+ border: var(--elevation-border);
560
+ line-height: 1;
561
+ }
562
+
563
+ /* ─── Empty state ─── */
564
+ .palette-empty {
565
+ padding: var(--space-xl) var(--space-md);
566
+ text-align: center;
567
+ font-family: var(--palette-empty-font);
568
+ font-size: var(--palette-empty-size);
569
+ color: var(--palette-empty-color);
570
+ }
571
+
572
+ /* ─── Animations ─── */
573
+ @keyframes palette-fade-in {
574
+ from { opacity: 0; }
575
+ to { opacity: 1; }
576
+ }
577
+
578
+ @keyframes palette-scale-in {
579
+ from { opacity: 0; transform: scale(0.96) translateY(-8px); }
580
+ to { opacity: 1; transform: scale(1) translateY(0); }
581
+ }
582
+
583
+ @media (prefers-reduced-motion: reduce) {
584
+ .palette,
585
+ .palette-backdrop {
586
+ animation: none;
587
+ }
588
+ .palette-item {
589
+ transition: none;
590
+ }
591
+ }
592
+
593
+ /* ─── Mobile ─── */
594
+ @media (max-width: 640px) {
595
+ .palette-container {
596
+ padding-top: var(--space-sm);
597
+ align-items: flex-start;
598
+ }
599
+
600
+ .palette {
601
+ max-width: calc(100vw - var(--space-md));
602
+ max-height: calc(100vh - var(--space-lg));
603
+ }
604
+ }
605
+ </style>
@@ -18,11 +18,13 @@
18
18
  <script>
19
19
  /**
20
20
  * @typedef {{ value: string, label: string }} SelectOption
21
- * @typedef {{ key: string, label: string, type: 'text' | 'select', width?: string, options?: SelectOption[], placeholder?: string }} ColumnDef
21
+ * @typedef {{ value: string, label: string, group?: string, description?: string }} ComboboxItem
22
+ * @typedef {{ key: string, label: string, type: 'text' | 'select' | 'combobox', width?: string, options?: SelectOption[], items?: ComboboxItem[], placeholder?: string }} ColumnDef
22
23
  */
23
24
 
24
25
  import Input from './Input.svelte';
25
26
  import Select from './Select.svelte';
27
+ import Combobox from './Combobox.svelte';
26
28
  import Button from './Button.svelte';
27
29
 
28
30
  let {
@@ -109,6 +111,15 @@
109
111
  {disabled}
110
112
  onchange={(e) => updateCell(rowIndex, col.key, e.target.value)}
111
113
  />
114
+ {:else if col.type === 'combobox'}
115
+ <Combobox
116
+ size="sm"
117
+ value={row[col.key] ?? ''}
118
+ items={col.items ?? []}
119
+ placeholder={col.placeholder}
120
+ {disabled}
121
+ onchange={(v) => updateCell(rowIndex, col.key, v)}
122
+ />
112
123
  {:else}
113
124
  <Input
114
125
  size="sm"
@@ -175,13 +175,13 @@
175
175
  all: unset;
176
176
  cursor: pointer;
177
177
  flex-shrink: 0;
178
- width: 20px;
179
- height: 20px;
178
+ width: var(--icon-size-md);
179
+ height: var(--icon-size-md);
180
180
  display: flex;
181
181
  align-items: center;
182
182
  justify-content: center;
183
183
  color: var(--color-text-muted);
184
- font-size: 16px;
184
+ font-size: var(--icon-size-sm);
185
185
  line-height: 1;
186
186
  border-radius: var(--radius-sm);
187
187
  }