@aiaiai-pt/design-system 0.4.2 → 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.
|
@@ -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
|