@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,473 @@
1
+ <!--
2
+ @component SearchInput
3
+
4
+ Text input optimized for search with built-in icon, clear button,
5
+ debounced callback, optional keyboard shortcut hint, and loading state.
6
+ Extends the Input token system (--input-*) with search-specific tokens (--search-*).
7
+
8
+ @example Basic
9
+ <SearchInput placeholder="Search transforms..." onsearch={handleSearch} />
10
+
11
+ @example With shortcut hint
12
+ <SearchInput placeholder="Search..." shortcutHint="⌘K" onsearch={handleSearch} />
13
+
14
+ @example Loading state
15
+ <SearchInput placeholder="Search..." loading={true} onsearch={handleSearch} />
16
+
17
+ @example Collapsible (for toolbars / mobile)
18
+ <SearchInput collapsible placeholder="Search..." onsearch={handleSearch} />
19
+
20
+ @example Inside a modal (preserve value on Escape)
21
+ <SearchInput holdValueOnEscape placeholder="Search..." onsearch={handleSearch} />
22
+
23
+ @example No debounce (instant)
24
+ <SearchInput debounce={0} onsearch={handleSearch} />
25
+ -->
26
+ <script module>
27
+ let _searchUid = 0;
28
+ </script>
29
+
30
+ <script>
31
+ /**
32
+ * @typedef {'sm' | 'md' | 'lg'} Size
33
+ */
34
+
35
+ let {
36
+ /** @type {string} */
37
+ value = $bindable(''),
38
+ /** @type {string} */
39
+ placeholder = 'Search...',
40
+ /** @type {Size} */
41
+ size = 'md',
42
+ /** @type {number} Debounce delay in ms. 0 = disabled. */
43
+ debounce = 300,
44
+ /** @type {boolean} Show spinner instead of search icon */
45
+ loading = false,
46
+ /** @type {boolean} Collapse to icon-only when empty and blurred */
47
+ collapsible = false,
48
+ /** @type {string | null} Keyboard shortcut hint badge (e.g., "⌘K", "/") */
49
+ shortcutHint = null,
50
+ /** @type {boolean} Preserve value when Escape is pressed (for use inside modals) */
51
+ holdValueOnEscape = false,
52
+ /** @type {boolean} */
53
+ disabled = false,
54
+ /** @type {string} Accessible label (sr-only) */
55
+ label = 'Search',
56
+ /** @type {string | undefined} */
57
+ id = undefined,
58
+ /** @type {string} */
59
+ class: className = '',
60
+ /** @type {((value: string) => void) | undefined} Fires on every keystroke */
61
+ oninput = undefined,
62
+ /** @type {((value: string) => void) | undefined} Fires after debounce delay */
63
+ onsearch = undefined,
64
+ /** @type {(() => void) | undefined} Fires when cleared (X click or Escape) */
65
+ onclear = undefined,
66
+ ...rest
67
+ } = $props();
68
+
69
+ const fallbackId = `search-${_searchUid++}`;
70
+ const inputId = $derived(id ?? fallbackId);
71
+
72
+ /** @type {HTMLInputElement | undefined} */
73
+ let inputEl = $state();
74
+ let collapsed = $state(collapsible);
75
+ let focused = $state(false);
76
+
77
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
78
+ let debounceTimer;
79
+
80
+ const hasValue = $derived(value.length > 0);
81
+
82
+ // React to collapsible prop changes
83
+ $effect(() => {
84
+ if (collapsible && !hasValue) collapsed = true;
85
+ if (!collapsible) collapsed = false;
86
+ });
87
+ const showShortcut = $derived(shortcutHint && !focused && !hasValue);
88
+
89
+ function handleInput() {
90
+ oninput?.(value);
91
+
92
+ if (debounce <= 0) {
93
+ onsearch?.(value);
94
+ return;
95
+ }
96
+
97
+ clearTimeout(debounceTimer);
98
+ debounceTimer = setTimeout(() => {
99
+ onsearch?.(value);
100
+ }, debounce);
101
+ }
102
+
103
+ function handleClear() {
104
+ value = '';
105
+ clearTimeout(debounceTimer);
106
+ onsearch?.('');
107
+ onclear?.();
108
+ inputEl?.focus();
109
+ }
110
+
111
+ /** @param {KeyboardEvent} e */
112
+ function handleKeydown(e) {
113
+ if (e.key === 'Escape') {
114
+ if (holdValueOnEscape) {
115
+ // Let the event propagate (parent modal handles Escape)
116
+ return;
117
+ }
118
+ if (hasValue) {
119
+ e.preventDefault();
120
+ e.stopPropagation();
121
+ handleClear();
122
+ } else if (collapsible) {
123
+ collapsed = true;
124
+ inputEl?.blur();
125
+ }
126
+ }
127
+ }
128
+
129
+ function handleFocus() {
130
+ focused = true;
131
+ if (collapsible && collapsed) {
132
+ collapsed = false;
133
+ }
134
+ }
135
+
136
+ function handleBlur() {
137
+ focused = false;
138
+ if (collapsible && !hasValue) {
139
+ collapsed = true;
140
+ }
141
+ }
142
+
143
+ function handleExpandClick() {
144
+ collapsed = false;
145
+ // Focus after the transition
146
+ requestAnimationFrame(() => {
147
+ inputEl?.focus();
148
+ });
149
+ }
150
+
151
+ // Cleanup on destroy
152
+ $effect(() => {
153
+ return () => clearTimeout(debounceTimer);
154
+ });
155
+ </script>
156
+
157
+ {#if collapsed}
158
+ <button
159
+ class="search-collapsed search-collapsed-{size} {className}"
160
+ onclick={handleExpandClick}
161
+ aria-label={label}
162
+ {disabled}
163
+ {...rest}
164
+ >
165
+ <svg class="search-icon search-icon-{size}" viewBox="0 0 256 256" fill="none" aria-hidden="true">
166
+ <circle cx="115.5" cy="115.5" r="67.5" stroke="currentColor" stroke-width="16" fill="none"/>
167
+ <line x1="164.2" y1="164.2" x2="224" y2="224" stroke="currentColor" stroke-width="16" stroke-linecap="round"/>
168
+ </svg>
169
+ </button>
170
+ {:else}
171
+ <div
172
+ class="search-group {className}"
173
+ class:search-group-focused={focused}
174
+ role="search"
175
+ aria-label={label}
176
+ {...rest}
177
+ >
178
+ <label class="sr-only" for={inputId}>{label}</label>
179
+
180
+ <span class="search-leading search-leading-{size}" aria-hidden="true">
181
+ {#if loading}
182
+ <span class="search-spinner search-spinner-{size}"></span>
183
+ {:else}
184
+ <svg class="search-icon search-icon-{size}" viewBox="0 0 256 256" fill="none">
185
+ <circle cx="115.5" cy="115.5" r="67.5" stroke="currentColor" stroke-width="16" fill="none"/>
186
+ <line x1="164.2" y1="164.2" x2="224" y2="224" stroke="currentColor" stroke-width="16" stroke-linecap="round"/>
187
+ </svg>
188
+ {/if}
189
+ </span>
190
+
191
+ <input
192
+ bind:this={inputEl}
193
+ id={inputId}
194
+ type="search"
195
+ class="search-input search-input-{size}"
196
+ {placeholder}
197
+ {disabled}
198
+ bind:value
199
+ oninput={handleInput}
200
+ onkeydown={handleKeydown}
201
+ onfocus={handleFocus}
202
+ onblur={handleBlur}
203
+ autocomplete="off"
204
+ spellcheck="false"
205
+ aria-label={label}
206
+ />
207
+
208
+ {#if hasValue}
209
+ <button
210
+ class="search-clear search-clear-{size}"
211
+ onclick={handleClear}
212
+ aria-label="Clear search"
213
+ tabindex="-1"
214
+ type="button"
215
+ >
216
+ <svg viewBox="0 0 256 256" fill="none" aria-hidden="true">
217
+ <line x1="80" y1="80" x2="176" y2="176" stroke="currentColor" stroke-width="16" stroke-linecap="round"/>
218
+ <line x1="176" y1="80" x2="80" y2="176" stroke="currentColor" stroke-width="16" stroke-linecap="round"/>
219
+ </svg>
220
+ </button>
221
+ {:else if showShortcut}
222
+ <kbd class="search-shortcut search-shortcut-{size}">{shortcutHint}</kbd>
223
+ {/if}
224
+ </div>
225
+ {/if}
226
+
227
+ <style>
228
+ .sr-only {
229
+ position: absolute;
230
+ width: 1px;
231
+ height: 1px;
232
+ padding: 0;
233
+ margin: -1px;
234
+ overflow: hidden;
235
+ clip: rect(0, 0, 0, 0);
236
+ white-space: nowrap;
237
+ border-width: 0;
238
+ }
239
+
240
+ /* ─── Container ─── */
241
+ .search-group {
242
+ position: relative;
243
+ display: flex;
244
+ align-items: center;
245
+ width: 100%;
246
+ }
247
+
248
+ /* ─── Input ─── */
249
+ .search-input {
250
+ font-family: var(--input-font);
251
+ font-size: var(--input-font-size);
252
+ border: var(--input-border);
253
+ border-radius: var(--input-radius);
254
+ background: var(--input-bg);
255
+ color: var(--input-text);
256
+ transition: border var(--input-transition);
257
+ width: 100%;
258
+ /* Remove native search decorations */
259
+ -webkit-appearance: none;
260
+ appearance: none;
261
+ }
262
+
263
+ .search-input::-webkit-search-cancel-button,
264
+ .search-input::-webkit-search-decoration {
265
+ display: none;
266
+ }
267
+
268
+ .search-input::placeholder {
269
+ color: var(--input-placeholder);
270
+ }
271
+
272
+ .search-input:focus {
273
+ outline: none;
274
+ border: var(--input-border-focus);
275
+ }
276
+
277
+ .search-input:disabled {
278
+ opacity: 0.5;
279
+ cursor: not-allowed;
280
+ }
281
+
282
+ /* Size variants — padding accounts for icon + clear/shortcut */
283
+ .search-input-sm {
284
+ height: var(--input-sm-height);
285
+ padding: 0 var(--input-sm-padding-x);
286
+ padding-left: calc(var(--input-sm-padding-x) + var(--search-icon-size-sm) + var(--space-xs));
287
+ padding-right: calc(var(--input-sm-padding-x) + var(--search-icon-size-sm) + var(--space-xs));
288
+ }
289
+
290
+ .search-input-md {
291
+ height: var(--input-md-height);
292
+ padding: 0 var(--input-md-padding-x);
293
+ padding-left: calc(var(--input-md-padding-x) + var(--search-icon-size-md) + var(--space-xs));
294
+ padding-right: calc(var(--input-md-padding-x) + var(--search-icon-size-md) + var(--space-xs));
295
+ }
296
+
297
+ .search-input-lg {
298
+ height: var(--input-lg-height);
299
+ padding: 0 var(--input-lg-padding-x);
300
+ padding-left: calc(var(--input-lg-padding-x) + var(--search-icon-size-lg) + var(--space-xs));
301
+ padding-right: calc(var(--input-lg-padding-x) + var(--search-icon-size-lg) + var(--space-xs));
302
+ }
303
+
304
+ /* ─── Leading icon / spinner ─── */
305
+ .search-leading {
306
+ position: absolute;
307
+ top: 50%;
308
+ transform: translateY(-50%);
309
+ display: flex;
310
+ align-items: center;
311
+ justify-content: center;
312
+ color: var(--search-icon-color);
313
+ pointer-events: none;
314
+ transition: color var(--input-transition);
315
+ }
316
+
317
+ .search-group-focused .search-leading {
318
+ color: var(--search-icon-color-focus);
319
+ }
320
+
321
+ .search-leading-sm { left: var(--input-sm-padding-x); }
322
+ .search-leading-md { left: var(--input-md-padding-x); }
323
+ .search-leading-lg { left: var(--input-lg-padding-x); }
324
+
325
+ .search-icon {
326
+ display: block;
327
+ }
328
+
329
+ .search-icon-sm { width: var(--search-icon-size-sm); height: var(--search-icon-size-sm); }
330
+ .search-icon-md { width: var(--search-icon-size-md); height: var(--search-icon-size-md); }
331
+ .search-icon-lg { width: var(--search-icon-size-lg); height: var(--search-icon-size-lg); }
332
+
333
+ /* ─── Spinner ─── */
334
+ .search-spinner {
335
+ border: var(--border-width-thick) solid var(--color-border);
336
+ border-top-color: var(--search-spinner-color);
337
+ border-radius: var(--radius-circle);
338
+ animation: search-spin 0.6s linear infinite;
339
+ }
340
+
341
+ .search-spinner-sm { width: var(--search-icon-size-sm); height: var(--search-icon-size-sm); }
342
+ .search-spinner-md { width: var(--search-icon-size-md); height: var(--search-icon-size-md); }
343
+ .search-spinner-lg { width: var(--search-icon-size-lg); height: var(--search-icon-size-lg); }
344
+
345
+ @keyframes search-spin {
346
+ to { transform: rotate(360deg); }
347
+ }
348
+
349
+ /* ─── Clear button ─── */
350
+ .search-clear {
351
+ all: unset;
352
+ box-sizing: border-box;
353
+ position: absolute;
354
+ top: 50%;
355
+ transform: translateY(-50%);
356
+ display: flex;
357
+ align-items: center;
358
+ justify-content: center;
359
+ cursor: pointer;
360
+ color: var(--search-clear-color);
361
+ border-radius: var(--radius-sm);
362
+ transition: color var(--input-transition);
363
+ }
364
+
365
+ .search-clear:hover {
366
+ color: var(--search-clear-color-hover);
367
+ }
368
+
369
+ .search-clear:focus-visible {
370
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
371
+ outline-offset: var(--focus-ring-offset);
372
+ }
373
+
374
+ .search-clear svg {
375
+ width: 100%;
376
+ height: 100%;
377
+ }
378
+
379
+ .search-clear-sm {
380
+ right: var(--input-sm-padding-x);
381
+ width: var(--search-icon-size-sm);
382
+ height: var(--search-icon-size-sm);
383
+ }
384
+
385
+ .search-clear-md {
386
+ right: var(--input-md-padding-x);
387
+ width: var(--search-icon-size-md);
388
+ height: var(--search-icon-size-md);
389
+ }
390
+
391
+ .search-clear-lg {
392
+ right: var(--input-lg-padding-x);
393
+ width: var(--search-icon-size-lg);
394
+ height: var(--search-icon-size-lg);
395
+ }
396
+
397
+ /* ─── Shortcut badge ─── */
398
+ .search-shortcut {
399
+ position: absolute;
400
+ top: 50%;
401
+ transform: translateY(-50%);
402
+ font-family: var(--search-shortcut-font);
403
+ font-size: var(--search-shortcut-size);
404
+ color: var(--search-shortcut-color);
405
+ background: var(--search-shortcut-bg);
406
+ border-radius: var(--search-shortcut-radius);
407
+ padding: var(--search-shortcut-padding);
408
+ pointer-events: none;
409
+ line-height: 1;
410
+ border: var(--elevation-border);
411
+ }
412
+
413
+ .search-shortcut-sm { right: var(--input-sm-padding-x); }
414
+ .search-shortcut-md { right: var(--input-md-padding-x); }
415
+ .search-shortcut-lg { right: var(--input-lg-padding-x); }
416
+
417
+ /* ─── Collapsed (icon-only) ─── */
418
+ .search-collapsed {
419
+ all: unset;
420
+ box-sizing: border-box;
421
+ cursor: pointer;
422
+ display: flex;
423
+ align-items: center;
424
+ justify-content: center;
425
+ border: var(--input-border);
426
+ border-radius: var(--input-radius);
427
+ background: var(--input-bg);
428
+ color: var(--search-icon-color);
429
+ transition: all var(--search-collapse-transition);
430
+ }
431
+
432
+ .search-collapsed:hover {
433
+ color: var(--search-icon-color-focus);
434
+ border: var(--input-border-focus);
435
+ }
436
+
437
+ .search-collapsed:focus-visible {
438
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
439
+ outline-offset: var(--focus-ring-offset);
440
+ }
441
+
442
+ .search-collapsed:disabled {
443
+ opacity: 0.5;
444
+ cursor: not-allowed;
445
+ }
446
+
447
+ .search-collapsed-sm {
448
+ width: var(--input-sm-height);
449
+ height: var(--input-sm-height);
450
+ }
451
+
452
+ .search-collapsed-md {
453
+ width: var(--input-md-height);
454
+ height: var(--input-md-height);
455
+ }
456
+
457
+ .search-collapsed-lg {
458
+ width: var(--input-lg-height);
459
+ height: var(--input-lg-height);
460
+ }
461
+
462
+ @media (prefers-reduced-motion: reduce) {
463
+ .search-input,
464
+ .search-leading,
465
+ .search-clear,
466
+ .search-collapsed {
467
+ transition: none;
468
+ }
469
+ .search-spinner {
470
+ animation: none;
471
+ }
472
+ }
473
+ </style>
@@ -4,6 +4,10 @@
4
4
  Native select with label, help text, and error state.
5
5
  Consumes --input-* tokens from components.css.
6
6
 
7
+ Usage patterns:
8
+ - Two-way binding: `<Select bind:value={myVar} options={opts} />`
9
+ - Controlled: `<Select value={myVar} onchange={(val) => myVar = val} options={opts} />`
10
+
7
11
  @example
8
12
  <Select label="COUNTRY" placeholder="Select a country" options={[
9
13
  { value: 'pt', label: 'Portugal' },
@@ -25,10 +29,12 @@
25
29
  label = undefined,
26
30
  /** @type {string | undefined} */
27
31
  placeholder = undefined,
28
- /** @type {string} */
29
- value = $bindable(''),
32
+ /** @type {string | undefined} */
33
+ value = $bindable(),
30
34
  /** @type {Option[]} */
31
35
  options = [],
36
+ /** @type {((value: string) => void) | undefined} */
37
+ onchange = undefined,
32
38
  /** @type {string | undefined} */
33
39
  help = undefined,
34
40
  /** @type {string | undefined} */
@@ -48,6 +54,12 @@
48
54
  const selectId = $derived(id ?? fallbackId);
49
55
  const hintId = $derived(`${selectId}-hint`);
50
56
  const hasHint = $derived(!!error || !!help);
57
+
58
+ let mounted = false;
59
+ $effect(() => {
60
+ if (!mounted) { mounted = true; return; }
61
+ if (onchange && value !== undefined) onchange(value);
62
+ });
51
63
  </script>
52
64
 
53
65
  <div class="input-group {className}">
@@ -122,8 +122,8 @@
122
122
  .nav-icon-wrap {
123
123
  display: flex;
124
124
  flex-shrink: 0;
125
- width: 16px;
126
- height: 16px;
125
+ width: var(--icon-size-sm);
126
+ height: var(--icon-size-sm);
127
127
  }
128
128
 
129
129
  .nav-icon-wrap :global(svg) {
@@ -108,8 +108,8 @@
108
108
  }
109
109
 
110
110
  .stepper-check {
111
- width: 12px;
112
- height: 12px;
111
+ width: var(--icon-size-xs);
112
+ height: var(--icon-size-xs);
113
113
  }
114
114
 
115
115
  .stepper-label {
@@ -45,6 +45,11 @@ export { default as MenuSeparator } from "./MenuSeparator.svelte";
45
45
  // Form controls — composite
46
46
  export { default as Combobox } from "./Combobox.svelte";
47
47
 
48
+ // Search
49
+ export { default as SearchInput } from "./SearchInput.svelte";
50
+ export { default as FilterBar } from "./FilterBar.svelte";
51
+ export { default as CommandPalette } from "./CommandPalette.svelte";
52
+
48
53
  // Tabs
49
54
  export { default as Tabs } from "./Tabs.svelte";
50
55
  export { default as TabList } from "./TabList.svelte";
@@ -70,3 +75,4 @@ export { default as CodeEditor } from "./CodeEditor.svelte";
70
75
  export { default as CollapsibleSection } from "./CollapsibleSection.svelte";
71
76
  export { default as OptionGrid } from "./OptionGrid.svelte";
72
77
  export { default as ConditionTable } from "./ConditionTable.svelte";
78
+ export { default as LogViewer } from "./LogViewer.svelte";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/tokens/base.css CHANGED
@@ -119,6 +119,14 @@
119
119
  --raw-tracking-micro: 0.005em;
120
120
  --raw-tracking-loose: 0.01em;
121
121
 
122
+ /* ─── Icon Sizes ─── */
123
+ --raw-icon-12: 0.75rem;
124
+ --raw-icon-16: 1rem;
125
+ --raw-icon-20: 1.25rem;
126
+ --raw-icon-24: 1.5rem;
127
+ --raw-icon-32: 2rem;
128
+ --raw-icon-48: 3rem;
129
+
122
130
  /* ─── Spacing ─── */
123
131
  --raw-space-2: 0.125rem;
124
132
  --raw-space-4: 0.25rem;