@aiaiai-pt/design-system 0.4.2 → 0.4.4

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,306 @@
1
+ <!--
2
+ @component FilterPanel
3
+
4
+ Typed, labeled filter bar for admin/data UIs.
5
+ Each filter renders as the appropriate widget based on its type:
6
+ select → Select dropdown, boolean → Toggle pair, search → Combobox.
7
+
8
+ Consumes --filter-chip-*, --filter-bar-clear-*, and --input-* tokens from components.css.
9
+
10
+ @example
11
+ <FilterPanel
12
+ filters={[
13
+ { key: 'status', label: 'Status', type: 'select', options: [
14
+ { value: 'open', label: 'Open' },
15
+ { value: 'closed', label: 'Closed' },
16
+ ]},
17
+ { key: 'active', label: 'Active', type: 'boolean' },
18
+ ]}
19
+ bind:value={activeFilters}
20
+ onchange={handleFilterChange}
21
+ />
22
+ -->
23
+ <script module>
24
+ /**
25
+ * @typedef {{ value: string, label: string }} FilterOption
26
+ * @typedef {{
27
+ * key: string,
28
+ * label: string,
29
+ * type: 'select' | 'boolean' | 'search',
30
+ * options?: FilterOption[],
31
+ * onsearch?: (query: string) => void,
32
+ * searchItems?: FilterOption[],
33
+ * searchLoading?: boolean,
34
+ * }} FilterFieldDef
35
+ */
36
+ </script>
37
+
38
+ <script>
39
+ import Combobox from './Combobox.svelte';
40
+
41
+ let {
42
+ /** @type {FilterFieldDef[]} */
43
+ filters = [],
44
+ /** @type {Record<string, any>} */
45
+ value = $bindable({}),
46
+ /** @type {((value: Record<string, any>) => void) | undefined} */
47
+ onchange = undefined,
48
+ /** @type {(() => void) | undefined} */
49
+ onclear = undefined,
50
+ /** @type {string} */
51
+ class: className = '',
52
+ ...rest
53
+ } = $props();
54
+
55
+ const activeCount = $derived(
56
+ Object.values(value).filter(v => v !== undefined && v !== null && v !== '').length
57
+ );
58
+
59
+ /**
60
+ * @param {string} key
61
+ * @param {string | undefined} val
62
+ */
63
+ function setFilter(key, val) {
64
+ value = { ...value, [key]: val === '' || val === undefined ? undefined : val };
65
+ onchange?.(value);
66
+ }
67
+
68
+ /**
69
+ * @param {string} key
70
+ * @param {string} optionValue
71
+ */
72
+ function toggleBoolean(key, optionValue) {
73
+ value = {
74
+ ...value,
75
+ [key]: value[key] === optionValue ? undefined : optionValue,
76
+ };
77
+ onchange?.(value);
78
+ }
79
+
80
+ function clearAll() {
81
+ value = {};
82
+ onchange?.({});
83
+ onclear?.();
84
+ }
85
+ </script>
86
+
87
+ {#if filters.length > 0}
88
+ <div
89
+ class="filter-panel {className}"
90
+ role="group"
91
+ aria-label="Filters"
92
+ {...rest}
93
+ >
94
+ {#each filters as filter (filter.key)}
95
+ <div class="filter-field">
96
+ {#if filter.type === 'select' && filter.options}
97
+ <label class="filter-field-label" for="filter-{filter.key}">{filter.label}</label>
98
+ <div class="filter-select-wrap">
99
+ <select
100
+ id="filter-{filter.key}"
101
+ class="filter-select filter-select-sm"
102
+ value={value[filter.key] ?? ''}
103
+ onchange={(e) => setFilter(filter.key, /** @type {HTMLSelectElement} */ (e.currentTarget).value)}
104
+ >
105
+ <option value="">All</option>
106
+ {#each filter.options as opt (opt.value)}
107
+ <option value={opt.value}>{opt.label}</option>
108
+ {/each}
109
+ </select>
110
+ <span class="filter-select-chevron" aria-hidden="true">
111
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M2.5 4.5L6 8l3.5-3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
112
+ </span>
113
+ </div>
114
+ {:else if filter.type === 'boolean'}
115
+ {@const opts = filter.options ?? [{ value: 'true', label: 'Yes' }, { value: 'false', label: 'No' }]}
116
+ <span class="filter-field-label">{filter.label}</span>
117
+ <div class="filter-bool-chips">
118
+ {#each opts as opt (opt.value)}
119
+ {@const active = value[filter.key] === opt.value}
120
+ <button
121
+ class="filter-chip"
122
+ class:filter-chip-active={active}
123
+ aria-pressed={active}
124
+ type="button"
125
+ onclick={() => toggleBoolean(filter.key, opt.value)}
126
+ >{opt.label}</button>
127
+ {/each}
128
+ </div>
129
+ {:else if filter.type === 'search'}
130
+ <Combobox
131
+ size="sm"
132
+ label={filter.label}
133
+ placeholder="All"
134
+ items={filter.searchItems ?? []}
135
+ value={value[filter.key] ?? ''}
136
+ onchange={(val) => setFilter(filter.key, val)}
137
+ onsearch={filter.onsearch}
138
+ loading={filter.searchLoading ?? false}
139
+ />
140
+ {/if}
141
+ </div>
142
+ {/each}
143
+
144
+ {#if activeCount >= 2}
145
+ <button
146
+ class="filter-panel-clear"
147
+ onclick={clearAll}
148
+ type="button"
149
+ >
150
+ Clear all
151
+ </button>
152
+ {/if}
153
+ </div>
154
+ {/if}
155
+
156
+ <style>
157
+ .filter-panel {
158
+ display: flex;
159
+ align-items: flex-end;
160
+ gap: var(--space-md);
161
+ flex-wrap: wrap;
162
+ }
163
+
164
+ .filter-select-wrap {
165
+ position: relative;
166
+ }
167
+
168
+ .filter-select {
169
+ font-family: var(--input-font);
170
+ font-size: var(--input-font-size);
171
+ border: var(--input-border);
172
+ border-radius: var(--input-radius);
173
+ background: var(--input-bg);
174
+ color: var(--input-text);
175
+ width: 100%;
176
+ appearance: none;
177
+ padding-right: var(--space-xl);
178
+ }
179
+
180
+ .filter-select-sm {
181
+ height: var(--input-sm-height);
182
+ padding: 0 var(--input-sm-padding-x);
183
+ }
184
+
185
+ .filter-select:focus {
186
+ outline: none;
187
+ border: var(--input-border-focus);
188
+ }
189
+
190
+ .filter-select-chevron {
191
+ position: absolute;
192
+ right: var(--space-sm);
193
+ top: 50%;
194
+ transform: translateY(-50%);
195
+ pointer-events: none;
196
+ color: var(--input-placeholder);
197
+ display: flex;
198
+ }
199
+
200
+ .filter-field {
201
+ display: flex;
202
+ flex-direction: column;
203
+ gap: var(--space-2xs);
204
+ min-width: 140px;
205
+ max-width: 220px;
206
+ }
207
+
208
+ .filter-field-label {
209
+ font-family: var(--input-label-font);
210
+ font-size: var(--input-label-size);
211
+ letter-spacing: var(--input-label-tracking);
212
+ color: var(--input-label-color);
213
+ }
214
+
215
+ .filter-bool-chips {
216
+ display: flex;
217
+ gap: var(--filter-chip-gap);
218
+ }
219
+
220
+ /* Reuse existing filter-chip tokens */
221
+ .filter-chip {
222
+ all: unset;
223
+ box-sizing: border-box;
224
+ display: inline-flex;
225
+ align-items: center;
226
+ height: var(--filter-chip-height);
227
+ padding: var(--filter-chip-padding);
228
+ font-family: var(--filter-chip-font);
229
+ font-size: var(--filter-chip-size);
230
+ letter-spacing: var(--filter-chip-tracking);
231
+ color: var(--filter-chip-color);
232
+ background: var(--filter-chip-bg);
233
+ border: var(--filter-chip-border);
234
+ border-radius: var(--filter-chip-radius);
235
+ cursor: pointer;
236
+ white-space: nowrap;
237
+ transition: all var(--filter-chip-transition);
238
+ user-select: none;
239
+ }
240
+
241
+ .filter-chip:hover {
242
+ background: var(--filter-chip-bg-hover);
243
+ }
244
+
245
+ .filter-chip:focus-visible {
246
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
247
+ outline-offset: var(--focus-ring-offset);
248
+ }
249
+
250
+ .filter-chip-active {
251
+ background: var(--filter-chip-bg-active);
252
+ color: var(--filter-chip-color-active);
253
+ border: var(--filter-chip-border-active);
254
+ }
255
+
256
+ .filter-chip-active:hover {
257
+ background: var(--filter-chip-bg-active);
258
+ }
259
+
260
+ .filter-panel-clear {
261
+ all: unset;
262
+ box-sizing: border-box;
263
+ font-family: var(--filter-chip-font);
264
+ font-size: var(--filter-chip-size);
265
+ letter-spacing: var(--filter-chip-tracking);
266
+ color: var(--filter-bar-clear-color);
267
+ cursor: pointer;
268
+ white-space: nowrap;
269
+ padding-bottom: var(--space-xs);
270
+ transition: color var(--filter-chip-transition);
271
+ }
272
+
273
+ .filter-panel-clear:hover {
274
+ color: var(--filter-bar-clear-color-hover);
275
+ }
276
+
277
+ .filter-panel-clear:focus-visible {
278
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
279
+ outline-offset: var(--focus-ring-offset);
280
+ border-radius: var(--radius-sm);
281
+ }
282
+
283
+ @media (max-width: 640px) {
284
+ .filter-panel {
285
+ flex-wrap: nowrap;
286
+ overflow-x: auto;
287
+ -webkit-overflow-scrolling: touch;
288
+ scrollbar-width: none;
289
+ }
290
+
291
+ .filter-panel::-webkit-scrollbar {
292
+ display: none;
293
+ }
294
+
295
+ .filter-field {
296
+ flex-shrink: 0;
297
+ }
298
+ }
299
+
300
+ @media (prefers-reduced-motion: reduce) {
301
+ .filter-chip,
302
+ .filter-panel-clear {
303
+ transition: none;
304
+ }
305
+ }
306
+ </style>
@@ -0,0 +1,59 @@
1
+ export default FilterPanel;
2
+ export type FilterOption = {
3
+ value: string;
4
+ label: string;
5
+ };
6
+ export type FilterFieldDef = {
7
+ key: string;
8
+ label: string;
9
+ type: "select" | "boolean" | "search";
10
+ options?: FilterOption[];
11
+ /** Async search callback for 'search' type filters (FK relationships) */
12
+ onsearch?: (query: string) => void;
13
+ /** Items for 'search' type, managed by parent */
14
+ searchItems?: FilterOption[];
15
+ /** Loading state for 'search' type */
16
+ searchLoading?: boolean;
17
+ };
18
+ type FilterPanel = {
19
+ $on?(type: string, callback: (e: any) => void): () => void;
20
+ $set?(props: Partial<$$ComponentProps>): void;
21
+ };
22
+ /**
23
+ * FilterPanel
24
+ *
25
+ * Typed, labeled filter bar for admin/data UIs.
26
+ * Each filter renders as the appropriate widget based on its type:
27
+ * select → Select dropdown, boolean → Toggle pair, search → Combobox.
28
+ *
29
+ * @example
30
+ * <FilterPanel
31
+ * filters={[
32
+ * { key: 'status', label: 'Status', type: 'select', options: [
33
+ * { value: 'open', label: 'Open' },
34
+ * { value: 'closed', label: 'Closed' },
35
+ * ]},
36
+ * { key: 'active', label: 'Active', type: 'boolean' },
37
+ * ]}
38
+ * bind:value={activeFilters}
39
+ * onchange={handleFilterChange}
40
+ * />
41
+ */
42
+ declare const FilterPanel: import("svelte").Component<
43
+ {
44
+ filters?: FilterFieldDef[];
45
+ value?: Record<string, any>;
46
+ onchange?: (value: Record<string, any>) => void;
47
+ onclear?: () => void;
48
+ class?: string;
49
+ } & Record<string, any>,
50
+ {},
51
+ "value"
52
+ >;
53
+ type $$ComponentProps = {
54
+ filters?: FilterFieldDef[];
55
+ value?: Record<string, any>;
56
+ onchange?: (value: Record<string, any>) => void;
57
+ onclear?: () => void;
58
+ class?: string;
59
+ } & Record<string, any>;
@@ -48,6 +48,7 @@ export { default as Combobox } from "./Combobox.svelte";
48
48
  // Search
49
49
  export { default as SearchInput } from "./SearchInput.svelte";
50
50
  export { default as FilterBar } from "./FilterBar.svelte";
51
+ export { default as FilterPanel } from "./FilterPanel.svelte";
51
52
  export { default as CommandPalette } from "./CommandPalette.svelte";
52
53
 
53
54
  // Tabs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",