@mendylanda/ui 0.1.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/LICENSE +21 -0
- package/NOTICE.md +5 -0
- package/README.md +49 -0
- package/THIRD_PARTY_LICENSES/shadcn-ui.txt +21 -0
- package/THIRD_PARTY_LICENSES/startercn.txt +21 -0
- package/dist/class-names.d.ts +1 -0
- package/dist/class-names.js +1 -0
- package/dist/customization.d.ts +31 -0
- package/dist/customization.js +42 -0
- package/dist/filters/core.d.ts +3 -0
- package/dist/filters/core.js +3 -0
- package/dist/filters/filter-bar.d.ts +49 -0
- package/dist/filters/filter-bar.js +435 -0
- package/dist/filters/filter-date-editor.d.ts +10 -0
- package/dist/filters/filter-date-editor.js +31 -0
- package/dist/filters/filter-definition.d.ts +220 -0
- package/dist/filters/filter-definition.js +227 -0
- package/dist/filters/filter-menu-panel.d.ts +21 -0
- package/dist/filters/filter-menu-panel.js +164 -0
- package/dist/filters/filter-select-editor.d.ts +25 -0
- package/dist/filters/filter-select-editor.js +46 -0
- package/dist/filters/filter-state.d.ts +34 -0
- package/dist/filters/filter-state.js +107 -0
- package/dist/filters/filter-text-editor.d.ts +11 -0
- package/dist/filters/filter-text-editor.js +34 -0
- package/dist/filters/filter-utils.d.ts +1 -0
- package/dist/filters/filter-utils.js +8 -0
- package/dist/filters/filters.d.ts +38 -0
- package/dist/filters/filters.js +94 -0
- package/dist/filters/index.d.ts +10 -0
- package/dist/filters/index.js +10 -0
- package/dist/filters/nuqs.d.ts +1 -0
- package/dist/filters/nuqs.js +2 -0
- package/dist/filters/use-filter-options.d.ts +24 -0
- package/dist/filters/use-filter-options.js +221 -0
- package/dist/filters/use-filters.d.ts +78 -0
- package/dist/filters/use-filters.js +158 -0
- package/dist/filters/use-url-filters.d.ts +41 -0
- package/dist/filters/use-url-filters.js +197 -0
- package/dist/filters/use-value-draft.d.ts +2 -0
- package/dist/filters/use-value-draft.js +16 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/primitives/button.d.ts +10 -0
- package/dist/primitives/button.js +36 -0
- package/dist/primitives/calendar.d.ts +8 -0
- package/dist/primitives/calendar.js +75 -0
- package/dist/primitives/checkbox.d.ts +4 -0
- package/dist/primitives/checkbox.js +10 -0
- package/dist/primitives/dropdown-menu.d.ts +25 -0
- package/dist/primitives/dropdown-menu.js +54 -0
- package/dist/primitives/index.d.ts +8 -0
- package/dist/primitives/index.js +8 -0
- package/dist/primitives/input.d.ts +3 -0
- package/dist/primitives/input.js +7 -0
- package/dist/primitives/label.d.ts +4 -0
- package/dist/primitives/label.js +9 -0
- package/dist/primitives/textarea.d.ts +3 -0
- package/dist/primitives/textarea.js +7 -0
- package/dist/styles.css +1775 -0
- package/dist/styles.css.d.ts +1 -0
- package/dist/utils.d.ts +3 -0
- package/dist/utils.js +19 -0
- package/package.json +98 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
export const FilterOptionCache = createContext(null);
|
|
4
|
+
function acquire(cache, key, load) {
|
|
5
|
+
let request = cache.get(key);
|
|
6
|
+
if (request?.settled && Date.now() - request.created > 30000) {
|
|
7
|
+
cache.delete(key);
|
|
8
|
+
request = undefined;
|
|
9
|
+
}
|
|
10
|
+
if (!request) {
|
|
11
|
+
const abort = new AbortController();
|
|
12
|
+
request = {
|
|
13
|
+
promise: Promise.resolve().then(() => load(abort.signal)),
|
|
14
|
+
abort,
|
|
15
|
+
users: 0,
|
|
16
|
+
settled: false,
|
|
17
|
+
created: Date.now(),
|
|
18
|
+
};
|
|
19
|
+
const current = request;
|
|
20
|
+
current.promise.then(() => {
|
|
21
|
+
current.settled = true;
|
|
22
|
+
}, () => {
|
|
23
|
+
current.settled = true;
|
|
24
|
+
if (cache.get(key) === current)
|
|
25
|
+
cache.delete(key);
|
|
26
|
+
});
|
|
27
|
+
cache.set(key, request);
|
|
28
|
+
if (cache.size > 100)
|
|
29
|
+
for (const [oldKey, old] of cache) {
|
|
30
|
+
if (old.settled && old.users === 0) {
|
|
31
|
+
cache.delete(oldKey);
|
|
32
|
+
if (cache.size <= 80)
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
request.users++;
|
|
38
|
+
const current = request;
|
|
39
|
+
let released = false;
|
|
40
|
+
return {
|
|
41
|
+
promise: current.promise,
|
|
42
|
+
release() {
|
|
43
|
+
if (released)
|
|
44
|
+
return;
|
|
45
|
+
released = true;
|
|
46
|
+
current.users--;
|
|
47
|
+
if (!current.settled && current.users === 0) {
|
|
48
|
+
current.abort.abort();
|
|
49
|
+
if (cache.get(key) === current)
|
|
50
|
+
cache.delete(key);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function errorMessage(error) {
|
|
56
|
+
return error instanceof Error ? error.message : "Options could not be loaded.";
|
|
57
|
+
}
|
|
58
|
+
export function selectedIds(value) {
|
|
59
|
+
return Array.isArray(value)
|
|
60
|
+
? value.filter((item) => typeof item === "string")
|
|
61
|
+
: typeof value === "string"
|
|
62
|
+
? [value]
|
|
63
|
+
: [];
|
|
64
|
+
}
|
|
65
|
+
export function useFilterOptions(id, field, value, enabled) {
|
|
66
|
+
const sharedCache = useContext(FilterOptionCache);
|
|
67
|
+
const privateCache = useRef(new Map());
|
|
68
|
+
const cache = sharedCache ?? privateCache.current;
|
|
69
|
+
const source = field.source;
|
|
70
|
+
const latest = useRef(source);
|
|
71
|
+
useLayoutEffect(() => {
|
|
72
|
+
latest.current = source;
|
|
73
|
+
}, [source]);
|
|
74
|
+
const [localQuery, setLocalQuery] = useState("");
|
|
75
|
+
const query = source?.query ?? localQuery;
|
|
76
|
+
const [retryKey, retry] = useState(0);
|
|
77
|
+
const scopeKey = JSON.stringify([id, source?.scope, source?.params]);
|
|
78
|
+
const requestKey = JSON.stringify([scopeKey, query, retryKey]);
|
|
79
|
+
const identity = JSON.stringify([id, source?.scope]);
|
|
80
|
+
const ids = selectedIds(value);
|
|
81
|
+
const idsKey = JSON.stringify(ids);
|
|
82
|
+
const [page, setPage] = useState({ key: "", items: [], loading: false });
|
|
83
|
+
const [resolved, setResolved] = useState({ key: "", identity: "", choices: [], done: false });
|
|
84
|
+
const resolveKey = JSON.stringify([scopeKey, idsKey, retryKey]);
|
|
85
|
+
const currentRequest = useRef(requestKey);
|
|
86
|
+
useLayoutEffect(() => {
|
|
87
|
+
currentRequest.current = requestKey;
|
|
88
|
+
}, [requestKey]);
|
|
89
|
+
const moreRelease = useRef(undefined);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (latest.current?.kind !== "remote" || !enabled)
|
|
92
|
+
return;
|
|
93
|
+
const requestSource = latest.current;
|
|
94
|
+
let alive = true;
|
|
95
|
+
let release;
|
|
96
|
+
setPage({ key: requestKey, items: [], loading: true });
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
const request = acquire(cache, `search:${requestKey}`, (signal) => requestSource.search(query, undefined, signal));
|
|
99
|
+
release = request.release;
|
|
100
|
+
request.promise.then((result) => {
|
|
101
|
+
if (alive)
|
|
102
|
+
setPage({
|
|
103
|
+
key: requestKey,
|
|
104
|
+
items: [...result.items],
|
|
105
|
+
cursor: result.cursor,
|
|
106
|
+
loading: false,
|
|
107
|
+
});
|
|
108
|
+
}, (error) => {
|
|
109
|
+
if (alive)
|
|
110
|
+
setPage({ key: requestKey, items: [], loading: false, error: errorMessage(error) });
|
|
111
|
+
});
|
|
112
|
+
}, query ? (latest.current.debounceMs ?? 200) : 0);
|
|
113
|
+
return () => {
|
|
114
|
+
alive = false;
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
release?.();
|
|
117
|
+
moreRelease.current?.();
|
|
118
|
+
};
|
|
119
|
+
}, [requestKey, query, enabled, cache]);
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (latest.current?.kind !== "remote" || ids.length === 0)
|
|
122
|
+
return;
|
|
123
|
+
const requestSource = latest.current;
|
|
124
|
+
let alive = true;
|
|
125
|
+
const currentIds = JSON.parse(idsKey);
|
|
126
|
+
const request = acquire(cache, `resolve:${resolveKey}`, async (signal) => ({
|
|
127
|
+
items: await requestSource.resolve(currentIds, signal),
|
|
128
|
+
}));
|
|
129
|
+
request.promise.then((result) => {
|
|
130
|
+
if (alive)
|
|
131
|
+
setResolved({ key: resolveKey, identity, choices: result.items, done: true });
|
|
132
|
+
}, (error) => {
|
|
133
|
+
if (alive)
|
|
134
|
+
setResolved((previous) => ({
|
|
135
|
+
key: resolveKey,
|
|
136
|
+
identity,
|
|
137
|
+
choices: previous.identity === identity ? previous.choices : [],
|
|
138
|
+
done: true,
|
|
139
|
+
error: errorMessage(error),
|
|
140
|
+
}));
|
|
141
|
+
});
|
|
142
|
+
return () => {
|
|
143
|
+
alive = false;
|
|
144
|
+
request.release();
|
|
145
|
+
};
|
|
146
|
+
}, [resolveKey, idsKey, ids.length, cache, identity]);
|
|
147
|
+
const remote = source?.kind === "remote";
|
|
148
|
+
const currentPage = page.key === requestKey
|
|
149
|
+
? page
|
|
150
|
+
: { items: [], loading: enabled, cursor: null, error: undefined };
|
|
151
|
+
const matchingResolved = resolved.key === resolveKey;
|
|
152
|
+
const items = remote ? currentPage.items : (source?.items ?? []);
|
|
153
|
+
const { selected, shown } = optionPresentation(source, items, ids, resolved, matchingResolved, resolved.identity === identity, remote, query);
|
|
154
|
+
return {
|
|
155
|
+
query,
|
|
156
|
+
setQuery(next) {
|
|
157
|
+
if (source?.onQueryChange)
|
|
158
|
+
source.onQueryChange(next);
|
|
159
|
+
else
|
|
160
|
+
setLocalQuery(next);
|
|
161
|
+
},
|
|
162
|
+
items: shown,
|
|
163
|
+
selected,
|
|
164
|
+
loading: remote ? currentPage.loading : (source?.loading ?? false),
|
|
165
|
+
resolving: remote && ids.length > 0 && !matchingResolved,
|
|
166
|
+
error: currentPage.error ?? (matchingResolved ? resolved.error : undefined) ?? source?.error,
|
|
167
|
+
retry() {
|
|
168
|
+
source?.retry?.();
|
|
169
|
+
retry((key) => key + 1);
|
|
170
|
+
},
|
|
171
|
+
hasMore: remote ? Boolean(currentPage.cursor) : (source?.hasMore ?? false),
|
|
172
|
+
loadMore() {
|
|
173
|
+
if (!remote) {
|
|
174
|
+
source?.loadMore?.();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!currentPage.cursor || currentPage.loading)
|
|
178
|
+
return;
|
|
179
|
+
const cursor = currentPage.cursor;
|
|
180
|
+
setPage((previous) => ({ ...previous, loading: true, error: undefined }));
|
|
181
|
+
const request = acquire(cache, `search:${requestKey}:${cursor}`, (signal) => source.search(query, cursor, signal));
|
|
182
|
+
moreRelease.current = request.release;
|
|
183
|
+
request.promise
|
|
184
|
+
.then((result) => {
|
|
185
|
+
if (currentRequest.current !== requestKey)
|
|
186
|
+
return;
|
|
187
|
+
setPage((previous) => ({
|
|
188
|
+
key: requestKey,
|
|
189
|
+
items: [
|
|
190
|
+
...new Map([...previous.items, ...result.items].map((item) => [item.value, item])).values(),
|
|
191
|
+
],
|
|
192
|
+
cursor: result.cursor,
|
|
193
|
+
loading: false,
|
|
194
|
+
}));
|
|
195
|
+
}, (error) => {
|
|
196
|
+
if (currentRequest.current === requestKey)
|
|
197
|
+
setPage((previous) => ({ ...previous, loading: false, error: errorMessage(error) }));
|
|
198
|
+
})
|
|
199
|
+
.finally(request.release);
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function optionPresentation(source, items, ids, resolved, matchingResolved, retainedLabels, remote, query) {
|
|
204
|
+
const known = new Map();
|
|
205
|
+
for (const item of [
|
|
206
|
+
...(source?.selectedItems ?? []),
|
|
207
|
+
...(retainedLabels ? resolved.choices : []),
|
|
208
|
+
...items,
|
|
209
|
+
])
|
|
210
|
+
known.set(item.value, item);
|
|
211
|
+
const selected = ids.map((id) => known.get(id) ?? {
|
|
212
|
+
value: id,
|
|
213
|
+
label: remote && matchingResolved && resolved.done && !resolved.error
|
|
214
|
+
? `Unavailable (${id})`
|
|
215
|
+
: id,
|
|
216
|
+
});
|
|
217
|
+
const shown = remote || source?.onQueryChange
|
|
218
|
+
? items
|
|
219
|
+
: items.filter((item) => item.label.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
|
|
220
|
+
return { selected, shown };
|
|
221
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { BoundDefinitions, FilterDefinitions, FilterValues } from "./filter-definition.js";
|
|
2
|
+
import type { FilterEntry, PasteResult } from "./filter-state.js";
|
|
3
|
+
export interface FilterChange {
|
|
4
|
+
source: "edit" | "remove" | "clear" | "paste" | "suggestion" | "search";
|
|
5
|
+
}
|
|
6
|
+
export interface FilterController<S = unknown> {
|
|
7
|
+
values: S;
|
|
8
|
+
search: string;
|
|
9
|
+
entries: FilterEntry[];
|
|
10
|
+
active: FilterEntry[];
|
|
11
|
+
menuOpen: boolean;
|
|
12
|
+
setMenuOpen(open: boolean): void;
|
|
13
|
+
openField: string | null;
|
|
14
|
+
setOpenField(id: string | null): void;
|
|
15
|
+
editField: string | null;
|
|
16
|
+
edit(id: string | null): void;
|
|
17
|
+
commit(id: string, value: unknown, source?: FilterChange["source"]): string | undefined;
|
|
18
|
+
batch(changes: Record<string, unknown>, search?: string, source?: FilterChange["source"]): string | undefined;
|
|
19
|
+
remove(id: string): void;
|
|
20
|
+
clear(): void;
|
|
21
|
+
setSearch(search: string): void;
|
|
22
|
+
paste(text: string, remainder?: {
|
|
23
|
+
before: string;
|
|
24
|
+
after: string;
|
|
25
|
+
}): PasteResult;
|
|
26
|
+
error: string | null;
|
|
27
|
+
shareable: boolean;
|
|
28
|
+
persistenceMessage?: string;
|
|
29
|
+
}
|
|
30
|
+
interface ControllerOptions<S> {
|
|
31
|
+
entries: FilterEntry[];
|
|
32
|
+
value: S;
|
|
33
|
+
search: string;
|
|
34
|
+
apply(changes: Record<string, unknown>, search: string | undefined, meta: FilterChange): void;
|
|
35
|
+
onChange?: (value: S, meta: FilterChange) => void;
|
|
36
|
+
}
|
|
37
|
+
export declare function useFilterController<S>(options: ControllerOptions<S>): FilterController<S>;
|
|
38
|
+
export interface LocalFilterOptions<D extends FilterDefinitions> {
|
|
39
|
+
defaultValues?: Partial<FilterValues<D>>;
|
|
40
|
+
defaultSearch?: string;
|
|
41
|
+
onChange?: (values: FilterValues<D>, meta: FilterChange) => void;
|
|
42
|
+
}
|
|
43
|
+
export declare function useFilters<const D extends FilterDefinitions>(definitions: D, options?: LocalFilterOptions<D>): {
|
|
44
|
+
set<K extends keyof FilterValues<D> & string>(key: K, value: FilterValues<D>[K]): string | undefined;
|
|
45
|
+
values: FilterValues<D>;
|
|
46
|
+
search: string;
|
|
47
|
+
entries: FilterEntry[];
|
|
48
|
+
active: FilterEntry[];
|
|
49
|
+
menuOpen: boolean;
|
|
50
|
+
setMenuOpen(open: boolean): void;
|
|
51
|
+
openField: string | null;
|
|
52
|
+
setOpenField(id: string | null): void;
|
|
53
|
+
editField: string | null;
|
|
54
|
+
edit(id: string | null): void;
|
|
55
|
+
commit(id: string, value: unknown, source?: FilterChange["source"]): string | undefined;
|
|
56
|
+
batch(changes: Record<string, unknown>, search?: string, source?: FilterChange["source"]): string | undefined;
|
|
57
|
+
remove(id: string): void;
|
|
58
|
+
clear(): void;
|
|
59
|
+
setSearch(search: string): void;
|
|
60
|
+
paste(text: string, remainder?: {
|
|
61
|
+
before: string;
|
|
62
|
+
after: string;
|
|
63
|
+
}): PasteResult;
|
|
64
|
+
error: string | null;
|
|
65
|
+
shareable: boolean;
|
|
66
|
+
persistenceMessage?: string;
|
|
67
|
+
};
|
|
68
|
+
export interface ControlledFilterOptions<S> {
|
|
69
|
+
definitions: BoundDefinitions<S>;
|
|
70
|
+
value: S;
|
|
71
|
+
onPatch(patch: Partial<S>, meta: FilterChange): void;
|
|
72
|
+
search?: {
|
|
73
|
+
read(value: S): string;
|
|
74
|
+
write(search: string, current: S): Partial<S>;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export declare function useControlledFilters<S>(options: ControlledFilterOptions<S>): FilterController<S>;
|
|
78
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from "react";
|
|
3
|
+
import { classifyPaste, initialValues } from "./filter-state.js";
|
|
4
|
+
export function useFilterController(options) {
|
|
5
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
6
|
+
const [openField, setOpenField] = useState(null);
|
|
7
|
+
const [editField, edit] = useState(null);
|
|
8
|
+
const [error, setError] = useState(null);
|
|
9
|
+
function batch(changes, search, source = "edit") {
|
|
10
|
+
const normalized = {};
|
|
11
|
+
const entriesById = new Map(options.entries.map((entry) => [entry.id, entry]));
|
|
12
|
+
for (const [id, value] of Object.entries(changes)) {
|
|
13
|
+
const entry = entriesById.get(id);
|
|
14
|
+
if (!entry || entry.field.disabled)
|
|
15
|
+
continue;
|
|
16
|
+
try {
|
|
17
|
+
const next = entry.field.normalize(value);
|
|
18
|
+
const message = source === "clear" || source === "remove" ? undefined : entry.field.validate(next);
|
|
19
|
+
if (message) {
|
|
20
|
+
setError(message);
|
|
21
|
+
return message;
|
|
22
|
+
}
|
|
23
|
+
normalized[id] = next;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
const message = "This filter value is invalid.";
|
|
27
|
+
setError(message);
|
|
28
|
+
return message;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
setError(null);
|
|
32
|
+
options.apply(normalized, search, { source });
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
values: options.value,
|
|
36
|
+
search: options.search,
|
|
37
|
+
entries: options.entries,
|
|
38
|
+
active: options.entries.filter((entry) => !entry.field.hidden && entry.field.isActive(entry.value)),
|
|
39
|
+
menuOpen,
|
|
40
|
+
setMenuOpen: (open) => {
|
|
41
|
+
setMenuOpen(open);
|
|
42
|
+
if (!open) {
|
|
43
|
+
setOpenField(null);
|
|
44
|
+
setError(null);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
openField,
|
|
48
|
+
setOpenField,
|
|
49
|
+
editField,
|
|
50
|
+
edit: (id) => {
|
|
51
|
+
edit(id);
|
|
52
|
+
if (!id)
|
|
53
|
+
setError(null);
|
|
54
|
+
},
|
|
55
|
+
batch,
|
|
56
|
+
commit: (id, value, source) => batch({ [id]: value }, undefined, source),
|
|
57
|
+
remove(id) {
|
|
58
|
+
const entry = options.entries.find((item) => item.id === id);
|
|
59
|
+
if (entry?.field.removable !== false) {
|
|
60
|
+
batch({ [id]: entry?.field.clearValue }, undefined, "remove");
|
|
61
|
+
edit(null);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
clear() {
|
|
65
|
+
batch(Object.fromEntries(options.entries.flatMap((entry) => entry.field.removable !== false && !entry.field.hidden && !entry.field.disabled
|
|
66
|
+
? [[entry.id, entry.field.clearValue]]
|
|
67
|
+
: [])), "", "clear");
|
|
68
|
+
edit(null);
|
|
69
|
+
setMenuOpen(false);
|
|
70
|
+
setOpenField(null);
|
|
71
|
+
},
|
|
72
|
+
setSearch: (search) => {
|
|
73
|
+
batch({}, search, "search");
|
|
74
|
+
},
|
|
75
|
+
paste(text, remainder) {
|
|
76
|
+
const result = classifyPaste(text, options.entries);
|
|
77
|
+
const unmatched = [...result.unmatched, ...result.ambiguous.map((item) => item.token)].join(" ");
|
|
78
|
+
const search = remainder
|
|
79
|
+
? [remainder.before, unmatched, remainder.after].filter(Boolean).join(" ")
|
|
80
|
+
: [options.search, unmatched].filter(Boolean).join(" ");
|
|
81
|
+
let cursor = [remainder ? remainder.before : options.search, result.unmatched.join(" ")]
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.join(" ").length;
|
|
84
|
+
for (const item of result.ambiguous) {
|
|
85
|
+
if (cursor)
|
|
86
|
+
cursor++;
|
|
87
|
+
item.searchRange = { start: cursor, end: cursor + item.token.length };
|
|
88
|
+
cursor += item.token.length;
|
|
89
|
+
}
|
|
90
|
+
batch(result.changes, search, "paste");
|
|
91
|
+
return result;
|
|
92
|
+
},
|
|
93
|
+
error,
|
|
94
|
+
shareable: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function useFilters(definitions, options = {}) {
|
|
98
|
+
const [state, setState] = useState(() => ({
|
|
99
|
+
values: { ...initialValues(definitions), ...options.defaultValues },
|
|
100
|
+
search: options.defaultSearch ?? "",
|
|
101
|
+
}));
|
|
102
|
+
const pending = useRef(state);
|
|
103
|
+
useLayoutEffect(() => {
|
|
104
|
+
pending.current = state;
|
|
105
|
+
}, [state]);
|
|
106
|
+
const controller = useFilterController({
|
|
107
|
+
entries: Object.entries(definitions).map(([id, field]) => ({
|
|
108
|
+
id,
|
|
109
|
+
field,
|
|
110
|
+
value: state.values[id] ?? (state.values[id] === null ? null : field.defaultValue),
|
|
111
|
+
})),
|
|
112
|
+
value: state.values,
|
|
113
|
+
search: state.search,
|
|
114
|
+
apply(changes, search, meta) {
|
|
115
|
+
const next = {
|
|
116
|
+
values: { ...pending.current.values, ...changes },
|
|
117
|
+
search: search ?? pending.current.search,
|
|
118
|
+
};
|
|
119
|
+
pending.current = next;
|
|
120
|
+
setState(next);
|
|
121
|
+
options.onChange?.(next.values, meta);
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
...controller,
|
|
126
|
+
set(key, value) {
|
|
127
|
+
return controller.commit(key, value);
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function useControlledFilters(options) {
|
|
132
|
+
const pending = useRef(options.value);
|
|
133
|
+
useLayoutEffect(() => {
|
|
134
|
+
pending.current = options.value;
|
|
135
|
+
}, [options.value]);
|
|
136
|
+
return useFilterController({
|
|
137
|
+
value: options.value,
|
|
138
|
+
search: options.search?.read(options.value) ?? "",
|
|
139
|
+
entries: Object.entries(options.definitions).map(([id, field]) => ({
|
|
140
|
+
id,
|
|
141
|
+
field: field,
|
|
142
|
+
value: field.read(options.value),
|
|
143
|
+
})),
|
|
144
|
+
apply(changes, search, meta) {
|
|
145
|
+
let next = pending.current;
|
|
146
|
+
let patch = {};
|
|
147
|
+
for (const [id, value] of Object.entries(changes)) {
|
|
148
|
+
const update = options.definitions[id].write(value, next);
|
|
149
|
+
patch = { ...patch, ...update };
|
|
150
|
+
next = { ...next, ...update };
|
|
151
|
+
}
|
|
152
|
+
if (search !== undefined && options.search)
|
|
153
|
+
patch = { ...patch, ...options.search.write(search, next) };
|
|
154
|
+
pending.current = { ...next, ...patch };
|
|
155
|
+
options.onPatch(patch, meta);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { FilterDefinitions, FilterValues } from "./filter-definition.js";
|
|
2
|
+
import type { FilterChange } from "./use-filters.js";
|
|
3
|
+
export interface UrlFilterOptions {
|
|
4
|
+
/** Include the current organization/user when remembering scoped data. */
|
|
5
|
+
scope: string;
|
|
6
|
+
searchKey?: string;
|
|
7
|
+
markerKey?: string;
|
|
8
|
+
remember?: "session" | false;
|
|
9
|
+
maxUrlLength?: number;
|
|
10
|
+
history?: "replace" | "push";
|
|
11
|
+
shallow?: boolean;
|
|
12
|
+
onPersistenceError?: (message: string) => void;
|
|
13
|
+
}
|
|
14
|
+
/** Uses nuqs as the applied state owner. Only overflow values live outside the URL. */
|
|
15
|
+
export declare function useUrlFilters<const D extends FilterDefinitions>(definitions: D, options: UrlFilterOptions): {
|
|
16
|
+
ready: boolean;
|
|
17
|
+
shareable: boolean;
|
|
18
|
+
persistenceMessage: string | undefined;
|
|
19
|
+
set<K extends keyof FilterValues<D> & string>(key: K, value: FilterValues<D>[K]): string | undefined;
|
|
20
|
+
createLink(base: string): string | null;
|
|
21
|
+
values: FilterValues<D>;
|
|
22
|
+
search: string;
|
|
23
|
+
entries: import("./filter-state.js").FilterEntry[];
|
|
24
|
+
active: import("./filter-state.js").FilterEntry[];
|
|
25
|
+
menuOpen: boolean;
|
|
26
|
+
setMenuOpen(open: boolean): void;
|
|
27
|
+
openField: string | null;
|
|
28
|
+
setOpenField(id: string | null): void;
|
|
29
|
+
editField: string | null;
|
|
30
|
+
edit(id: string | null): void;
|
|
31
|
+
commit(id: string, value: unknown, source?: FilterChange["source"]): string | undefined;
|
|
32
|
+
batch(changes: Record<string, unknown>, search?: string, source?: FilterChange["source"]): string | undefined;
|
|
33
|
+
remove(id: string): void;
|
|
34
|
+
clear(): void;
|
|
35
|
+
setSearch(search: string): void;
|
|
36
|
+
paste(text: string, remainder?: {
|
|
37
|
+
before: string;
|
|
38
|
+
after: string;
|
|
39
|
+
}): import("./filter-state.js").PasteResult;
|
|
40
|
+
error: string | null;
|
|
41
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { parseAsString, useQueryStates } from "nuqs";
|
|
4
|
+
import { decodeFilters, encodeFilters, validateDefinitions } from "./filter-state.js";
|
|
5
|
+
import { useFilterController } from "./use-filters.js";
|
|
6
|
+
const subscribeStorage = () => () => { };
|
|
7
|
+
function useStoredQuery(key) {
|
|
8
|
+
return useSyncExternalStore(subscribeStorage, () => (key ? (readSnapshot(key)?.query ?? null) : null), () => null);
|
|
9
|
+
}
|
|
10
|
+
function readSnapshot(key) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = sessionStorage.getItem(key);
|
|
13
|
+
if (!raw)
|
|
14
|
+
return null;
|
|
15
|
+
const value = JSON.parse(raw);
|
|
16
|
+
return typeof value === "object" &&
|
|
17
|
+
value !== null &&
|
|
18
|
+
"query" in value &&
|
|
19
|
+
typeof value.query === "string" &&
|
|
20
|
+
"savedAt" in value &&
|
|
21
|
+
typeof value.savedAt === "number"
|
|
22
|
+
? value
|
|
23
|
+
: null;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Uses nuqs as the applied state owner. Only overflow values live outside the URL. */
|
|
30
|
+
export function useUrlFilters(definitions, options) {
|
|
31
|
+
const searchKey = options.searchKey ?? "q";
|
|
32
|
+
const markerKey = options.markerKey ?? "_filters";
|
|
33
|
+
validateDefinitions(definitions, searchKey, markerKey);
|
|
34
|
+
const keys = [
|
|
35
|
+
...Object.entries(definitions).map(([id, field]) => field.urlKey ?? id),
|
|
36
|
+
searchKey,
|
|
37
|
+
markerKey,
|
|
38
|
+
];
|
|
39
|
+
const parsers = Object.fromEntries(keys.map((key) => [key, parseAsString]));
|
|
40
|
+
const [raw, setRaw] = useQueryStates(parsers, {
|
|
41
|
+
history: options.history ?? "replace",
|
|
42
|
+
shallow: options.shallow ?? true,
|
|
43
|
+
});
|
|
44
|
+
const [overflow, setOverflow] = useState(null);
|
|
45
|
+
const [message, setMessage] = useState();
|
|
46
|
+
const [ready, setReady] = useState(false);
|
|
47
|
+
const latest = useRef({ definitions, options, raw });
|
|
48
|
+
useLayoutEffect(() => {
|
|
49
|
+
latest.current = { definitions, options, raw };
|
|
50
|
+
}, [definitions, options, raw]);
|
|
51
|
+
const path = typeof window === "undefined" ? "" : window.location.pathname;
|
|
52
|
+
const storageKey = `mendy-ui:filters:${path}:${searchKey}:${options.scope}`;
|
|
53
|
+
const marker = raw[markerKey];
|
|
54
|
+
const storedQuery = useStoredQuery(marker ? `${storageKey}:overflow:${marker}` : null);
|
|
55
|
+
const pending = useRef(null);
|
|
56
|
+
const fullParams = appliedParams(raw, keys, marker, overflow, storedQuery, options.scope);
|
|
57
|
+
const values = decodeFilters(definitions, fullParams);
|
|
58
|
+
const search = fullParams.get(searchKey) ?? "";
|
|
59
|
+
useLayoutEffect(() => {
|
|
60
|
+
pending.current = { values, search };
|
|
61
|
+
}, [values, search]);
|
|
62
|
+
function report(text) {
|
|
63
|
+
setMessage(text);
|
|
64
|
+
options.onPersistenceError?.(text);
|
|
65
|
+
}
|
|
66
|
+
function writeSnapshot(key, query) {
|
|
67
|
+
try {
|
|
68
|
+
sessionStorage.setItem(key, JSON.stringify({ query, savedAt: Date.now() }));
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
report("Filters could not be saved in this browser. Keep this page open to retain them.");
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function update(values, search, source) {
|
|
77
|
+
pending.current = { values, search };
|
|
78
|
+
const full = encodeFilters(definitions, values, new URLSearchParams(window.location.search));
|
|
79
|
+
full.delete(markerKey);
|
|
80
|
+
if (search)
|
|
81
|
+
full.set(searchKey, search);
|
|
82
|
+
else
|
|
83
|
+
full.delete(searchKey);
|
|
84
|
+
const owned = new URLSearchParams();
|
|
85
|
+
for (const key of keys)
|
|
86
|
+
if (full.has(key))
|
|
87
|
+
owned.set(key, full.get(key));
|
|
88
|
+
const exceeds = `${window.location.origin}${window.location.pathname}?${full}`.length >
|
|
89
|
+
(options.maxUrlLength ?? 2000);
|
|
90
|
+
const next = Object.fromEntries(keys.map((key) => [key, full.get(key)]));
|
|
91
|
+
setMessage(undefined);
|
|
92
|
+
if (exceeds) {
|
|
93
|
+
const token = crypto.randomUUID();
|
|
94
|
+
writeSnapshot(`${storageKey}:overflow:${token}`, owned.toString());
|
|
95
|
+
setOverflow({ marker: token, scope: options.scope, query: owned.toString() });
|
|
96
|
+
for (const key of keys)
|
|
97
|
+
next[key] = null;
|
|
98
|
+
next[markerKey] = token;
|
|
99
|
+
// Bound saved history while retaining recent overflow states for browser navigation.
|
|
100
|
+
try {
|
|
101
|
+
const prefix = `${storageKey}:overflow:`;
|
|
102
|
+
const saved = Object.keys(sessionStorage)
|
|
103
|
+
.flatMap((key) => key.startsWith(prefix) ? [{ key, time: readSnapshot(key)?.savedAt ?? 0 }] : [])
|
|
104
|
+
.sort((a, b) => b.time - a.time);
|
|
105
|
+
for (const item of saved.slice(30))
|
|
106
|
+
sessionStorage.removeItem(item.key);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
/* Storage failure was already reported by writeSnapshot. */
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
setOverflow(null);
|
|
114
|
+
if (options.remember === "session") {
|
|
115
|
+
if (source === "clear") {
|
|
116
|
+
try {
|
|
117
|
+
sessionStorage.removeItem(storageKey);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
report("Remembered filters could not be cleared in this browser.");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else
|
|
124
|
+
writeSnapshot(storageKey, owned.toString());
|
|
125
|
+
}
|
|
126
|
+
void setRaw(next).catch(() => report("The URL could not be updated. Filters may not survive a reload."));
|
|
127
|
+
}
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
setReady(true);
|
|
130
|
+
const current = latest.current;
|
|
131
|
+
const address = new URLSearchParams(window.location.search);
|
|
132
|
+
const hasUrl = [
|
|
133
|
+
...Object.entries(current.definitions).map(([id, field]) => field.urlKey ?? id),
|
|
134
|
+
searchKey,
|
|
135
|
+
markerKey,
|
|
136
|
+
].some((key) => address.has(key));
|
|
137
|
+
if (!hasUrl && current.options.remember === "session") {
|
|
138
|
+
const saved = readSnapshot(storageKey);
|
|
139
|
+
if (saved) {
|
|
140
|
+
const params = new URLSearchParams(saved.query);
|
|
141
|
+
const restored = decodeFilters(current.definitions, params);
|
|
142
|
+
update(restored, params.get(searchKey) ?? "", "edit");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Restoration runs once per storage scope. URL changes subsequently belong to nuqs.
|
|
146
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
147
|
+
}, [storageKey]);
|
|
148
|
+
const controller = useFilterController({
|
|
149
|
+
value: values,
|
|
150
|
+
search,
|
|
151
|
+
entries: Object.entries(definitions).map(([id, field]) => ({ id, field, value: values[id] })),
|
|
152
|
+
apply(changes, nextSearch, meta) {
|
|
153
|
+
update({ ...pending.current.values, ...changes }, nextSearch ?? pending.current.search, meta.source);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
return {
|
|
157
|
+
...controller,
|
|
158
|
+
ready,
|
|
159
|
+
shareable: !marker,
|
|
160
|
+
persistenceMessage: persistenceNotice(ready, marker, storedQuery, overflow?.marker, message),
|
|
161
|
+
set(key, value) {
|
|
162
|
+
return controller.commit(key, value);
|
|
163
|
+
},
|
|
164
|
+
createLink(base) {
|
|
165
|
+
if (marker)
|
|
166
|
+
return null;
|
|
167
|
+
const url = new URL(base, window.location.origin);
|
|
168
|
+
const params = encodeFilters(definitions, values, url.searchParams);
|
|
169
|
+
params.delete(markerKey);
|
|
170
|
+
if (search)
|
|
171
|
+
params.set(searchKey, search);
|
|
172
|
+
else
|
|
173
|
+
params.delete(searchKey);
|
|
174
|
+
url.search = params.toString();
|
|
175
|
+
return url.toString();
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function persistenceNotice(ready, marker, storedQuery, memoryMarker, message) {
|
|
180
|
+
if (ready && marker && storedQuery === null && memoryMarker !== marker)
|
|
181
|
+
return "This link refers to filters saved in another browser session. The full selection is unavailable.";
|
|
182
|
+
if (message)
|
|
183
|
+
return message;
|
|
184
|
+
if (marker)
|
|
185
|
+
return "This selection is saved in this browser session. The URL does not contain the full filters.";
|
|
186
|
+
}
|
|
187
|
+
function appliedParams(raw, keys, marker, overflow, stored, scope) {
|
|
188
|
+
if (overflow && overflow.marker === marker && overflow.scope === scope)
|
|
189
|
+
return new URLSearchParams(overflow.query);
|
|
190
|
+
if (marker && stored !== null)
|
|
191
|
+
return new URLSearchParams(stored);
|
|
192
|
+
const params = new URLSearchParams();
|
|
193
|
+
for (const key of keys)
|
|
194
|
+
if (raw[key] != null)
|
|
195
|
+
params.set(key, raw[key]);
|
|
196
|
+
return params;
|
|
197
|
+
}
|