@viniaraujo68/plinth 0.3.0 → 0.5.0
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.
- package/README.md +189 -19
- package/dist/components/ErrorDisplay.svelte +30 -16
- package/dist/components/ErrorDisplay.svelte.d.ts +4 -0
- package/dist/components/JsonView.svelte +43 -0
- package/dist/components/JsonView.svelte.d.ts +4 -0
- package/dist/components/JsonViewFrame.svelte +654 -0
- package/dist/components/JsonViewFrame.svelte.d.ts +31 -0
- package/dist/components/Paginator.svelte +34 -8
- package/dist/components/Paginator.svelte.d.ts +10 -0
- package/dist/components/RequestErrorDisplay.svelte +43 -0
- package/dist/components/RequestErrorDisplay.svelte.d.ts +11 -0
- package/dist/components/Tabs.svelte +204 -0
- package/dist/components/Tabs.svelte.d.ts +76 -0
- package/dist/components/VirtualJsonView.svelte +67 -0
- package/dist/components/VirtualJsonView.svelte.d.ts +4 -0
- package/dist/components/index.d.ts +8 -1
- package/dist/components/index.js +11 -5
- package/dist/components/json-view.d.ts +70 -0
- package/dist/components/json-view.js +302 -0
- package/dist/components/request-error.d.ts +8 -0
- package/dist/components/request-error.js +55 -0
- package/dist/components/tabs.d.ts +5 -0
- package/dist/components/tabs.js +1 -0
- package/dist/http.d.ts +10 -5
- package/dist/http.js +34 -6
- package/dist/i18n/LocaleToggle.svelte +45 -0
- package/dist/i18n/LocaleToggle.svelte.d.ts +48 -0
- package/dist/i18n/i18n.svelte.d.ts +56 -0
- package/dist/i18n/i18n.svelte.js +94 -0
- package/dist/i18n/index.d.ts +3 -0
- package/dist/i18n/index.js +7 -0
- package/dist/i18n/translate.d.ts +30 -0
- package/dist/i18n/translate.js +38 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/redirect.d.ts +40 -0
- package/dist/redirect.js +59 -0
- package/dist/routing.svelte.d.ts +1 -0
- package/dist/routing.svelte.js +3 -0
- package/dist/shell/AppShell.svelte +269 -95
- package/dist/shell/AppShell.svelte.d.ts +3 -2
- package/dist/shell/NavigationProgress.svelte +85 -0
- package/dist/shell/NavigationProgress.svelte.d.ts +31 -0
- package/dist/shell/TopBar.svelte +191 -0
- package/dist/shell/TopBar.svelte.d.ts +41 -0
- package/dist/shell/UserAvatar.svelte +50 -0
- package/dist/shell/UserAvatar.svelte.d.ts +14 -0
- package/dist/shell/index.d.ts +2 -0
- package/dist/shell/index.js +7 -3
- package/dist/shell/initials.d.ts +6 -0
- package/dist/shell/initials.js +13 -0
- package/dist/table/DataTable.svelte +199 -9
- package/dist/table/DataTable.svelte.d.ts +28 -5
- package/dist/table/FilterBar.svelte +417 -0
- package/dist/table/FilterBar.svelte.d.ts +34 -0
- package/dist/table/FilterRuleEditor.svelte +290 -0
- package/dist/table/FilterRuleEditor.svelte.d.ts +33 -0
- package/dist/table/filter-labels.d.ts +23 -0
- package/dist/table/filter-labels.js +30 -0
- package/dist/table/filter.d.ts +27 -0
- package/dist/table/filter.js +279 -0
- package/dist/table/index.d.ts +4 -1
- package/dist/table/index.js +5 -2
- package/dist/table/types.d.ts +49 -0
- package/dist/toast/Toaster.svelte +7 -3
- package/dist/toast/index.d.ts +1 -1
- package/dist/toast/toast-manager.svelte.d.ts +29 -1
- package/dist/toast/toast-manager.svelte.js +61 -1
- package/dist/url-state/codec.d.ts +15 -0
- package/dist/url-state/codec.js +1 -0
- package/dist/url-state/index.d.ts +5 -0
- package/dist/url-state/index.js +4 -0
- package/dist/url-state/params-codec.d.ts +73 -0
- package/dist/url-state/params-codec.js +116 -0
- package/dist/url-state/search-params.d.ts +4 -0
- package/dist/url-state/search-params.js +28 -0
- package/dist/url-state/synced-state.svelte.d.ts +20 -0
- package/dist/url-state/synced-state.svelte.js +88 -0
- package/dist/url-state/url-store.svelte.d.ts +12 -0
- package/dist/url-state/url-store.svelte.js +56 -0
- package/dist/virtual/DataList.svelte +215 -0
- package/dist/virtual/DataList.svelte.d.ts +52 -0
- package/dist/virtual/VirtualList.svelte +150 -0
- package/dist/virtual/VirtualList.svelte.d.ts +42 -0
- package/dist/virtual/index.d.ts +7 -0
- package/dist/virtual/index.js +6 -0
- package/dist/virtual/keyed-cache.d.ts +7 -0
- package/dist/virtual/keyed-cache.js +20 -0
- package/dist/virtual/labels.d.ts +12 -0
- package/dist/virtual/labels.js +6 -0
- package/dist/virtual/paging.d.ts +5 -0
- package/dist/virtual/paging.js +11 -0
- package/dist/virtual/range.d.ts +9 -0
- package/dist/virtual/range.js +31 -0
- package/dist/virtual/size-tree.d.ts +10 -0
- package/dist/virtual/size-tree.js +56 -0
- package/dist/virtual/virtualizer.svelte.d.ts +38 -0
- package/dist/virtual/virtualizer.svelte.js +295 -0
- package/dist/virtual/windowed-data.svelte.d.ts +38 -0
- package/dist/virtual/windowed-data.svelte.js +139 -0
- package/dist/virtual/windowed-virtualizer.svelte.d.ts +9 -0
- package/dist/virtual/windowed-virtualizer.svelte.js +22 -0
- package/package.json +13 -1
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
<script lang="ts" generics="T">
|
|
2
|
+
import { untrack } from "svelte";
|
|
3
|
+
import Listbox from "../components/Listbox.svelte";
|
|
4
|
+
import SegmentedControl from "../components/SegmentedControl.svelte";
|
|
5
|
+
import { normalizeForSearch } from "../components/select.js";
|
|
6
|
+
import type { FilterLabels } from "./filter-labels.js";
|
|
7
|
+
import { isRuleActive, operatorsOf, type FilterableColumn } from "./filter.js";
|
|
8
|
+
import type {
|
|
9
|
+
Column,
|
|
10
|
+
FilterOperator,
|
|
11
|
+
FilterRule,
|
|
12
|
+
NumberOperator,
|
|
13
|
+
TextOperator,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
columns: readonly Column<T>[];
|
|
18
|
+
fields: readonly FilterableColumn<T>[];
|
|
19
|
+
rule?: FilterRule;
|
|
20
|
+
labels: FilterLabels;
|
|
21
|
+
onapply: (rule: FilterRule) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let { columns, fields, rule, labels, onapply }: Props = $props();
|
|
25
|
+
|
|
26
|
+
const FIELD_SEARCH_THRESHOLD = 8;
|
|
27
|
+
const CHOICE_SEARCH_THRESHOLD = 8;
|
|
28
|
+
|
|
29
|
+
const initial = untrack(() => rule);
|
|
30
|
+
|
|
31
|
+
let fieldKey = $state<string | null>(initial?.key ?? null);
|
|
32
|
+
let fieldQuery = $state("");
|
|
33
|
+
let operator = $state<FilterOperator>(
|
|
34
|
+
initial?.operator ?? untrack(() => (fields[0] ? operatorsOf(fields[0].filter)[0] : "contains")),
|
|
35
|
+
);
|
|
36
|
+
let text = $state(typeof initial?.value === "string" ? initial.value : "");
|
|
37
|
+
let amount = $state<number | null | undefined>(
|
|
38
|
+
typeof initial?.value === "number" ? initial.value : null,
|
|
39
|
+
);
|
|
40
|
+
let choices = $state<string[]>(Array.isArray(initial?.value) ? [...initial.value] : []);
|
|
41
|
+
|
|
42
|
+
const field = $derived(fields.find((candidate) => candidate.key === fieldKey));
|
|
43
|
+
const operators = $derived(field ? operatorsOf(field.filter) : []);
|
|
44
|
+
|
|
45
|
+
const shownFields = $derived.by(() => {
|
|
46
|
+
const query = normalizeForSearch(fieldQuery.trim());
|
|
47
|
+
if (query === "") return fields;
|
|
48
|
+
return fields.filter((candidate) => normalizeForSearch(candidate.label).includes(query));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const draft = $derived.by((): FilterRule | undefined => {
|
|
52
|
+
if (!field) return undefined;
|
|
53
|
+
const { key, filter } = field;
|
|
54
|
+
if (filter.kind === "choice") return { key, operator: "in", value: choices };
|
|
55
|
+
if (filter.kind === "number")
|
|
56
|
+
return typeof amount === "number"
|
|
57
|
+
? { key, operator: operator as NumberOperator, value: amount }
|
|
58
|
+
: undefined;
|
|
59
|
+
return { key, operator: operator as TextOperator, value: text };
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const valid = $derived(draft !== undefined && isRuleActive(columns, draft));
|
|
63
|
+
|
|
64
|
+
const pick = (candidate: FilterableColumn<T>) => {
|
|
65
|
+
const keepOperator = operatorsOf(candidate.filter).includes(operator);
|
|
66
|
+
fieldKey = candidate.key;
|
|
67
|
+
if (!keepOperator) operator = operatorsOf(candidate.filter)[0];
|
|
68
|
+
choices = [];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const toggleChoice = (value: string) => {
|
|
72
|
+
choices = choices.includes(value)
|
|
73
|
+
? choices.filter((picked) => picked !== value)
|
|
74
|
+
: [...choices, value];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const submit = (event: SubmitEvent) => {
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
if (draft && valid) onapply(draft);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const focusOnMount = (node: HTMLElement) => {
|
|
83
|
+
node.focus();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const kindGlyph = (column: FilterableColumn<T>) => {
|
|
87
|
+
if (column.filter.kind === "number") return "#";
|
|
88
|
+
if (column.filter.kind === "choice") return "≡";
|
|
89
|
+
return "Aa";
|
|
90
|
+
};
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
{#if !field}
|
|
94
|
+
<div class="plinth-filter-editor">
|
|
95
|
+
{#if fields.length > FIELD_SEARCH_THRESHOLD}
|
|
96
|
+
<input
|
|
97
|
+
class="input w-full input-sm"
|
|
98
|
+
type="search"
|
|
99
|
+
placeholder={labels.searchFields}
|
|
100
|
+
aria-label={labels.searchFields}
|
|
101
|
+
bind:value={fieldQuery}
|
|
102
|
+
{@attach focusOnMount}
|
|
103
|
+
/>
|
|
104
|
+
{/if}
|
|
105
|
+
<ul class="plinth-filter-fields" aria-label={labels.fields}>
|
|
106
|
+
{#each shownFields as candidate (candidate.key)}
|
|
107
|
+
<li>
|
|
108
|
+
<button type="button" class="plinth-filter-field" onclick={() => pick(candidate)}>
|
|
109
|
+
<span class="plinth-filter-glyph" aria-hidden="true">{kindGlyph(candidate)}</span>
|
|
110
|
+
{candidate.label}
|
|
111
|
+
</button>
|
|
112
|
+
</li>
|
|
113
|
+
{:else}
|
|
114
|
+
<li class="plinth-filter-none">{labels.noFields}</li>
|
|
115
|
+
{/each}
|
|
116
|
+
</ul>
|
|
117
|
+
</div>
|
|
118
|
+
{:else}
|
|
119
|
+
<form class="plinth-filter-editor" onsubmit={submit}>
|
|
120
|
+
<div class="plinth-filter-head">
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
class="plinth-filter-back"
|
|
124
|
+
aria-label={labels.back}
|
|
125
|
+
onclick={() => (fieldKey = null)}
|
|
126
|
+
>
|
|
127
|
+
<svg
|
|
128
|
+
viewBox="0 0 24 24"
|
|
129
|
+
fill="none"
|
|
130
|
+
stroke="currentColor"
|
|
131
|
+
stroke-width="2"
|
|
132
|
+
stroke-linecap="round"
|
|
133
|
+
stroke-linejoin="round"
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
>
|
|
136
|
+
<path d="m15 18-6-6 6-6" />
|
|
137
|
+
</svg>
|
|
138
|
+
</button>
|
|
139
|
+
<span class="plinth-filter-title">{field.label}</span>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
{#if operators.length > 1}
|
|
143
|
+
<SegmentedControl
|
|
144
|
+
class="plinth-filter-operators"
|
|
145
|
+
label={labels.operatorGroup(field as Column<never>)}
|
|
146
|
+
options={operators.map((id) => ({
|
|
147
|
+
id,
|
|
148
|
+
label: labels.operator(id, field as Column<never>),
|
|
149
|
+
}))}
|
|
150
|
+
value={operator}
|
|
151
|
+
onchange={(id) => (operator = id)}
|
|
152
|
+
/>
|
|
153
|
+
{/if}
|
|
154
|
+
|
|
155
|
+
{#if field.filter.kind === "number"}
|
|
156
|
+
<input
|
|
157
|
+
class="input w-full input-sm"
|
|
158
|
+
type="number"
|
|
159
|
+
aria-label={labels.value}
|
|
160
|
+
placeholder={labels.value}
|
|
161
|
+
min={field.filter.min}
|
|
162
|
+
max={field.filter.max}
|
|
163
|
+
step={field.filter.step ?? "any"}
|
|
164
|
+
bind:value={amount}
|
|
165
|
+
{@attach focusOnMount}
|
|
166
|
+
/>
|
|
167
|
+
{:else if field.filter.kind === "text"}
|
|
168
|
+
<input
|
|
169
|
+
class="input w-full input-sm"
|
|
170
|
+
type="text"
|
|
171
|
+
aria-label={labels.value}
|
|
172
|
+
placeholder={labels.value}
|
|
173
|
+
bind:value={text}
|
|
174
|
+
{@attach focusOnMount}
|
|
175
|
+
/>
|
|
176
|
+
{:else}
|
|
177
|
+
<Listbox
|
|
178
|
+
class="plinth-filter-choices"
|
|
179
|
+
aria-label={field.label}
|
|
180
|
+
options={field.filter.options}
|
|
181
|
+
value={choices}
|
|
182
|
+
onpick={(option) => toggleChoice(option.value)}
|
|
183
|
+
searchable={field.filter.options.length > CHOICE_SEARCH_THRESHOLD}
|
|
184
|
+
searchPlaceholder={labels.search}
|
|
185
|
+
emptyLabel={labels.noOptions}
|
|
186
|
+
autofocus
|
|
187
|
+
/>
|
|
188
|
+
{/if}
|
|
189
|
+
|
|
190
|
+
<button type="submit" class="btn btn-primary btn-sm" disabled={!valid}>
|
|
191
|
+
{labels.apply}
|
|
192
|
+
</button>
|
|
193
|
+
</form>
|
|
194
|
+
{/if}
|
|
195
|
+
|
|
196
|
+
<style>
|
|
197
|
+
.plinth-filter-editor {
|
|
198
|
+
display: flex;
|
|
199
|
+
flex-direction: column;
|
|
200
|
+
gap: 0.5rem;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.plinth-filter-fields {
|
|
204
|
+
display: flex;
|
|
205
|
+
flex-direction: column;
|
|
206
|
+
max-height: 16rem;
|
|
207
|
+
overflow-y: auto;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.plinth-filter-field {
|
|
211
|
+
display: flex;
|
|
212
|
+
align-items: center;
|
|
213
|
+
gap: 0.625rem;
|
|
214
|
+
width: 100%;
|
|
215
|
+
padding: 0.375rem 0.5rem;
|
|
216
|
+
border-radius: var(--radius-field, 0.5rem);
|
|
217
|
+
font-size: 0.875rem;
|
|
218
|
+
text-align: left;
|
|
219
|
+
cursor: pointer;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.plinth-filter-field:hover,
|
|
223
|
+
.plinth-filter-field:focus-visible {
|
|
224
|
+
background: color-mix(in oklch, var(--color-base-content) 6%, transparent);
|
|
225
|
+
outline: none;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.plinth-filter-glyph {
|
|
229
|
+
display: inline-grid;
|
|
230
|
+
place-items: center;
|
|
231
|
+
width: 1.5rem;
|
|
232
|
+
height: 1.25rem;
|
|
233
|
+
border-radius: 0.25rem;
|
|
234
|
+
background: color-mix(in oklch, var(--color-base-content) 7%, transparent);
|
|
235
|
+
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
|
|
236
|
+
font-size: 0.6875rem;
|
|
237
|
+
font-weight: 600;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.plinth-filter-none {
|
|
241
|
+
padding: 0.5rem;
|
|
242
|
+
font-size: 0.875rem;
|
|
243
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
.plinth-filter-head {
|
|
247
|
+
display: flex;
|
|
248
|
+
align-items: center;
|
|
249
|
+
gap: 0.25rem;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.plinth-filter-back {
|
|
253
|
+
display: inline-grid;
|
|
254
|
+
place-items: center;
|
|
255
|
+
width: 1.5rem;
|
|
256
|
+
height: 1.5rem;
|
|
257
|
+
border-radius: var(--radius-field, 0.5rem);
|
|
258
|
+
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
|
|
259
|
+
cursor: pointer;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.plinth-filter-back:hover {
|
|
263
|
+
background: color-mix(in oklch, var(--color-base-content) 6%, transparent);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.plinth-filter-back svg {
|
|
267
|
+
width: 1rem;
|
|
268
|
+
height: 1rem;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.plinth-filter-title {
|
|
272
|
+
font-size: 0.875rem;
|
|
273
|
+
font-weight: 600;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
:global(.plinth-filter-operators) {
|
|
277
|
+
width: 100%;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
:global(.plinth-filter-operators .plinth-segmented-options) {
|
|
281
|
+
display: grid;
|
|
282
|
+
flex: 1;
|
|
283
|
+
grid-auto-columns: minmax(0, 1fr);
|
|
284
|
+
grid-auto-flow: column;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
:global(.plinth-filter-operators .plinth-segment) {
|
|
288
|
+
font-size: 0.875rem;
|
|
289
|
+
}
|
|
290
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FilterLabels } from "./filter-labels.js";
|
|
2
|
+
import { type FilterableColumn } from "./filter.js";
|
|
3
|
+
import type { Column, FilterRule } from "./types.js";
|
|
4
|
+
declare function $$render<T>(): {
|
|
5
|
+
props: {
|
|
6
|
+
columns: readonly Column<T>[];
|
|
7
|
+
fields: readonly FilterableColumn<T>[];
|
|
8
|
+
rule?: FilterRule;
|
|
9
|
+
labels: FilterLabels;
|
|
10
|
+
onapply: (rule: FilterRule) => void;
|
|
11
|
+
};
|
|
12
|
+
exports: {};
|
|
13
|
+
bindings: "";
|
|
14
|
+
slots: {};
|
|
15
|
+
events: {};
|
|
16
|
+
};
|
|
17
|
+
declare class __sveltets_Render<T> {
|
|
18
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
19
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
20
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
21
|
+
bindings(): "";
|
|
22
|
+
exports(): {};
|
|
23
|
+
}
|
|
24
|
+
interface $$IsomorphicComponent {
|
|
25
|
+
new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
26
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
27
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
28
|
+
<T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
29
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
30
|
+
}
|
|
31
|
+
declare const FilterRuleEditor: $$IsomorphicComponent;
|
|
32
|
+
type FilterRuleEditor<T> = InstanceType<typeof FilterRuleEditor<T>>;
|
|
33
|
+
export default FilterRuleEditor;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Column, FilterCombinator, FilterOperator } from "./types.js";
|
|
2
|
+
export interface FilterLabels {
|
|
3
|
+
add: string;
|
|
4
|
+
addGroup: string;
|
|
5
|
+
clear: string;
|
|
6
|
+
apply: string;
|
|
7
|
+
back: string;
|
|
8
|
+
fields: string;
|
|
9
|
+
searchFields: string;
|
|
10
|
+
search: string;
|
|
11
|
+
noFields: string;
|
|
12
|
+
noOptions: string;
|
|
13
|
+
value: string;
|
|
14
|
+
group: string;
|
|
15
|
+
combinator: (combinator: FilterCombinator) => string;
|
|
16
|
+
toggleCombinator: (next: FilterCombinator) => string;
|
|
17
|
+
operator: (operator: FilterOperator, column: Column<never>) => string;
|
|
18
|
+
operatorGroup: (column: Column<never>) => string;
|
|
19
|
+
remove: (description: string) => string;
|
|
20
|
+
removeGroup: string;
|
|
21
|
+
edit: (description: string) => string;
|
|
22
|
+
}
|
|
23
|
+
export declare const DEFAULT_FILTER_LABELS: FilterLabels;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const SYMBOLS = {
|
|
2
|
+
contains: "contains",
|
|
3
|
+
eq: "=",
|
|
4
|
+
gte: "≥",
|
|
5
|
+
gt: ">",
|
|
6
|
+
lte: "≤",
|
|
7
|
+
lt: "<",
|
|
8
|
+
in: "is",
|
|
9
|
+
};
|
|
10
|
+
export const DEFAULT_FILTER_LABELS = {
|
|
11
|
+
add: "Filter",
|
|
12
|
+
addGroup: "Group",
|
|
13
|
+
clear: "Clear",
|
|
14
|
+
apply: "Apply",
|
|
15
|
+
back: "Back to fields",
|
|
16
|
+
fields: "Fields",
|
|
17
|
+
searchFields: "Find a field…",
|
|
18
|
+
search: "Search…",
|
|
19
|
+
noFields: "No field matches",
|
|
20
|
+
noOptions: "No option matches",
|
|
21
|
+
value: "Value",
|
|
22
|
+
group: "Filter group",
|
|
23
|
+
combinator: (combinator) => combinator,
|
|
24
|
+
toggleCombinator: (next) => `Match ${next === "and" ? "all" : "any"} of these`,
|
|
25
|
+
operator: (operator, column) => operator === "eq" && column.filter?.kind === "text" ? "is" : SYMBOLS[operator],
|
|
26
|
+
operatorGroup: (column) => `${column.label} comparison`,
|
|
27
|
+
remove: (description) => `Remove filter ${description}`,
|
|
28
|
+
removeGroup: "Remove group",
|
|
29
|
+
edit: (description) => `Edit filter ${description}`,
|
|
30
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Column, ColumnFilter, FilterCombinator, FilterGroup, FilterNode, FilterOperator, FilterRule, NumberOperator, TextOperator } from "./types.js";
|
|
2
|
+
export type FilterableColumn<T> = Column<T> & {
|
|
3
|
+
filter: ColumnFilter<T>;
|
|
4
|
+
};
|
|
5
|
+
export type NodePath = readonly number[];
|
|
6
|
+
export declare const TEXT_OPERATORS: readonly TextOperator[];
|
|
7
|
+
export declare const NUMBER_OPERATORS: readonly NumberOperator[];
|
|
8
|
+
export declare const isGroup: (node: FilterNode) => node is FilterGroup;
|
|
9
|
+
export declare const emptyFilter: (combinator?: FilterCombinator) => FilterGroup;
|
|
10
|
+
export declare const operatorsOf: <T>(filter: ColumnFilter<T>) => readonly FilterOperator[];
|
|
11
|
+
export declare const filterableColumns: <T>(columns: readonly Column<T>[]) => FilterableColumn<T>[];
|
|
12
|
+
export declare const filterValue: <T>(column: Column<T>, row: T) => unknown;
|
|
13
|
+
export declare const isRuleActive: <T>(columns: readonly Column<T>[], rule: FilterRule) => boolean;
|
|
14
|
+
export declare const normalizeFilter: <T>(columns: readonly Column<T>[], filter: FilterGroup) => FilterGroup;
|
|
15
|
+
export declare const filterRules: (node: FilterNode) => FilterRule[];
|
|
16
|
+
export declare const isFilterEmpty: <T>(columns: readonly Column<T>[], filter: FilterGroup) => boolean;
|
|
17
|
+
export declare const filterRows: <T>(rows: readonly T[], columns: readonly Column<T>[], filter: FilterGroup) => readonly T[];
|
|
18
|
+
export declare const findRule: (filter: FilterGroup, key: string, operator: FilterOperator) => FilterRule | undefined;
|
|
19
|
+
export declare const setRule: (filter: FilterGroup, rule: FilterRule) => FilterGroup;
|
|
20
|
+
export declare const removeRule: (filter: FilterGroup, key: string, operator?: FilterOperator) => FilterGroup;
|
|
21
|
+
export declare const nodeAt: (filter: FilterGroup, path: NodePath) => FilterNode | undefined;
|
|
22
|
+
export declare const addNode: (filter: FilterGroup, groupPath: NodePath, node: FilterNode) => FilterGroup;
|
|
23
|
+
export declare const replaceNode: (filter: FilterGroup, path: NodePath, node: FilterNode) => FilterGroup;
|
|
24
|
+
export declare const removeNode: (filter: FilterGroup, path: NodePath) => FilterGroup;
|
|
25
|
+
export declare const setCombinator: (filter: FilterGroup, groupPath: NodePath, combinator: FilterCombinator) => FilterGroup;
|
|
26
|
+
export declare const filterToParams: (filter: FilterGroup, name?: string) => Record<string, string | null>;
|
|
27
|
+
export declare const filterFromParams: <T>(params: URLSearchParams, columns: readonly Column<T>[], name?: string) => FilterGroup;
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { normalizeForSearch } from "../components/select.js";
|
|
2
|
+
import { cellValue } from "./sort.js";
|
|
3
|
+
export const TEXT_OPERATORS = ["contains", "eq"];
|
|
4
|
+
export const NUMBER_OPERATORS = ["gte", "lte", "eq", "gt", "lt"];
|
|
5
|
+
const CHOICE_OPERATORS = ["in"];
|
|
6
|
+
const MAX_DEPTH = 4;
|
|
7
|
+
export const isGroup = (node) => "children" in node;
|
|
8
|
+
export const emptyFilter = (combinator = "and") => ({
|
|
9
|
+
combinator,
|
|
10
|
+
children: [],
|
|
11
|
+
});
|
|
12
|
+
export const operatorsOf = (filter) => {
|
|
13
|
+
if (filter.kind === "text")
|
|
14
|
+
return filter.operators ?? TEXT_OPERATORS;
|
|
15
|
+
if (filter.kind === "number")
|
|
16
|
+
return filter.operators ?? NUMBER_OPERATORS;
|
|
17
|
+
return CHOICE_OPERATORS;
|
|
18
|
+
};
|
|
19
|
+
export const filterableColumns = (columns) => columns.filter((column) => column.filter !== undefined);
|
|
20
|
+
export const filterValue = (column, row) => column.filter?.value ? column.filter.value(row) : cellValue(column, row);
|
|
21
|
+
const asText = (value) => {
|
|
22
|
+
if (typeof value === "string")
|
|
23
|
+
return value === "" ? undefined : value;
|
|
24
|
+
if (typeof value === "number")
|
|
25
|
+
return Number.isNaN(value) ? undefined : String(value);
|
|
26
|
+
if (typeof value === "boolean" || typeof value === "bigint")
|
|
27
|
+
return String(value);
|
|
28
|
+
return undefined;
|
|
29
|
+
};
|
|
30
|
+
const asNumber = (value) => {
|
|
31
|
+
if (typeof value === "number")
|
|
32
|
+
return Number.isFinite(value) ? value : undefined;
|
|
33
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
34
|
+
const parsed = Number(value);
|
|
35
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
};
|
|
39
|
+
const lookup = (columns) => new Map(columns.map((column) => [column.key, column]));
|
|
40
|
+
const normalizeRule = (column, rule) => {
|
|
41
|
+
const filter = column?.filter;
|
|
42
|
+
if (!filter || !operatorsOf(filter).includes(rule.operator))
|
|
43
|
+
return undefined;
|
|
44
|
+
if (filter.kind === "text")
|
|
45
|
+
return typeof rule.value === "string" && rule.value.trim() !== "" ? rule : undefined;
|
|
46
|
+
if (filter.kind === "number")
|
|
47
|
+
return typeof rule.value === "number" && Number.isFinite(rule.value) ? rule : undefined;
|
|
48
|
+
if (!Array.isArray(rule.value))
|
|
49
|
+
return undefined;
|
|
50
|
+
const known = new Set(filter.options.map((option) => option.value));
|
|
51
|
+
const value = [...new Set(rule.value)].filter((picked) => known.has(picked));
|
|
52
|
+
return value.length === 0 ? undefined : { key: rule.key, operator: "in", value };
|
|
53
|
+
};
|
|
54
|
+
export const isRuleActive = (columns, rule) => normalizeRule(lookup(columns).get(rule.key), rule) !== undefined;
|
|
55
|
+
const normalizeNode = (byKey, node, depth) => {
|
|
56
|
+
if (!isGroup(node))
|
|
57
|
+
return normalizeRule(byKey.get(node.key), node);
|
|
58
|
+
if (depth > MAX_DEPTH)
|
|
59
|
+
return undefined;
|
|
60
|
+
const children = node.children.flatMap((child) => {
|
|
61
|
+
const normalized = normalizeNode(byKey, child, depth + 1);
|
|
62
|
+
return normalized ? [normalized] : [];
|
|
63
|
+
});
|
|
64
|
+
if (children.length === 0)
|
|
65
|
+
return undefined;
|
|
66
|
+
if (children.length === 1)
|
|
67
|
+
return children[0];
|
|
68
|
+
return { combinator: node.combinator === "or" ? "or" : "and", children };
|
|
69
|
+
};
|
|
70
|
+
export const normalizeFilter = (columns, filter) => {
|
|
71
|
+
const normalized = normalizeNode(lookup(columns), filter, 0);
|
|
72
|
+
if (!normalized)
|
|
73
|
+
return emptyFilter(filter.combinator);
|
|
74
|
+
return isGroup(normalized)
|
|
75
|
+
? normalized
|
|
76
|
+
: { combinator: filter.combinator, children: [normalized] };
|
|
77
|
+
};
|
|
78
|
+
export const filterRules = (node) => isGroup(node) ? node.children.flatMap(filterRules) : [node];
|
|
79
|
+
export const isFilterEmpty = (columns, filter) => normalizeFilter(columns, filter).children.length === 0;
|
|
80
|
+
const compareNumber = (operator, left, right) => {
|
|
81
|
+
if (operator === "gte")
|
|
82
|
+
return left >= right;
|
|
83
|
+
if (operator === "gt")
|
|
84
|
+
return left > right;
|
|
85
|
+
if (operator === "lte")
|
|
86
|
+
return left <= right;
|
|
87
|
+
if (operator === "lt")
|
|
88
|
+
return left < right;
|
|
89
|
+
return left === right;
|
|
90
|
+
};
|
|
91
|
+
const rulePredicate = (column, rule) => {
|
|
92
|
+
const read = (row) => filterValue(column, row);
|
|
93
|
+
if (rule.operator === "in") {
|
|
94
|
+
const picked = new Set(rule.value);
|
|
95
|
+
return (row) => {
|
|
96
|
+
const text = asText(read(row));
|
|
97
|
+
return text !== undefined && picked.has(text);
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
if (column.filter?.kind === "number" && typeof rule.value === "number") {
|
|
101
|
+
const operator = rule.operator;
|
|
102
|
+
const target = rule.value;
|
|
103
|
+
return (row) => {
|
|
104
|
+
const value = asNumber(read(row));
|
|
105
|
+
return value !== undefined && compareNumber(operator, value, target);
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const query = normalizeForSearch(String(rule.value));
|
|
109
|
+
const exact = rule.operator === "eq";
|
|
110
|
+
return (row) => {
|
|
111
|
+
const text = asText(read(row));
|
|
112
|
+
if (text === undefined)
|
|
113
|
+
return false;
|
|
114
|
+
const normalized = normalizeForSearch(text);
|
|
115
|
+
return exact ? normalized === query : normalized.includes(query);
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
const compile = (byKey, node) => {
|
|
119
|
+
if (!isGroup(node))
|
|
120
|
+
return rulePredicate(byKey.get(node.key), node);
|
|
121
|
+
const tests = node.children.map((child) => compile(byKey, child));
|
|
122
|
+
return node.combinator === "or"
|
|
123
|
+
? (row) => tests.some((test) => test(row))
|
|
124
|
+
: (row) => tests.every((test) => test(row));
|
|
125
|
+
};
|
|
126
|
+
export const filterRows = (rows, columns, filter) => {
|
|
127
|
+
const normalized = normalizeFilter(columns, filter);
|
|
128
|
+
if (normalized.children.length === 0)
|
|
129
|
+
return rows;
|
|
130
|
+
const test = compile(lookup(columns), normalized);
|
|
131
|
+
return rows.filter(test);
|
|
132
|
+
};
|
|
133
|
+
const sameSlot = (node, key, operator) => !isGroup(node) && node.key === key && (operator === undefined || node.operator === operator);
|
|
134
|
+
export const findRule = (filter, key, operator) => filter.children.find((node) => sameSlot(node, key, operator));
|
|
135
|
+
export const setRule = (filter, rule) => {
|
|
136
|
+
const index = filter.children.findIndex((node) => sameSlot(node, rule.key, rule.operator));
|
|
137
|
+
const children = index === -1
|
|
138
|
+
? [...filter.children, rule]
|
|
139
|
+
: filter.children.map((node, at) => (at === index ? rule : node));
|
|
140
|
+
return { ...filter, children };
|
|
141
|
+
};
|
|
142
|
+
export const removeRule = (filter, key, operator) => ({
|
|
143
|
+
...filter,
|
|
144
|
+
children: filter.children.filter((node) => !sameSlot(node, key, operator)),
|
|
145
|
+
});
|
|
146
|
+
export const nodeAt = (filter, path) => {
|
|
147
|
+
let node = filter;
|
|
148
|
+
for (const index of path) {
|
|
149
|
+
if (!isGroup(node) || index < 0 || index >= node.children.length)
|
|
150
|
+
return undefined;
|
|
151
|
+
node = node.children[index];
|
|
152
|
+
}
|
|
153
|
+
return node;
|
|
154
|
+
};
|
|
155
|
+
const updateGroup = (group, path, update) => {
|
|
156
|
+
if (path.length === 0)
|
|
157
|
+
return update(group);
|
|
158
|
+
const [head, ...rest] = path;
|
|
159
|
+
return {
|
|
160
|
+
...group,
|
|
161
|
+
children: group.children.map((child, index) => index === head && isGroup(child) ? updateGroup(child, rest, update) : child),
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
export const addNode = (filter, groupPath, node) => updateGroup(filter, groupPath, (group) => ({ ...group, children: [...group.children, node] }));
|
|
165
|
+
export const replaceNode = (filter, path, node) => {
|
|
166
|
+
if (path.length === 0)
|
|
167
|
+
return isGroup(node) ? node : filter;
|
|
168
|
+
const parent = path.slice(0, -1);
|
|
169
|
+
const index = path[path.length - 1];
|
|
170
|
+
return updateGroup(filter, parent, (group) => ({
|
|
171
|
+
...group,
|
|
172
|
+
children: group.children.map((child, at) => (at === index ? node : child)),
|
|
173
|
+
}));
|
|
174
|
+
};
|
|
175
|
+
export const removeNode = (filter, path) => {
|
|
176
|
+
if (path.length === 0)
|
|
177
|
+
return emptyFilter(filter.combinator);
|
|
178
|
+
const parent = path.slice(0, -1);
|
|
179
|
+
const index = path[path.length - 1];
|
|
180
|
+
return updateGroup(filter, parent, (group) => ({
|
|
181
|
+
...group,
|
|
182
|
+
children: group.children.filter((_, at) => at !== index),
|
|
183
|
+
}));
|
|
184
|
+
};
|
|
185
|
+
export const setCombinator = (filter, groupPath, combinator) => updateGroup(filter, groupPath, (group) => ({ ...group, combinator }));
|
|
186
|
+
const FILTER_PARAM = "filter";
|
|
187
|
+
const escapePart = (text) => encodeURIComponent(text).replace(/[()]/g, (char) => (char === "(" ? "%28" : "%29"));
|
|
188
|
+
const encodeRule = (rule) => {
|
|
189
|
+
const value = Array.isArray(rule.value)
|
|
190
|
+
? rule.value.map(escapePart).join(",")
|
|
191
|
+
: escapePart(String(rule.value));
|
|
192
|
+
return `${escapePart(rule.key)}:${rule.operator}:${value}`;
|
|
193
|
+
};
|
|
194
|
+
const encodeNode = (node) => {
|
|
195
|
+
if (!isGroup(node))
|
|
196
|
+
return encodeRule(node);
|
|
197
|
+
const children = node.children.flatMap((child) => encodeNode(child) ?? []);
|
|
198
|
+
if (children.length === 0)
|
|
199
|
+
return undefined;
|
|
200
|
+
return `${node.combinator}(${children.join(";")})`;
|
|
201
|
+
};
|
|
202
|
+
export const filterToParams = (filter, name = FILTER_PARAM) => {
|
|
203
|
+
const children = filter.children.flatMap((child) => encodeNode(child) ?? []);
|
|
204
|
+
if (children.length === 0)
|
|
205
|
+
return { [name]: null };
|
|
206
|
+
const body = children.join(";");
|
|
207
|
+
return { [name]: filter.combinator === "or" ? `or(${body})` : body };
|
|
208
|
+
};
|
|
209
|
+
const decodeRule = (part, byKey) => {
|
|
210
|
+
const pieces = part.split(":");
|
|
211
|
+
if (pieces.length !== 3)
|
|
212
|
+
return undefined;
|
|
213
|
+
const [rawKey, operator, rawValue] = pieces;
|
|
214
|
+
try {
|
|
215
|
+
const key = decodeURIComponent(rawKey);
|
|
216
|
+
const kind = byKey.get(key)?.filter?.kind;
|
|
217
|
+
if (kind === "choice")
|
|
218
|
+
return {
|
|
219
|
+
key,
|
|
220
|
+
operator: operator,
|
|
221
|
+
value: rawValue === "" ? [] : rawValue.split(",").map(decodeURIComponent),
|
|
222
|
+
};
|
|
223
|
+
const text = decodeURIComponent(rawValue);
|
|
224
|
+
if (kind === "number")
|
|
225
|
+
return {
|
|
226
|
+
key,
|
|
227
|
+
operator: operator,
|
|
228
|
+
value: text.trim() === "" ? Number.NaN : Number(text),
|
|
229
|
+
};
|
|
230
|
+
return { key, operator: operator, value: text };
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const parseExpression = (source, byKey) => {
|
|
237
|
+
let position = 0;
|
|
238
|
+
const parseList = (depth) => {
|
|
239
|
+
const nodes = [];
|
|
240
|
+
for (;;) {
|
|
241
|
+
const node = parseItem(depth);
|
|
242
|
+
if (node === undefined)
|
|
243
|
+
return undefined;
|
|
244
|
+
if (node !== null)
|
|
245
|
+
nodes.push(node);
|
|
246
|
+
if (source[position] !== ";")
|
|
247
|
+
return nodes;
|
|
248
|
+
position += 1;
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const parseItem = (depth) => {
|
|
252
|
+
const group = /^(and|or)\(/.exec(source.slice(position));
|
|
253
|
+
if (group) {
|
|
254
|
+
if (depth >= MAX_DEPTH)
|
|
255
|
+
return undefined;
|
|
256
|
+
position += group[0].length;
|
|
257
|
+
const children = parseList(depth + 1);
|
|
258
|
+
if (children === undefined || source[position] !== ")")
|
|
259
|
+
return undefined;
|
|
260
|
+
position += 1;
|
|
261
|
+
return { combinator: group[1], children };
|
|
262
|
+
}
|
|
263
|
+
const start = position;
|
|
264
|
+
while (position < source.length && source[position] !== ";" && source[position] !== ")")
|
|
265
|
+
position += 1;
|
|
266
|
+
return decodeRule(source.slice(start, position), byKey) ?? null;
|
|
267
|
+
};
|
|
268
|
+
const nodes = parseList(0);
|
|
269
|
+
return nodes !== undefined && position === source.length ? nodes : undefined;
|
|
270
|
+
};
|
|
271
|
+
export const filterFromParams = (params, columns, name = FILTER_PARAM) => {
|
|
272
|
+
const raw = params.get(name);
|
|
273
|
+
if (!raw)
|
|
274
|
+
return emptyFilter();
|
|
275
|
+
const nodes = parseExpression(raw, lookup(columns));
|
|
276
|
+
if (!nodes)
|
|
277
|
+
return emptyFilter();
|
|
278
|
+
return normalizeFilter(columns, { combinator: "and", children: nodes });
|
|
279
|
+
};
|