@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>;
|
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
|