@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/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
- Hand-rolled `.d.ts` shims live under `src/types/`. Override any component by dropping `src/lib/components/<Name>.n` in your project — `nornsAutoImport`'s first-match-wins shadows the library silently.
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.12",
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.17",
38
- "@human-synthesis/norns-core": "^0.0.10",
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="lucide:chevrons-up-down" size="size-4")
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 No matches
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 := items.find (it) => it.value === value
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'
@@ -3,7 +3,8 @@ form(
3
3
  action!="{action}"
4
4
  enctype!="{enctype}"
5
5
  class!="{classes}"
6
- onsubmit!="{onsubmit}"
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
- } := $props()
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
- if form?.errors
34
- for issue of form.errors
35
- name := issue.path?.[0]?.key
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="lucide:chevrons-up-down" size="size-4")
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 No matches
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
+ }
@@ -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;
@@ -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
- /** Currently-sorted column key. Bindable. */
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;