@aiaiai-pt/design-system 0.4.1 → 0.4.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.
|
@@ -53,6 +53,10 @@
|
|
|
53
53
|
size = 'md',
|
|
54
54
|
/** @type {((value: string) => void) | undefined} */
|
|
55
55
|
onchange = undefined,
|
|
56
|
+
/** @type {((query: string) => void) | undefined} — async search; when set, disables internal filtering */
|
|
57
|
+
onsearch = undefined,
|
|
58
|
+
/** @type {boolean} */
|
|
59
|
+
loading = false,
|
|
56
60
|
/** @type {string} */
|
|
57
61
|
class: className = '',
|
|
58
62
|
...rest
|
|
@@ -76,8 +80,21 @@
|
|
|
76
80
|
// Selected item label for display
|
|
77
81
|
const selectedItem = $derived(items.find(i => i.value === value));
|
|
78
82
|
|
|
79
|
-
//
|
|
83
|
+
// Debounced onsearch dispatch
|
|
84
|
+
let _searchTimer = 0;
|
|
85
|
+
$effect(() => {
|
|
86
|
+
// Subscribe to query changes; only fire if onsearch is set
|
|
87
|
+
const q = query;
|
|
88
|
+
if (!onsearch) return;
|
|
89
|
+
clearTimeout(_searchTimer);
|
|
90
|
+
if (!q) return;
|
|
91
|
+
_searchTimer = /** @type {any} */ (setTimeout(() => onsearch(q), 300));
|
|
92
|
+
return () => clearTimeout(_searchTimer);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Filter items by query — skip when onsearch is set (parent controls items)
|
|
80
96
|
const filtered = $derived.by(() => {
|
|
97
|
+
if (onsearch) return items;
|
|
81
98
|
if (!query) return items;
|
|
82
99
|
const q = query.toLowerCase();
|
|
83
100
|
return items.filter(i =>
|
|
@@ -228,7 +245,11 @@
|
|
|
228
245
|
role="listbox"
|
|
229
246
|
aria-label={label ?? 'Options'}
|
|
230
247
|
>
|
|
231
|
-
{#if
|
|
248
|
+
{#if loading}
|
|
249
|
+
<li class="combobox-empty" role="option" aria-selected="false" aria-disabled="true">
|
|
250
|
+
Searching…
|
|
251
|
+
</li>
|
|
252
|
+
{:else if filtered.length === 0}
|
|
232
253
|
<li class="combobox-empty" role="option" aria-selected="false" aria-disabled="true">
|
|
233
254
|
No results found
|
|
234
255
|
</li>
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
export default Combobox;
|
|
2
2
|
export type ComboboxItem = {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
group?: string;
|
|
6
|
+
description?: string;
|
|
7
7
|
};
|
|
8
8
|
type Combobox = {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
10
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
13
13
|
* Combobox
|
|
@@ -34,7 +34,8 @@ type Combobox = {
|
|
|
34
34
|
* bind:value={selected}
|
|
35
35
|
* />
|
|
36
36
|
*/
|
|
37
|
-
declare const Combobox: import("svelte").Component<
|
|
37
|
+
declare const Combobox: import("svelte").Component<
|
|
38
|
+
{
|
|
38
39
|
items?: any[];
|
|
39
40
|
value?: string;
|
|
40
41
|
label?: any;
|
|
@@ -44,17 +45,26 @@ declare const Combobox: import("svelte").Component<{
|
|
|
44
45
|
help?: any;
|
|
45
46
|
size?: string;
|
|
46
47
|
onchange?: any;
|
|
48
|
+
/** Async search callback; when set, disables internal filtering. Parent must update `items` with results. */
|
|
49
|
+
onsearch?: (query: string) => void;
|
|
50
|
+
/** Show "Searching…" in dropdown while loading async results */
|
|
51
|
+
loading?: boolean;
|
|
47
52
|
class?: string;
|
|
48
|
-
} & Record<string, any>,
|
|
53
|
+
} & Record<string, any>,
|
|
54
|
+
{},
|
|
55
|
+
"value"
|
|
56
|
+
>;
|
|
49
57
|
type $$ComponentProps = {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
items?: any[];
|
|
59
|
+
value?: string;
|
|
60
|
+
label?: any;
|
|
61
|
+
placeholder?: string;
|
|
62
|
+
disabled?: boolean;
|
|
63
|
+
error?: any;
|
|
64
|
+
help?: any;
|
|
65
|
+
size?: string;
|
|
66
|
+
onchange?: any;
|
|
67
|
+
onsearch?: (query: string) => void;
|
|
68
|
+
loading?: boolean;
|
|
69
|
+
class?: string;
|
|
60
70
|
} & Record<string, any>;
|
|
@@ -0,0 +1,261 @@
|
|
|
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 Select from './Select.svelte';
|
|
40
|
+
import Combobox from './Combobox.svelte';
|
|
41
|
+
|
|
42
|
+
let {
|
|
43
|
+
/** @type {FilterFieldDef[]} */
|
|
44
|
+
filters = [],
|
|
45
|
+
/** @type {Record<string, any>} */
|
|
46
|
+
value = $bindable({}),
|
|
47
|
+
/** @type {((value: Record<string, any>) => void) | undefined} */
|
|
48
|
+
onchange = undefined,
|
|
49
|
+
/** @type {(() => void) | undefined} */
|
|
50
|
+
onclear = undefined,
|
|
51
|
+
/** @type {string} */
|
|
52
|
+
class: className = '',
|
|
53
|
+
...rest
|
|
54
|
+
} = $props();
|
|
55
|
+
|
|
56
|
+
const activeCount = $derived(
|
|
57
|
+
Object.values(value).filter(v => v !== undefined && v !== null && v !== '').length
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} key
|
|
62
|
+
* @param {string | undefined} val
|
|
63
|
+
*/
|
|
64
|
+
function setFilter(key, val) {
|
|
65
|
+
value = { ...value, [key]: val === '' || val === undefined ? undefined : val };
|
|
66
|
+
onchange?.(value);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @param {string} key
|
|
71
|
+
* @param {string} optionValue
|
|
72
|
+
*/
|
|
73
|
+
function toggleBoolean(key, optionValue) {
|
|
74
|
+
value = {
|
|
75
|
+
...value,
|
|
76
|
+
[key]: value[key] === optionValue ? undefined : optionValue,
|
|
77
|
+
};
|
|
78
|
+
onchange?.(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function clearAll() {
|
|
82
|
+
value = {};
|
|
83
|
+
onchange?.({});
|
|
84
|
+
onclear?.();
|
|
85
|
+
}
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
{#if filters.length > 0}
|
|
89
|
+
<div
|
|
90
|
+
class="filter-panel {className}"
|
|
91
|
+
role="group"
|
|
92
|
+
aria-label="Filters"
|
|
93
|
+
{...rest}
|
|
94
|
+
>
|
|
95
|
+
{#each filters as filter (filter.key)}
|
|
96
|
+
<div class="filter-field">
|
|
97
|
+
{#if filter.type === 'select' && filter.options}
|
|
98
|
+
<Select
|
|
99
|
+
size="sm"
|
|
100
|
+
label={filter.label}
|
|
101
|
+
value={value[filter.key] ?? ''}
|
|
102
|
+
options={[{ value: '', label: 'All' }, ...filter.options]}
|
|
103
|
+
onchange={(val) => setFilter(filter.key, val)}
|
|
104
|
+
/>
|
|
105
|
+
{:else if filter.type === 'boolean'}
|
|
106
|
+
{@const opts = filter.options ?? [{ value: 'true', label: 'Yes' }, { value: 'false', label: 'No' }]}
|
|
107
|
+
<span class="filter-field-label">{filter.label}</span>
|
|
108
|
+
<div class="filter-bool-chips">
|
|
109
|
+
{#each opts as opt (opt.value)}
|
|
110
|
+
{@const active = value[filter.key] === opt.value}
|
|
111
|
+
<button
|
|
112
|
+
class="filter-chip"
|
|
113
|
+
class:filter-chip-active={active}
|
|
114
|
+
aria-pressed={active}
|
|
115
|
+
type="button"
|
|
116
|
+
onclick={() => toggleBoolean(filter.key, opt.value)}
|
|
117
|
+
>{opt.label}</button>
|
|
118
|
+
{/each}
|
|
119
|
+
</div>
|
|
120
|
+
{:else if filter.type === 'search'}
|
|
121
|
+
<Combobox
|
|
122
|
+
size="sm"
|
|
123
|
+
label={filter.label}
|
|
124
|
+
placeholder="All"
|
|
125
|
+
items={filter.searchItems ?? []}
|
|
126
|
+
value={value[filter.key] ?? ''}
|
|
127
|
+
onchange={(val) => setFilter(filter.key, val)}
|
|
128
|
+
onsearch={filter.onsearch}
|
|
129
|
+
loading={filter.searchLoading ?? false}
|
|
130
|
+
/>
|
|
131
|
+
{/if}
|
|
132
|
+
</div>
|
|
133
|
+
{/each}
|
|
134
|
+
|
|
135
|
+
{#if activeCount >= 2}
|
|
136
|
+
<button
|
|
137
|
+
class="filter-panel-clear"
|
|
138
|
+
onclick={clearAll}
|
|
139
|
+
type="button"
|
|
140
|
+
>
|
|
141
|
+
Clear all
|
|
142
|
+
</button>
|
|
143
|
+
{/if}
|
|
144
|
+
</div>
|
|
145
|
+
{/if}
|
|
146
|
+
|
|
147
|
+
<style>
|
|
148
|
+
.filter-panel {
|
|
149
|
+
display: flex;
|
|
150
|
+
align-items: flex-end;
|
|
151
|
+
gap: var(--space-md);
|
|
152
|
+
flex-wrap: wrap;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.filter-field {
|
|
156
|
+
display: flex;
|
|
157
|
+
flex-direction: column;
|
|
158
|
+
gap: var(--space-2xs);
|
|
159
|
+
min-width: 140px;
|
|
160
|
+
max-width: 220px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.filter-field-label {
|
|
164
|
+
font-family: var(--input-label-font);
|
|
165
|
+
font-size: var(--input-label-size);
|
|
166
|
+
letter-spacing: var(--input-label-tracking);
|
|
167
|
+
color: var(--input-label-color);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.filter-bool-chips {
|
|
171
|
+
display: flex;
|
|
172
|
+
gap: var(--filter-chip-gap);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* Reuse existing filter-chip tokens */
|
|
176
|
+
.filter-chip {
|
|
177
|
+
all: unset;
|
|
178
|
+
box-sizing: border-box;
|
|
179
|
+
display: inline-flex;
|
|
180
|
+
align-items: center;
|
|
181
|
+
height: var(--filter-chip-height);
|
|
182
|
+
padding: var(--filter-chip-padding);
|
|
183
|
+
font-family: var(--filter-chip-font);
|
|
184
|
+
font-size: var(--filter-chip-size);
|
|
185
|
+
letter-spacing: var(--filter-chip-tracking);
|
|
186
|
+
color: var(--filter-chip-color);
|
|
187
|
+
background: var(--filter-chip-bg);
|
|
188
|
+
border: var(--filter-chip-border);
|
|
189
|
+
border-radius: var(--filter-chip-radius);
|
|
190
|
+
cursor: pointer;
|
|
191
|
+
white-space: nowrap;
|
|
192
|
+
transition: all var(--filter-chip-transition);
|
|
193
|
+
user-select: none;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.filter-chip:hover {
|
|
197
|
+
background: var(--filter-chip-bg-hover);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.filter-chip:focus-visible {
|
|
201
|
+
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
202
|
+
outline-offset: var(--focus-ring-offset);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.filter-chip-active {
|
|
206
|
+
background: var(--filter-chip-bg-active);
|
|
207
|
+
color: var(--filter-chip-color-active);
|
|
208
|
+
border: var(--filter-chip-border-active);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.filter-chip-active:hover {
|
|
212
|
+
background: var(--filter-chip-bg-active);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.filter-panel-clear {
|
|
216
|
+
all: unset;
|
|
217
|
+
box-sizing: border-box;
|
|
218
|
+
font-family: var(--filter-chip-font);
|
|
219
|
+
font-size: var(--filter-chip-size);
|
|
220
|
+
letter-spacing: var(--filter-chip-tracking);
|
|
221
|
+
color: var(--filter-bar-clear-color);
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
white-space: nowrap;
|
|
224
|
+
padding-bottom: var(--space-xs);
|
|
225
|
+
transition: color var(--filter-chip-transition);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.filter-panel-clear:hover {
|
|
229
|
+
color: var(--filter-bar-clear-color-hover);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.filter-panel-clear:focus-visible {
|
|
233
|
+
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
234
|
+
outline-offset: var(--focus-ring-offset);
|
|
235
|
+
border-radius: var(--radius-sm);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@media (max-width: 640px) {
|
|
239
|
+
.filter-panel {
|
|
240
|
+
flex-wrap: nowrap;
|
|
241
|
+
overflow-x: auto;
|
|
242
|
+
-webkit-overflow-scrolling: touch;
|
|
243
|
+
scrollbar-width: none;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.filter-panel::-webkit-scrollbar {
|
|
247
|
+
display: none;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.filter-field {
|
|
251
|
+
flex-shrink: 0;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
@media (prefers-reduced-motion: reduce) {
|
|
256
|
+
.filter-chip,
|
|
257
|
+
.filter-panel-clear {
|
|
258
|
+
transition: none;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
</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>;
|
package/components/index.js
CHANGED
|
@@ -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
|