@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 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
- /** Currently-sorted column key. Bindable. */
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.13",
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.18",
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="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
@@ -99,3 +99,4 @@ export { cn } from './lib/cn.js';
99
99
  export { variantClasses } from './lib/variants.js';
100
100
  export { presetUI } from './auto-import.js';
101
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;
@@ -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;