@human-synthesis/norns-ui 0.0.12 → 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 +1462 -0
- package/README.md +34 -3
- package/package.json +9 -5
- 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 +2 -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/src/types/Window.d.ts +22 -0
package/README.md
CHANGED
|
@@ -98,16 +98,47 @@ 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.
|
|
104
132
|
- **Forms**: `Btn`, `Form`, `Field`, `FieldGroup`, `Input`, `Textarea`, `Select`, `Checkbox`, `Radio`, `Switch`.
|
|
105
133
|
- **Behavior** (custom on `@floating-ui/dom`): `Dialog`, `Sheet`, `Popover`, `Dropdown`, `Tooltip`, `Tabs`, `Accordion`, `ContextMenu`, `Collapsible`.
|
|
106
|
-
- **Display**: `Card`, `Surface`, `Banner`, `Badge`, `Chip`, `Avatar`, `Skeleton`, `Progress`, `ProgressCircular`, `Icon`.
|
|
107
|
-
- **Composite**: `Header`, `HeroBanner`, `Stepper`, `Breadcrumbs`, `Pagination`, `Carousel`.
|
|
134
|
+
- **Display**: `Card`, `Window`, `Surface`, `Banner`, `Badge`, `Chip`, `Avatar`, `AvatarGroup`, `Skeleton`, `Progress`, `ProgressCircular`, `Icon`, `Image`, `Audio`, `Video`, `Timeline`, `GradientText`.
|
|
135
|
+
- **Composite**: `Header`, `HeroBanner`, `Stepper`, `Breadcrumbs`, `Pagination`, `Carousel`, `Tree`, `Toolbar`, `Separator`, `ButtonGroup`, `ToggleButton`, `ToggleButtonGroup`, `HierarchicalMenu`, `MegaMenu`.
|
|
136
|
+
- **Inputs**: `NumberInput`, `OtpField`, `TagsInput`, `Autocomplete`, `MultiSelect`, `ColorPicker`, `Uploader`, `DatePicker`, `DateRangePicker`, `TimePicker`, `Calendar`, `DataTable`, `ScrollArea`, `CopyButton`, `ThemeToggler`, `ShinyButton`, `RippleButton`.
|
|
108
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`.
|
|
139
|
+
- **Motion** (opt-in, `@human-synthesis/norns-ui/motion`): `AnimatedNumber`, `GradientBackground`, `LiquidButton`, `Reveal`, `Sparkles`.
|
|
109
140
|
|
|
110
|
-
|
|
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.
|
|
111
142
|
|
|
112
143
|
## Behaviors
|
|
113
144
|
|
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)",
|
|
@@ -11,12 +11,15 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"src",
|
|
14
|
-
"README.md"
|
|
14
|
+
"README.md",
|
|
15
|
+
"COMPONENTS.md"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
18
|
"test": "bun test",
|
|
18
19
|
"check:shims": "bun scripts/check-shims.mjs",
|
|
19
|
-
"check:shims:strict": "bun scripts/check-shims.mjs --strict"
|
|
20
|
+
"check:shims:strict": "bun scripts/check-shims.mjs --strict",
|
|
21
|
+
"docs:components": "bun scripts/gen-components-doc.mjs",
|
|
22
|
+
"docs:components:check": "bun scripts/gen-components-doc.mjs --check"
|
|
20
23
|
},
|
|
21
24
|
"exports": {
|
|
22
25
|
".": "./src/index.js",
|
|
@@ -25,6 +28,7 @@
|
|
|
25
28
|
"./behaviors": "./src/lib/behaviors/index.js",
|
|
26
29
|
"./behaviors/*": "./src/lib/behaviors/*",
|
|
27
30
|
"./toast": "./src/lib/toast.svelte.js",
|
|
31
|
+
"./list": "./src/lib/list.svelte.js",
|
|
28
32
|
"./styles": "./src/styles/index.css",
|
|
29
33
|
"./styles/tokens": "./src/styles/tokens.css",
|
|
30
34
|
"./styles/atoms": "./src/styles/atoms.css",
|
|
@@ -34,8 +38,8 @@
|
|
|
34
38
|
"./package.json": "./package.json"
|
|
35
39
|
},
|
|
36
40
|
"peerDependencies": {
|
|
37
|
-
"@human-synthesis/norns": "^0.0.
|
|
38
|
-
"@human-synthesis/norns-core": "^0.0.
|
|
41
|
+
"@human-synthesis/norns": "^0.0.19",
|
|
42
|
+
"@human-synthesis/norns-core": "^0.0.11",
|
|
39
43
|
"svelte": "^5.0.0",
|
|
40
44
|
"tailwindcss": "^4.0.0"
|
|
41
45
|
},
|
|
@@ -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
|
@@ -33,6 +33,7 @@ export { default as ToastProvider } from './components/ToastProvider.n';
|
|
|
33
33
|
// 0.0.4 — display layer
|
|
34
34
|
export { default as Icon } from './components/Icon.n';
|
|
35
35
|
export { default as Card } from './components/Card.n';
|
|
36
|
+
export { default as Window } from './components/Window.n';
|
|
36
37
|
export { default as Surface } from './components/Surface.n';
|
|
37
38
|
export { default as Banner } from './components/Banner.n';
|
|
38
39
|
export { default as Badge } from './components/Badge.n';
|
|
@@ -98,3 +99,4 @@ export { cn } from './lib/cn.js';
|
|
|
98
99
|
export { variantClasses } from './lib/variants.js';
|
|
99
100
|
export { presetUI } from './auto-import.js';
|
|
100
101
|
export { toast, notify, dismiss, clear } from './lib/toast.svelte.js';
|
|
102
|
+
export { useList } from './lib/list.svelte.js';
|
|
@@ -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;
|