@human-synthesis/norns-ui 0.0.13 → 0.0.14
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/COMPONENTS.md +46 -1
- package/README.md +29 -0
- package/package.json +3 -2
- package/src/components/Autocomplete.n +41 -4
- package/src/components/DataTable.n +11 -4
- package/src/components/Form.n +40 -7
- package/src/components/MultiSelect.n +42 -4
- package/src/index.js +1 -0
- package/src/lib/list.svelte.d.ts +46 -0
- package/src/lib/list.svelte.js +143 -0
- package/src/styles/atoms.css +1 -0
- package/src/types/Autocomplete.d.ts +12 -0
- package/src/types/DataTable.d.ts +9 -1
- package/src/types/Form.d.ts +16 -0
- package/src/types/MultiSelect.d.ts +10 -1
package/COMPONENTS.md
CHANGED
|
@@ -56,11 +56,23 @@ export type AudioProps = {
|
|
|
56
56
|
```ts
|
|
57
57
|
export type ComboboxItem = { value: string; label: string };
|
|
58
58
|
|
|
59
|
+
/** Remote option source: resolves the items for the current input, or a `{ data }` envelope. */
|
|
60
|
+
export type ComboboxSource = (term: string) => Promise<ComboboxItem[] | { data?: ComboboxItem[] } | null | undefined> | ComboboxItem[];
|
|
61
|
+
|
|
59
62
|
export type AutocompleteProps = {
|
|
63
|
+
/** Local options, filtered client-side by label. Ignored when `source` is set. */
|
|
60
64
|
items?: ComboboxItem[];
|
|
65
|
+
/** Remote options: called (debounced) with the typed text while the list is open. */
|
|
66
|
+
source?: ComboboxSource;
|
|
67
|
+
/** Milliseconds to wait after typing before calling `source`. Default 200. */
|
|
68
|
+
debounce?: number;
|
|
69
|
+
/** Minimum typed characters before `source` is called. Default 0. */
|
|
70
|
+
minChars?: number;
|
|
61
71
|
value?: string;
|
|
62
72
|
open?: boolean;
|
|
63
73
|
placeholder?: string;
|
|
74
|
+
emptyMessage?: string;
|
|
75
|
+
loadingMessage?: string;
|
|
64
76
|
disabled?: boolean;
|
|
65
77
|
name?: string;
|
|
66
78
|
id?: string;
|
|
@@ -381,6 +393,8 @@ export type DataTableColumn = {
|
|
|
381
393
|
class?: string;
|
|
382
394
|
cellClass?: string;
|
|
383
395
|
sortable?: boolean;
|
|
396
|
+
/** Render the cell value (e.g. a date or number formatter). */
|
|
397
|
+
format?: (value: unknown, row: Record<string, unknown>) => string | number;
|
|
384
398
|
};
|
|
385
399
|
|
|
386
400
|
export type DataTableProps = {
|
|
@@ -389,8 +403,14 @@ export type DataTableProps = {
|
|
|
389
403
|
striped?: boolean;
|
|
390
404
|
dense?: boolean;
|
|
391
405
|
stickyHeader?: boolean;
|
|
406
|
+
/** Dims the table and swaps the empty message while a page is being fetched (see `useList()`). */
|
|
407
|
+
loading?: boolean;
|
|
392
408
|
emptyMessage?: string;
|
|
393
|
-
|
|
409
|
+
loadingMessage?: string;
|
|
410
|
+
/**
|
|
411
|
+
* Currently-sorted column key. Bindable. The table only flips these two;
|
|
412
|
+
* sort `rows` yourself or bind them to `useList()` for server-side ordering.
|
|
413
|
+
*/
|
|
394
414
|
sortKey?: string;
|
|
395
415
|
sortDir?: 'asc' | 'desc';
|
|
396
416
|
onrowclick?: (row: Record<string, unknown>, index: number) => void;
|
|
@@ -564,6 +584,22 @@ export type FormProps = Omit<HTMLFormAttributes, 'class' | 'children'> & {
|
|
|
564
584
|
* up its own error automatically.
|
|
565
585
|
*/
|
|
566
586
|
form?: FormActionResult;
|
|
587
|
+
/**
|
|
588
|
+
* API mode. Intercepts the submit, collects the fields into a plain object
|
|
589
|
+
* and calls this instead of posting the form — typically
|
|
590
|
+
* `(values) => api.post('/api/notes', values)` against a norns `route()`.
|
|
591
|
+
* A rejection whose `body.issues` (or `issues`) is a valibot issue list is
|
|
592
|
+
* mapped into the same errors context as `form`.
|
|
593
|
+
*/
|
|
594
|
+
submit?: (values: Record<string, FormDataEntryValue>, event: SubmitEvent) => Promise<unknown> | unknown;
|
|
595
|
+
/** Called with the resolved value of `submit`. */
|
|
596
|
+
onsuccess?: (result: unknown, values: Record<string, FormDataEntryValue>) => void;
|
|
597
|
+
/** Called when `submit` rejects (after the errors map was updated). */
|
|
598
|
+
onerror?: (error: unknown) => void;
|
|
599
|
+
/** True while `submit` is pending. Bindable. */
|
|
600
|
+
submitting?: boolean;
|
|
601
|
+
/** API mode: clear the fields after `submit` resolves. */
|
|
602
|
+
resetOnSuccess?: boolean;
|
|
567
603
|
class?: string;
|
|
568
604
|
children?: Snippet;
|
|
569
605
|
};
|
|
@@ -752,10 +788,19 @@ export type MegaMenuProps = {
|
|
|
752
788
|
|
|
753
789
|
```ts
|
|
754
790
|
export type MultiSelectProps = {
|
|
791
|
+
/** Local options, filtered client-side by label. Ignored when `source` is set. */
|
|
755
792
|
items?: ComboboxItem[];
|
|
793
|
+
/** Remote options: called (debounced) with the typed text while the list is open. Picked labels are remembered across searches. */
|
|
794
|
+
source?: ComboboxSource;
|
|
795
|
+
/** Milliseconds to wait after typing before calling `source`. Default 200. */
|
|
796
|
+
debounce?: number;
|
|
797
|
+
/** Minimum typed characters before `source` is called. Default 0. */
|
|
798
|
+
minChars?: number;
|
|
756
799
|
value?: string[];
|
|
757
800
|
open?: boolean;
|
|
758
801
|
placeholder?: string;
|
|
802
|
+
emptyMessage?: string;
|
|
803
|
+
loadingMessage?: string;
|
|
759
804
|
disabled?: boolean;
|
|
760
805
|
name?: string;
|
|
761
806
|
id?: string;
|
package/README.md
CHANGED
|
@@ -98,6 +98,34 @@ Form(action="?/save" form!="{form}")
|
|
|
98
98
|
|
|
99
99
|
`<Field name="title">` reads its error from the parent `<Form>`'s context map; no per-page boilerplate.
|
|
100
100
|
|
|
101
|
+
### API mode
|
|
102
|
+
|
|
103
|
+
Give `Form` a `submit` function and it posts through your API client instead of a form action. A rejection carrying `issues` (a norns `route()` 400, surfaced as `ApiError.body.issues`) feeds the same errors map, so the Fields light up either way:
|
|
104
|
+
|
|
105
|
+
```pug
|
|
106
|
+
Form(submit!="{(values) => api.post('/api/notes', values)}" onsuccess!="{onCreated}" bind:submitting!="{busy}" resetOnSuccess!="{true}")
|
|
107
|
+
Field(label="Title" name="title" required)
|
|
108
|
+
Input(name="title")
|
|
109
|
+
Btn(type="submit" variant="primary" loading!="{busy}") Create
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Server-driven lists
|
|
113
|
+
|
|
114
|
+
`useList()` (`@human-synthesis/norns-ui/list`) owns page / sort / search state and re-runs your loader with a query string a norns `listQuery()` route understands. Bind it to `DataTable` and `Pagination`; the server orders and slices, the page never holds more than one page:
|
|
115
|
+
|
|
116
|
+
```pug
|
|
117
|
+
Input(type="search" bind:value!="{list.q}")
|
|
118
|
+
DataTable(columns!="{columns}" rows!="{list.rows}" bind:sortKey!="{list.sort}" bind:sortDir!="{list.dir}" loading!="{list.loading}")
|
|
119
|
+
Pagination(bind:page!="{list.page}" total!="{list.total}" pageSize!="{list.pageSize}")
|
|
120
|
+
|
|
121
|
+
<script>
|
|
122
|
+
import { useList } from '@human-synthesis/norns-ui/list'
|
|
123
|
+
list := useList (qs) => api.get(`/api/notes?${qs}`), { sort: 'updated_at', dir: 'desc', debounce: 150 }
|
|
124
|
+
</script>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`Autocomplete` and `MultiSelect` accept a `source` function (debounced, out-of-order responses dropped) in place of `items`, so pickers over large sets fetch options as you type.
|
|
128
|
+
|
|
101
129
|
## What's in here
|
|
102
130
|
|
|
103
131
|
- **Atoms** (CSS-only `@layer components`): `.btn`, `.input`, `.field`, `.form`, `.checkbox`, `.radio`, `.switch`, `.card`, `.surface-elevated`, `.btn-icon`, `.banner`, `.badge`, `.chip`, `.avatar`, `.skeleton`, `.progress`, `.norns-header`, `.hero`, `.stepper`, `.breadcrumbs`, `.pagination`, `.accordion`, `.carousel`, plus variants/sizes.
|
|
@@ -107,6 +135,7 @@ Form(action="?/save" form!="{form}")
|
|
|
107
135
|
- **Composite**: `Header`, `HeroBanner`, `Stepper`, `Breadcrumbs`, `Pagination`, `Carousel`, `Tree`, `Toolbar`, `Separator`, `ButtonGroup`, `ToggleButton`, `ToggleButtonGroup`, `HierarchicalMenu`, `MegaMenu`.
|
|
108
136
|
- **Inputs**: `NumberInput`, `OtpField`, `TagsInput`, `Autocomplete`, `MultiSelect`, `ColorPicker`, `Uploader`, `DatePicker`, `DateRangePicker`, `TimePicker`, `Calendar`, `DataTable`, `ScrollArea`, `CopyButton`, `ThemeToggler`, `ShinyButton`, `RippleButton`.
|
|
109
137
|
- **Toast**: `ToastProvider` + `toast()` / `notify()` / `dismiss()` from `@human-synthesis/norns-ui/toast`.
|
|
138
|
+
- **Lists**: `useList()` from `@human-synthesis/norns-ui/list` — server-driven paging / sorting / search state for `DataTable` + `Pagination`.
|
|
110
139
|
- **Motion** (opt-in, `@human-synthesis/norns-ui/motion`): `AnimatedNumber`, `GradientBackground`, `LiquidButton`, `Reveal`, `Sparkles`.
|
|
111
140
|
|
|
112
141
|
The full prop reference for every component is **[COMPONENTS.md](./COMPONENTS.md)**, generated from the `.d.ts` shims under `src/types/` (`bun run docs:components`). Live usage of every component: `norns-demo/src/routes/examples/ui/+page.n`. Override any component by dropping `src/lib/components/<Name>.n` in your project — `nornsAutoImport`'s first-match-wins shadows the library silently.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@human-synthesis/norns-ui",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"description": "UI library for the Norns ecosystem — Pug + Civet components on Tailwind v4.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Daniel Teodoroiu (https://humansynthesis.ai)",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"./behaviors": "./src/lib/behaviors/index.js",
|
|
29
29
|
"./behaviors/*": "./src/lib/behaviors/*",
|
|
30
30
|
"./toast": "./src/lib/toast.svelte.js",
|
|
31
|
+
"./list": "./src/lib/list.svelte.js",
|
|
31
32
|
"./styles": "./src/styles/index.css",
|
|
32
33
|
"./styles/tokens": "./src/styles/tokens.css",
|
|
33
34
|
"./styles/atoms": "./src/styles/atoms.css",
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
"./package.json": "./package.json"
|
|
38
39
|
},
|
|
39
40
|
"peerDependencies": {
|
|
40
|
-
"@human-synthesis/norns": "^0.0.
|
|
41
|
+
"@human-synthesis/norns": "^0.0.19",
|
|
41
42
|
"@human-synthesis/norns-core": "^0.0.11",
|
|
42
43
|
"svelte": "^5.0.0",
|
|
43
44
|
"tailwindcss": "^4.0.0"
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
aria-expanded!="{open}"
|
|
10
10
|
aria-controls!="{listboxId}"
|
|
11
11
|
aria-autocomplete="list"
|
|
12
|
+
aria-busy!="{loading ? 'true' : undefined}"
|
|
12
13
|
aria-activedescendant!="{open && filtered[active] ? optionId(active) : undefined}"
|
|
13
14
|
id!="{resolvedId}"
|
|
14
15
|
aria-invalid!="{hasError ? 'true' : undefined}"
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
onblur!="{onBlur}"
|
|
19
20
|
)
|
|
20
21
|
button.combobox-trigger(type="button" tabindex="-1" aria-label="Toggle options" onclick!="{toggle}")
|
|
21
|
-
Icon(name
|
|
22
|
+
Icon(name!="{loading ? 'lucide:loader-circle' : 'lucide:chevrons-up-down'}" size="size-4" class!="{loading ? 'animate-spin' : ''}")
|
|
22
23
|
|
|
23
24
|
+if('open && filtered.length > 0')
|
|
24
25
|
ul(
|
|
@@ -40,9 +41,9 @@
|
|
|
40
41
|
)
|
|
41
42
|
Icon(name="lucide:check" size="size-3.5" class!="{value === item.value ? '' : 'opacity-0'}")
|
|
42
43
|
span {item.label}
|
|
43
|
-
+if('open && filtered.length === 0')
|
|
44
|
+
+if('open && filtered.length === 0 && (loading || term.length >= minChars)')
|
|
44
45
|
div(use:portal use:floatAction use:clickOutside!="{[onClose, open, wrapEl]}" class="dropdown-content combobox-listbox" data-state="open")
|
|
45
|
-
.combobox-empty
|
|
46
|
+
.combobox-empty {loading ? loadingMessage : emptyMessage}
|
|
46
47
|
|
|
47
48
|
<script>
|
|
48
49
|
import { getContext } from 'svelte'
|
|
@@ -53,9 +54,14 @@
|
|
|
53
54
|
|
|
54
55
|
{
|
|
55
56
|
items = []
|
|
57
|
+
source
|
|
58
|
+
debounce = 200
|
|
59
|
+
minChars = 0
|
|
56
60
|
value = $bindable(undefined)
|
|
57
61
|
open = $bindable(false)
|
|
58
62
|
placeholder = 'Search…'
|
|
63
|
+
emptyMessage = 'No matches'
|
|
64
|
+
loadingMessage = 'Searching…'
|
|
59
65
|
disabled = false
|
|
60
66
|
name
|
|
61
67
|
id: providedId
|
|
@@ -80,13 +86,43 @@
|
|
|
80
86
|
inputEl .= $state null
|
|
81
87
|
wrapEl .= $state null
|
|
82
88
|
|
|
89
|
+
// Remote mode: `source(term)` resolves the options for the current input.
|
|
90
|
+
// Results are debounced, out-of-order responses are dropped, and the
|
|
91
|
+
// picked item is remembered so its label survives a new search.
|
|
92
|
+
remote .= $state []
|
|
93
|
+
loading .= $state false
|
|
94
|
+
picked .= $state null
|
|
95
|
+
seq .= 0
|
|
96
|
+
|
|
97
|
+
all := $derived source ? remote : items
|
|
98
|
+
|
|
99
|
+
search := async (t) =>
|
|
100
|
+
id := ++seq
|
|
101
|
+
loading = true
|
|
102
|
+
try
|
|
103
|
+
res := await source t
|
|
104
|
+
return unless id === seq
|
|
105
|
+
remote = Array.isArray(res) ? res : (res?.data ?? [])
|
|
106
|
+
catch
|
|
107
|
+
remote = [] if id === seq
|
|
108
|
+
finally
|
|
109
|
+
loading = false if id === seq
|
|
110
|
+
|
|
111
|
+
$effect =>
|
|
112
|
+
return unless source && open
|
|
113
|
+
t := term
|
|
114
|
+
return if t.length < minChars
|
|
115
|
+
timer := setTimeout (=> search t), debounce
|
|
116
|
+
return () => clearTimeout timer
|
|
117
|
+
|
|
83
118
|
// Reset term to selected label when value changes externally.
|
|
84
119
|
$effect =>
|
|
85
120
|
if value !== undefined
|
|
86
|
-
match :=
|
|
121
|
+
match := all.find((it) => it.value === value) ?? (picked?.value === value ? picked : null)
|
|
87
122
|
term = match?.label ?? '' if match && term !== match.label
|
|
88
123
|
|
|
89
124
|
filtered := $derived.by =>
|
|
125
|
+
return remote if source
|
|
90
126
|
return items unless term
|
|
91
127
|
t := term.toLowerCase()
|
|
92
128
|
items.filter (it) => it.label.toLowerCase().includes(t)
|
|
@@ -109,6 +145,7 @@
|
|
|
109
145
|
setTimeout (=> open = false), 100
|
|
110
146
|
|
|
111
147
|
pick := (item) =>
|
|
148
|
+
picked = item
|
|
112
149
|
value = item.value
|
|
113
150
|
term = item.label
|
|
114
151
|
open = false
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.data-table-wrap(class!="{wrapClasses}")
|
|
1
|
+
.data-table-wrap(class!="{wrapClasses}" aria-busy!="{loading ? 'true' : undefined}")
|
|
2
2
|
table.data-table
|
|
3
3
|
thead
|
|
4
4
|
tr
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
th(
|
|
7
7
|
class!="{col.class ?? ''}"
|
|
8
8
|
style!="{col.width ? `width: ${col.width}` : undefined}"
|
|
9
|
+
aria-sort!="{sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}"
|
|
9
10
|
)
|
|
10
11
|
+if('col.sortable')
|
|
11
12
|
button.data-table-sort(type="button" onclick!="{() => onSort(col.key)}")
|
|
@@ -17,12 +18,12 @@
|
|
|
17
18
|
+if('rows.length === 0')
|
|
18
19
|
tr.data-table-empty
|
|
19
20
|
td(colspan!="{columns.length}")
|
|
20
|
-
| {emptyMessage}
|
|
21
|
+
| {loading ? loadingMessage : emptyMessage}
|
|
21
22
|
+each('rows as row, i')
|
|
22
23
|
tr(onclick!="{onrowclick ? () => onrowclick(row, i) : undefined}" class!="{onrowclick ? 'data-table-row-clickable' : ''}")
|
|
23
24
|
+each('columns as col')
|
|
24
25
|
td(class!="{col.cellClass ?? ''}")
|
|
25
|
-
| {row[col.key]}
|
|
26
|
+
| {col.format ? col.format(row[col.key], row) : row[col.key]}
|
|
26
27
|
|
|
27
28
|
<script>
|
|
28
29
|
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
@@ -34,20 +35,26 @@
|
|
|
34
35
|
striped = false
|
|
35
36
|
dense = false
|
|
36
37
|
stickyHeader = false
|
|
38
|
+
loading = false
|
|
37
39
|
emptyMessage = 'No data'
|
|
40
|
+
loadingMessage = 'Loading…'
|
|
38
41
|
sortKey = $bindable(undefined)
|
|
39
42
|
sortDir = $bindable('asc')
|
|
40
43
|
onrowclick
|
|
41
44
|
class: extra = ''
|
|
42
45
|
} .= $props()
|
|
43
46
|
|
|
44
|
-
wrapClasses := cn
|
|
47
|
+
wrapClasses := $derived cn
|
|
45
48
|
'data-table-root'
|
|
46
49
|
striped && 'data-table-striped'
|
|
47
50
|
dense && 'data-table-dense'
|
|
48
51
|
stickyHeader && 'data-table-sticky'
|
|
52
|
+
loading && 'data-table-loading'
|
|
49
53
|
extra
|
|
50
54
|
|
|
55
|
+
// Sorting is delegated: the component only flips `sortKey` / `sortDir`
|
|
56
|
+
// (both bindable). Sort the `rows` yourself, or hand both bindings to
|
|
57
|
+
// `useList()` and let the server order the page.
|
|
51
58
|
onSort := (key) =>
|
|
52
59
|
if sortKey === key
|
|
53
60
|
sortDir = sortDir === 'asc' ? 'desc' : 'asc'
|
package/src/components/Form.n
CHANGED
|
@@ -3,7 +3,8 @@ form(
|
|
|
3
3
|
action!="{action}"
|
|
4
4
|
enctype!="{enctype}"
|
|
5
5
|
class!="{classes}"
|
|
6
|
-
|
|
6
|
+
aria-busy!="{submitting ? 'true' : undefined}"
|
|
7
|
+
onsubmit!="{handleSubmit}"
|
|
7
8
|
)
|
|
8
9
|
| {@render children?.()}
|
|
9
10
|
|
|
@@ -17,9 +18,40 @@ form(
|
|
|
17
18
|
enctype
|
|
18
19
|
onsubmit
|
|
19
20
|
form
|
|
21
|
+
submit
|
|
22
|
+
onsuccess
|
|
23
|
+
onerror
|
|
24
|
+
submitting = $bindable(false)
|
|
25
|
+
resetOnSuccess = false
|
|
20
26
|
children
|
|
21
27
|
class: extra = ''
|
|
22
|
-
}
|
|
28
|
+
} .= $props()
|
|
29
|
+
|
|
30
|
+
// API mode. With `submit`, the native POST is intercepted: the fields are
|
|
31
|
+
// collected into a plain object and handed to `submit(values, event)` —
|
|
32
|
+
// typically `api.post('/api/notes', values)` against a norns route().
|
|
33
|
+
// A rejection carrying `issues` (route()'s 400 body, surfaced as
|
|
34
|
+
// ApiError.body.issues) feeds the same errors map the action path uses,
|
|
35
|
+
// so <Field name="…"> shows the message either way.
|
|
36
|
+
apiIssues .= $state null
|
|
37
|
+
|
|
38
|
+
handleSubmit := async (e) =>
|
|
39
|
+
onsubmit?.(e)
|
|
40
|
+
return unless submit && !e.defaultPrevented
|
|
41
|
+
e.preventDefault()
|
|
42
|
+
values := Object.fromEntries new FormData(e.currentTarget)
|
|
43
|
+
submitting = true
|
|
44
|
+
apiIssues = null
|
|
45
|
+
try
|
|
46
|
+
result := await submit values, e
|
|
47
|
+
e.currentTarget?.reset?.() if resetOnSuccess
|
|
48
|
+
onsuccess?.(result, values)
|
|
49
|
+
catch err
|
|
50
|
+
issues := err?.body?.issues ?? err?.issues
|
|
51
|
+
apiIssues = issues if Array.isArray issues
|
|
52
|
+
onerror?.(err)
|
|
53
|
+
finally
|
|
54
|
+
submitting = false
|
|
23
55
|
|
|
24
56
|
// Errors-by-name map. Built reactively from the valibot issue list so
|
|
25
57
|
// descendant Fields can look up their own error by `name` without a
|
|
@@ -30,16 +62,17 @@ form(
|
|
|
30
62
|
// errors during initial server render.
|
|
31
63
|
errorsMap := $derived.by =>
|
|
32
64
|
out := {}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
65
|
+
issues := apiIssues ?? form?.errors
|
|
66
|
+
if issues
|
|
67
|
+
for issue of issues
|
|
68
|
+
name := issue.path?.[0]?.key ?? issue.path?.[0]
|
|
36
69
|
if name && !out[name]
|
|
37
70
|
out[name] = issue.message
|
|
38
71
|
out
|
|
39
72
|
|
|
40
73
|
// Getter wraps the $derived so context consumers re-evaluate on read.
|
|
41
|
-
ctx := { get errors() { errorsMap } }
|
|
74
|
+
ctx := { get errors() { errorsMap }, get submitting() { submitting } }
|
|
42
75
|
setContext 'norns-ui:form', ctx
|
|
43
76
|
|
|
44
|
-
classes := cn 'form', extra
|
|
77
|
+
classes := $derived cn 'form', extra
|
|
45
78
|
</script>
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
aria-expanded!="{open}"
|
|
25
25
|
aria-controls!="{listboxId}"
|
|
26
26
|
aria-autocomplete="list"
|
|
27
|
+
aria-busy!="{loading ? 'true' : undefined}"
|
|
27
28
|
id!="{resolvedId}"
|
|
28
29
|
aria-invalid!="{hasError ? 'true' : undefined}"
|
|
29
30
|
oninput!="{onInput}"
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
aria-label="Toggle options"
|
|
38
39
|
onclick!="{(e) => { e.stopPropagation(); toggle() }}"
|
|
39
40
|
)
|
|
40
|
-
Icon(name
|
|
41
|
+
Icon(name!="{loading ? 'lucide:loader-circle' : 'lucide:chevrons-up-down'}" size="size-4" class!="{loading ? 'animate-spin' : ''}")
|
|
41
42
|
|
|
42
43
|
+if('open && filtered.length > 0')
|
|
43
44
|
ul(
|
|
@@ -59,9 +60,9 @@
|
|
|
59
60
|
)
|
|
60
61
|
Icon(name="lucide:check" size="size-3.5" class!="{value.includes(item.value) ? '' : 'opacity-0'}")
|
|
61
62
|
span {item.label}
|
|
62
|
-
+if('open && filtered.length === 0')
|
|
63
|
+
+if('open && filtered.length === 0 && (loading || term.length >= minChars)')
|
|
63
64
|
div(use:portal use:floatAction use:clickOutside!="{[onClose, open, wrapEl]}" class="dropdown-content combobox-listbox" data-state="open")
|
|
64
|
-
.combobox-empty
|
|
65
|
+
.combobox-empty {loading ? loadingMessage : emptyMessage}
|
|
65
66
|
|
|
66
67
|
<script>
|
|
67
68
|
import { getContext } from 'svelte'
|
|
@@ -72,9 +73,14 @@
|
|
|
72
73
|
|
|
73
74
|
{
|
|
74
75
|
items = []
|
|
76
|
+
source
|
|
77
|
+
debounce = 200
|
|
78
|
+
minChars = 0
|
|
75
79
|
value = $bindable([])
|
|
76
80
|
open = $bindable(false)
|
|
77
81
|
placeholder = 'Pick options…'
|
|
82
|
+
emptyMessage = 'No matches'
|
|
83
|
+
loadingMessage = 'Searching…'
|
|
78
84
|
disabled = false
|
|
79
85
|
name
|
|
80
86
|
id: providedId
|
|
@@ -98,7 +104,38 @@
|
|
|
98
104
|
inputEl .= $state null
|
|
99
105
|
wrapEl .= $state null
|
|
100
106
|
|
|
107
|
+
// Remote mode: `source(term)` resolves the options for the current input.
|
|
108
|
+
// Labels of everything ever seen are kept in `known`, so chips for values
|
|
109
|
+
// picked from an earlier search still render after the list moved on.
|
|
110
|
+
remote .= $state []
|
|
111
|
+
loading .= $state false
|
|
112
|
+
known := $state {}
|
|
113
|
+
seq .= 0
|
|
114
|
+
|
|
115
|
+
search := async (t) =>
|
|
116
|
+
id := ++seq
|
|
117
|
+
loading = true
|
|
118
|
+
try
|
|
119
|
+
res := await source t
|
|
120
|
+
return unless id === seq
|
|
121
|
+
list := Array.isArray(res) ? res : (res?.data ?? [])
|
|
122
|
+
for it of list
|
|
123
|
+
known[it.value] = it.label
|
|
124
|
+
remote = list
|
|
125
|
+
catch
|
|
126
|
+
remote = [] if id === seq
|
|
127
|
+
finally
|
|
128
|
+
loading = false if id === seq
|
|
129
|
+
|
|
130
|
+
$effect =>
|
|
131
|
+
return unless source && open
|
|
132
|
+
t := term
|
|
133
|
+
return if t.length < minChars
|
|
134
|
+
timer := setTimeout (=> search t), debounce
|
|
135
|
+
return () => clearTimeout timer
|
|
136
|
+
|
|
101
137
|
filtered := $derived.by =>
|
|
138
|
+
return remote if source
|
|
102
139
|
return items unless term
|
|
103
140
|
t := term.toLowerCase()
|
|
104
141
|
items.filter (it) => it.label.toLowerCase().includes(t)
|
|
@@ -106,7 +143,7 @@
|
|
|
106
143
|
$effect =>
|
|
107
144
|
active = 0 if active >= filtered.length
|
|
108
145
|
|
|
109
|
-
labelOf := (v) => items.find((it) => it.value === v)?.label ?? v
|
|
146
|
+
labelOf := (v) => items.find((it) => it.value === v)?.label ?? known[v] ?? v
|
|
110
147
|
|
|
111
148
|
focusInput := => inputEl?.focus()
|
|
112
149
|
|
|
@@ -124,6 +161,7 @@
|
|
|
124
161
|
setTimeout (=> open = false), 100
|
|
125
162
|
|
|
126
163
|
pick := (item) =>
|
|
164
|
+
known[item.value] = item.label
|
|
127
165
|
if value.includes(item.value)
|
|
128
166
|
value = value.filter (v) => v !== item.value
|
|
129
167
|
else
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface ListQueryState {
|
|
2
|
+
page: number;
|
|
3
|
+
pageSize: number;
|
|
4
|
+
sort?: string;
|
|
5
|
+
dir: 'asc' | 'desc';
|
|
6
|
+
q: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ListLoader<T> = (
|
|
10
|
+
qs: string,
|
|
11
|
+
query: ListQueryState
|
|
12
|
+
) => Promise<{ data?: T[]; total?: number } | T[] | null | undefined>;
|
|
13
|
+
|
|
14
|
+
export interface UseListOptions<T> {
|
|
15
|
+
page?: number;
|
|
16
|
+
pageSize?: number;
|
|
17
|
+
sort?: string;
|
|
18
|
+
dir?: 'asc' | 'desc';
|
|
19
|
+
q?: string;
|
|
20
|
+
/** Debounce every reload by this many milliseconds (useful when `q` is bound to an input). */
|
|
21
|
+
debounce?: number;
|
|
22
|
+
/** First page from a `load` function; no request is made until the query changes. */
|
|
23
|
+
initial?: { data?: T[]; total?: number } | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ListState<T> {
|
|
27
|
+
readonly rows: T[];
|
|
28
|
+
readonly total: number;
|
|
29
|
+
readonly loading: boolean;
|
|
30
|
+
readonly error: unknown;
|
|
31
|
+
readonly pages: number;
|
|
32
|
+
readonly params: string;
|
|
33
|
+
page: number;
|
|
34
|
+
pageSize: number;
|
|
35
|
+
sort: string | undefined;
|
|
36
|
+
dir: 'asc' | 'desc';
|
|
37
|
+
q: string;
|
|
38
|
+
refresh(): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Server-driven list state for DataTable + Pagination. Call during component
|
|
43
|
+
* initialisation; every change to page / pageSize / sort / dir / q re-runs
|
|
44
|
+
* `load` with a `listQuery()`-compatible query string.
|
|
45
|
+
*/
|
|
46
|
+
export function useList<T = Record<string, unknown>>(load: ListLoader<T>, opts?: UseListOptions<T>): ListState<T>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-driven list state for DataTable + Pagination (+ a search box).
|
|
3
|
+
*
|
|
4
|
+
* The component owns the page / sort / search state; the server owns the
|
|
5
|
+
* data. Every state change re-runs `load` with the query string a norns
|
|
6
|
+
* `listQuery()` route understands, and the `{ data, total }` envelope from
|
|
7
|
+
* `listResult()` lands in `rows` / `total`.
|
|
8
|
+
*
|
|
9
|
+
* import { useList } from '@human-synthesis/norns-ui/list'
|
|
10
|
+
* api := createApi { schemas: [noteWire] }
|
|
11
|
+
* list := useList (qs) => api.get(`/api/notes?${qs}`), { sort: 'updated_at', dir: 'desc' }
|
|
12
|
+
*
|
|
13
|
+
* Input(bind:value!="{list.q}")
|
|
14
|
+
* DataTable(rows!="{list.rows}" bind:sortKey!="{list.sort}" bind:sortDir!="{list.dir}" loading!="{list.loading}")
|
|
15
|
+
* Pagination(bind:page!="{list.page}" total!="{list.total}" pageSize!="{list.pageSize}")
|
|
16
|
+
*
|
|
17
|
+
* Call it during component initialisation (it registers a `$effect`). With
|
|
18
|
+
* `initial` (data from a `load` function) the first render is SSR-complete
|
|
19
|
+
* and no request is made until something changes. In-flight responses that
|
|
20
|
+
* arrive out of order are dropped.
|
|
21
|
+
*
|
|
22
|
+
* @template T
|
|
23
|
+
* @param {(qs: string, query: { page: number, pageSize: number, sort?: string, dir: 'asc' | 'desc', q: string }) => Promise<{ data?: T[], total?: number } | T[] | null | undefined>} load
|
|
24
|
+
* @param {{
|
|
25
|
+
* page?: number,
|
|
26
|
+
* pageSize?: number,
|
|
27
|
+
* sort?: string,
|
|
28
|
+
* dir?: 'asc' | 'desc',
|
|
29
|
+
* q?: string,
|
|
30
|
+
* debounce?: number,
|
|
31
|
+
* initial?: { data?: T[], total?: number } | null
|
|
32
|
+
* }} [opts]
|
|
33
|
+
*/
|
|
34
|
+
export function useList(load, opts = {}) {
|
|
35
|
+
if (typeof load !== 'function') throw new Error('useList(load): `load` must be a function');
|
|
36
|
+
|
|
37
|
+
const state = $state({
|
|
38
|
+
page: opts.page ?? 1,
|
|
39
|
+
pageSize: opts.pageSize ?? 20,
|
|
40
|
+
sort: opts.sort,
|
|
41
|
+
dir: opts.dir ?? 'asc',
|
|
42
|
+
q: opts.q ?? '',
|
|
43
|
+
rows: /** @type {T[]} */ (opts.initial?.data ?? []),
|
|
44
|
+
total: opts.initial?.total ?? opts.initial?.data?.length ?? 0,
|
|
45
|
+
loading: false,
|
|
46
|
+
error: /** @type {unknown} */ (null)
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
let seq = 0;
|
|
50
|
+
let skipFirst = opts.initial != null;
|
|
51
|
+
const debounce = opts.debounce ?? 0;
|
|
52
|
+
|
|
53
|
+
function snapshot() {
|
|
54
|
+
return { page: state.page, pageSize: state.pageSize, sort: state.sort, dir: state.dir, q: state.q };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function params(query = snapshot()) {
|
|
58
|
+
const p = new URLSearchParams();
|
|
59
|
+
p.set('page', String(query.page));
|
|
60
|
+
p.set('pageSize', String(query.pageSize));
|
|
61
|
+
if (query.sort) {
|
|
62
|
+
p.set('sort', query.sort);
|
|
63
|
+
p.set('dir', query.dir);
|
|
64
|
+
}
|
|
65
|
+
if (query.q) p.set('q', query.q);
|
|
66
|
+
return p.toString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function refresh() {
|
|
70
|
+
const id = ++seq;
|
|
71
|
+
const query = snapshot();
|
|
72
|
+
state.loading = true;
|
|
73
|
+
state.error = null;
|
|
74
|
+
try {
|
|
75
|
+
const res = await load(params(query), query);
|
|
76
|
+
if (id !== seq) return;
|
|
77
|
+
const rows = Array.isArray(res) ? res : (res?.data ?? []);
|
|
78
|
+
state.rows = rows;
|
|
79
|
+
state.total = Array.isArray(res) ? rows.length : (res?.total ?? rows.length);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (id !== seq) return;
|
|
82
|
+
state.error = e;
|
|
83
|
+
} finally {
|
|
84
|
+
if (id === seq) state.loading = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
$effect(() => {
|
|
89
|
+
// Reading through params() subscribes to every query field.
|
|
90
|
+
params();
|
|
91
|
+
if (skipFirst) {
|
|
92
|
+
skipFirst = false;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (debounce > 0) {
|
|
96
|
+
const timer = setTimeout(refresh, debounce);
|
|
97
|
+
return () => clearTimeout(timer);
|
|
98
|
+
}
|
|
99
|
+
refresh();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
get rows() { return state.rows; },
|
|
104
|
+
get total() { return state.total; },
|
|
105
|
+
get loading() { return state.loading; },
|
|
106
|
+
get error() { return state.error; },
|
|
107
|
+
get pages() { return Math.max(1, Math.ceil(state.total / state.pageSize)); },
|
|
108
|
+
/** The query string as sent to `load` (useful for links / debugging). */
|
|
109
|
+
get params() { return params(); },
|
|
110
|
+
|
|
111
|
+
get page() { return state.page; },
|
|
112
|
+
set page(v) { state.page = Math.max(1, Number(v) || 1); },
|
|
113
|
+
|
|
114
|
+
get pageSize() { return state.pageSize; },
|
|
115
|
+
set pageSize(v) {
|
|
116
|
+
const n = Math.max(1, Number(v) || 1);
|
|
117
|
+
if (n === state.pageSize) return;
|
|
118
|
+
state.pageSize = n;
|
|
119
|
+
state.page = 1;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
get sort() { return state.sort; },
|
|
123
|
+
set sort(v) {
|
|
124
|
+
if (v === state.sort) return;
|
|
125
|
+
state.sort = v;
|
|
126
|
+
state.page = 1;
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
get dir() { return state.dir; },
|
|
130
|
+
set dir(v) { state.dir = v === 'desc' ? 'desc' : 'asc'; },
|
|
131
|
+
|
|
132
|
+
get q() { return state.q; },
|
|
133
|
+
set q(v) {
|
|
134
|
+
const s = String(v ?? '');
|
|
135
|
+
if (s === state.q) return;
|
|
136
|
+
state.q = s;
|
|
137
|
+
state.page = 1;
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
/** Re-run `load` with the current query (e.g. after a mutation). */
|
|
141
|
+
refresh
|
|
142
|
+
};
|
|
143
|
+
}
|
package/src/styles/atoms.css
CHANGED
|
@@ -1603,6 +1603,7 @@
|
|
|
1603
1603
|
cursor: pointer;
|
|
1604
1604
|
}
|
|
1605
1605
|
.data-table-sort:hover { color: var(--color-primary-600); }
|
|
1606
|
+
.data-table-loading tbody { @apply opacity-60 transition-opacity; }
|
|
1606
1607
|
|
|
1607
1608
|
/* ============================================================ */
|
|
1608
1609
|
/* Tree */
|
|
@@ -2,11 +2,23 @@ import type { Component } from 'svelte';
|
|
|
2
2
|
|
|
3
3
|
export type ComboboxItem = { value: string; label: string };
|
|
4
4
|
|
|
5
|
+
/** Remote option source: resolves the items for the current input, or a `{ data }` envelope. */
|
|
6
|
+
export type ComboboxSource = (term: string) => Promise<ComboboxItem[] | { data?: ComboboxItem[] } | null | undefined> | ComboboxItem[];
|
|
7
|
+
|
|
5
8
|
export type AutocompleteProps = {
|
|
9
|
+
/** Local options, filtered client-side by label. Ignored when `source` is set. */
|
|
6
10
|
items?: ComboboxItem[];
|
|
11
|
+
/** Remote options: called (debounced) with the typed text while the list is open. */
|
|
12
|
+
source?: ComboboxSource;
|
|
13
|
+
/** Milliseconds to wait after typing before calling `source`. Default 200. */
|
|
14
|
+
debounce?: number;
|
|
15
|
+
/** Minimum typed characters before `source` is called. Default 0. */
|
|
16
|
+
minChars?: number;
|
|
7
17
|
value?: string;
|
|
8
18
|
open?: boolean;
|
|
9
19
|
placeholder?: string;
|
|
20
|
+
emptyMessage?: string;
|
|
21
|
+
loadingMessage?: string;
|
|
10
22
|
disabled?: boolean;
|
|
11
23
|
name?: string;
|
|
12
24
|
id?: string;
|
package/src/types/DataTable.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export type DataTableColumn = {
|
|
|
7
7
|
class?: string;
|
|
8
8
|
cellClass?: string;
|
|
9
9
|
sortable?: boolean;
|
|
10
|
+
/** Render the cell value (e.g. a date or number formatter). */
|
|
11
|
+
format?: (value: unknown, row: Record<string, unknown>) => string | number;
|
|
10
12
|
};
|
|
11
13
|
|
|
12
14
|
export type DataTableProps = {
|
|
@@ -15,8 +17,14 @@ export type DataTableProps = {
|
|
|
15
17
|
striped?: boolean;
|
|
16
18
|
dense?: boolean;
|
|
17
19
|
stickyHeader?: boolean;
|
|
20
|
+
/** Dims the table and swaps the empty message while a page is being fetched (see `useList()`). */
|
|
21
|
+
loading?: boolean;
|
|
18
22
|
emptyMessage?: string;
|
|
19
|
-
|
|
23
|
+
loadingMessage?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Currently-sorted column key. Bindable. The table only flips these two;
|
|
26
|
+
* sort `rows` yourself or bind them to `useList()` for server-side ordering.
|
|
27
|
+
*/
|
|
20
28
|
sortKey?: string;
|
|
21
29
|
sortDir?: 'asc' | 'desc';
|
|
22
30
|
onrowclick?: (row: Record<string, unknown>, index: number) => void;
|
package/src/types/Form.d.ts
CHANGED
|
@@ -22,6 +22,22 @@ export type FormProps = Omit<HTMLFormAttributes, 'class' | 'children'> & {
|
|
|
22
22
|
* up its own error automatically.
|
|
23
23
|
*/
|
|
24
24
|
form?: FormActionResult;
|
|
25
|
+
/**
|
|
26
|
+
* API mode. Intercepts the submit, collects the fields into a plain object
|
|
27
|
+
* and calls this instead of posting the form — typically
|
|
28
|
+
* `(values) => api.post('/api/notes', values)` against a norns `route()`.
|
|
29
|
+
* A rejection whose `body.issues` (or `issues`) is a valibot issue list is
|
|
30
|
+
* mapped into the same errors context as `form`.
|
|
31
|
+
*/
|
|
32
|
+
submit?: (values: Record<string, FormDataEntryValue>, event: SubmitEvent) => Promise<unknown> | unknown;
|
|
33
|
+
/** Called with the resolved value of `submit`. */
|
|
34
|
+
onsuccess?: (result: unknown, values: Record<string, FormDataEntryValue>) => void;
|
|
35
|
+
/** Called when `submit` rejects (after the errors map was updated). */
|
|
36
|
+
onerror?: (error: unknown) => void;
|
|
37
|
+
/** True while `submit` is pending. Bindable. */
|
|
38
|
+
submitting?: boolean;
|
|
39
|
+
/** API mode: clear the fields after `submit` resolves. */
|
|
40
|
+
resetOnSuccess?: boolean;
|
|
25
41
|
class?: string;
|
|
26
42
|
children?: Snippet;
|
|
27
43
|
};
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import type { Component } from 'svelte';
|
|
2
|
-
import type { ComboboxItem } from './Autocomplete';
|
|
2
|
+
import type { ComboboxItem, ComboboxSource } from './Autocomplete';
|
|
3
3
|
|
|
4
4
|
export type MultiSelectProps = {
|
|
5
|
+
/** Local options, filtered client-side by label. Ignored when `source` is set. */
|
|
5
6
|
items?: ComboboxItem[];
|
|
7
|
+
/** Remote options: called (debounced) with the typed text while the list is open. Picked labels are remembered across searches. */
|
|
8
|
+
source?: ComboboxSource;
|
|
9
|
+
/** Milliseconds to wait after typing before calling `source`. Default 200. */
|
|
10
|
+
debounce?: number;
|
|
11
|
+
/** Minimum typed characters before `source` is called. Default 0. */
|
|
12
|
+
minChars?: number;
|
|
6
13
|
value?: string[];
|
|
7
14
|
open?: boolean;
|
|
8
15
|
placeholder?: string;
|
|
16
|
+
emptyMessage?: string;
|
|
17
|
+
loadingMessage?: string;
|
|
9
18
|
disabled?: boolean;
|
|
10
19
|
name?: string;
|
|
11
20
|
id?: string;
|