@human-synthesis/norns-ui 0.0.11 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@human-synthesis/norns-ui",
3
- "version": "0.0.11",
3
+ "version": "0.1.0",
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)",
@@ -29,12 +29,13 @@
29
29
  "./styles/tokens": "./src/styles/tokens.css",
30
30
  "./styles/atoms": "./src/styles/atoms.css",
31
31
  "./components/*": "./src/components/*",
32
+ "./contracts": "./src/contracts.js",
32
33
  "./motion": "./src/motion/index.js",
33
34
  "./motion/*": "./src/motion/*",
34
35
  "./package.json": "./package.json"
35
36
  },
36
37
  "peerDependencies": {
37
- "@human-synthesis/norns": "^0.0.16",
38
+ "@human-synthesis/norns": "^0.1.0",
38
39
  "@human-synthesis/norns-core": "^0.0.10",
39
40
  "svelte": "^5.0.0",
40
41
  "tailwindcss": "^4.0.0"
@@ -49,6 +50,7 @@
49
50
  },
50
51
  "dependencies": {
51
52
  "@floating-ui/dom": "^1.7.6",
53
+ "valibot": "^1.4.0",
52
54
  "@fontsource-variable/outfit": "^5.2.8",
53
55
  "@iconify-json/lucide": "^1.2.105",
54
56
  "@iconify/svelte": "^5.2.1",
@@ -63,6 +63,7 @@ export function presetUI() {
63
63
  Avatar: c('Avatar'),
64
64
  Skeleton: c('Skeleton'),
65
65
  Progress: c('Progress'),
66
+ Spinner: c('Spinner'),
66
67
  ProgressCircular: c('ProgressCircular'),
67
68
 
68
69
  // 0.0.4 — composite layer
@@ -106,6 +107,15 @@ export function presetUI() {
106
107
  TimePicker: c('TimePicker'),
107
108
  Calendar: c('Calendar'),
108
109
  DataTable: c('DataTable'),
110
+ Table: c('Table'),
111
+
112
+ // U-03 — data views
113
+ Kanban: c('Kanban'),
114
+ Chart: c('Chart'),
115
+
116
+ // U-04 — data tier (Combobox is Autocomplete's ARIA name; same component)
117
+ Listbox: c('Listbox'),
118
+ Combobox: c('Autocomplete'),
109
119
 
110
120
  // 0.0.5 — CSS-only motion components
111
121
  AvatarGroup: c('AvatarGroup'),
@@ -0,0 +1,129 @@
1
+ figure.chart-root(class!="{rootClasses}")
2
+ svg(viewBox!="{`0 0 ${W} ${H}`}" role="img" aria-label!="{label}" preserveAspectRatio="none")
3
+ line.chart-axis(x1!="{pad}" y1!="{H - pad}" x2!="{W - pad}" y2!="{H - pad}")
4
+ +if('type === "bar"')
5
+ +each('points as p (p.i)')
6
+ rect.chart-bar(
7
+ x!="{p.x - barW / 2}"
8
+ y!="{p.y}"
9
+ width!="{barW}"
10
+ height!="{H - pad - p.y}"
11
+ )
12
+ +if('type === "line" || type === "area"')
13
+ +if('type === "area"')
14
+ path.chart-area(d!="{areaPath}")
15
+ path.chart-line(d!="{linePath}")
16
+ +each('points as p (p.i)')
17
+ circle.chart-dot(cx!="{p.x}" cy!="{p.y}" r="2.5")
18
+ +if('showLabels')
19
+ .chart-labels
20
+ +each('points as p (p.i)')
21
+ span.chart-label {p.label}
22
+
23
+ <script>
24
+ import { cn } from '@human-synthesis/norns-ui/cn'
25
+
26
+ {
27
+ data = []
28
+ type = 'bar'
29
+ x = undefined
30
+ y = undefined
31
+ label = 'chart'
32
+ labels = true
33
+ class: extra = ''
34
+ } .= $props()
35
+
36
+ W := 600
37
+ H := 200
38
+ pad := 8
39
+
40
+ rootClasses := $derived cn('chart', `chart-${type}`, extra)
41
+
42
+ rows := $derived Array.isArray(data) ? data : []
43
+
44
+ xKey := $derived.by => {
45
+ return x if x
46
+ const first = rows[0] ?? {}
47
+ Object.keys(first).find((k) => typeof first[k] !== 'number') ?? Object.keys(first)[0]
48
+ }
49
+
50
+ yKey := $derived.by => {
51
+ return y if y
52
+ const first = rows[0] ?? {}
53
+ Object.keys(first).find((k) => typeof first[k] === 'number' && k !== xKey)
54
+ }
55
+
56
+ values := $derived rows.map((row) => Number(row?.[yKey]) || 0)
57
+ maxV := $derived Math.max(1, ...values)
58
+
59
+ points := $derived.by => {
60
+ const n = rows.length
61
+ return [] if n === 0
62
+ const span = W - pad * 2
63
+ const step = n > 1 ? span / (n - 1) : 0
64
+ rows.map (row, i) =>
65
+ const v = Number(row?.[yKey]) || 0
66
+ {
67
+ i
68
+ x: n > 1 ? pad + step * i : W / 2
69
+ y: H - pad - (v / maxV) * (H - pad * 2)
70
+ v
71
+ label: String(row?.[xKey] ?? i + 1)
72
+ }
73
+ }
74
+
75
+ barW := $derived.by => {
76
+ const n = Math.max(1, points.length)
77
+ Math.max(4, Math.min(48, ((W - pad * 2) / n) * 0.6))
78
+ }
79
+
80
+ linePath := $derived points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ')
81
+
82
+ areaPath := $derived.by => {
83
+ return '' if points.length === 0
84
+ const first = points[0]
85
+ const last = points[points.length - 1]
86
+ `${linePath} L${last.x},${H - pad} L${first.x},${H - pad} Z`
87
+ }
88
+
89
+ showLabels := $derived labels !== false && labels !== 'false' && points.length > 0
90
+ </script>
91
+
92
+ <style>
93
+ .chart-root {
94
+ display: grid;
95
+ gap: 0.25rem;
96
+ margin: 0;
97
+ }
98
+ svg {
99
+ width: 100%;
100
+ height: auto;
101
+ display: block;
102
+ }
103
+ .chart-axis {
104
+ stroke: var(--color-border, color-mix(in oklab, currentColor 20%, transparent));
105
+ stroke-width: 1;
106
+ }
107
+ .chart-bar {
108
+ fill: var(--color-accent, currentColor);
109
+ opacity: 0.85;
110
+ }
111
+ .chart-line {
112
+ fill: none;
113
+ stroke: var(--color-accent, currentColor);
114
+ stroke-width: 2;
115
+ }
116
+ .chart-area {
117
+ fill: var(--color-accent, currentColor);
118
+ opacity: 0.15;
119
+ }
120
+ .chart-dot {
121
+ fill: var(--color-accent, currentColor);
122
+ }
123
+ .chart-labels {
124
+ display: flex;
125
+ justify-content: space-between;
126
+ font-size: 0.6875rem;
127
+ opacity: 0.7;
128
+ }
129
+ </style>
@@ -0,0 +1,181 @@
1
+ .kanban-root(class!="{rootClasses}")
2
+ +each('cols as col (col.key)')
3
+ section.kanban-column(
4
+ class!="{dropTarget === col.key ? 'kanban-column-active' : ''}"
5
+ ondragover!="{(e) => onDragOver(e, col.key)}"
6
+ ondragleave!="{() => onDragLeave(col.key)}"
7
+ ondrop!="{(e) => onDrop(e, col.key)}"
8
+ )
9
+ header.kanban-column-header
10
+ span.kanban-column-title {col.label}
11
+ span.kanban-column-count {col.cards.length}
12
+ .kanban-cards
13
+ +if('col.cards.length === 0')
14
+ .kanban-empty {emptyMessage}
15
+ +each('col.cards as card, i (cardId(card) ?? i)')
16
+ article.kanban-card(
17
+ draggable!="{movable}"
18
+ ondragstart!="{(e) => onDragStart(e, card, col.key)}"
19
+ onclick!="{oncardclick ? () => oncardclick(card) : undefined}"
20
+ class!="{oncardclick ? 'kanban-card-clickable' : ''}"
21
+ )
22
+ .kanban-card-title {titleOf(card)}
23
+ +if('subtitle && card[subtitle] != null')
24
+ .kanban-card-subtitle {card[subtitle]}
25
+
26
+ <script>
27
+ import { cn } from '@human-synthesis/norns-ui/cn'
28
+
29
+ {
30
+ data = {}
31
+ columns = undefined
32
+ title = undefined
33
+ subtitle = undefined
34
+ onMove = undefined
35
+ oncardclick = undefined
36
+ emptyMessage = 'No cards'
37
+ class: extra = ''
38
+ } .= $props()
39
+
40
+ // local overlay so a drop lands instantly; server truth wins on reload
41
+ moves .= $state({})
42
+
43
+ movable := $derived onMove !== undefined
44
+ rootClasses := $derived cn('kanban', extra)
45
+
46
+ labelOf := (key) => {
47
+ const spaced = String(key).replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2')
48
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1)
49
+ }
50
+
51
+ cardId := (card) => card?.id ?? card?.key ?? undefined
52
+
53
+ titleOf := (card) => {
54
+ if title then return card?.[title]
55
+ card?.title ?? card?.subject ?? card?.name ?? cardId(card) ?? ''
56
+ }
57
+
58
+ grouped := $derived (data && typeof data === 'object' && !Array.isArray(data)) ? data : {}
59
+
60
+ keys := $derived.by => {
61
+ const source = columns ?? Object.keys(grouped)
62
+ return typeof source === 'string'
63
+ ? source.split(',').map((s) => s.trim()).filter(Boolean)
64
+ : source
65
+ }
66
+
67
+ cols := $derived.by => {
68
+ const out = keys.map (key) =>
69
+ { key, label: labelOf(key), cards: [] }
70
+ const byKey = new Map(out.map((c) => [c.key, c]))
71
+ for (const [key, cards] of Object.entries(grouped))
72
+ for (const card of Array.isArray(cards) ? cards : [])
73
+ const to = moves[cardId(card)] ?? key
74
+ byKey.get(to)?.cards.push(card)
75
+ out
76
+ }
77
+
78
+ dragged .= $state(undefined)
79
+ dropTarget .= $state(undefined)
80
+
81
+ onDragStart := (e, card, from) =>
82
+ dragged = { card, from }
83
+ e.dataTransfer?.setData('text/plain', String(cardId(card) ?? ''))
84
+ if e.dataTransfer then e.dataTransfer.effectAllowed = 'move'
85
+
86
+ onDragOver := (e, key) =>
87
+ return unless movable and dragged
88
+ e.preventDefault()
89
+ dropTarget = key
90
+
91
+ onDragLeave := (key) =>
92
+ if dropTarget === key then dropTarget = undefined
93
+
94
+ submitMove := (card, to) =>
95
+ if typeof onMove is 'function'
96
+ onMove(card, to)
97
+ else if typeof onMove is 'string'
98
+ // generated pages bind onMove to a "?/action" form-action URL; the
99
+ // action's input contract is strict, so only `id` goes over the wire —
100
+ // the transition itself is what the bound action means by "move"
101
+ const body = new FormData()
102
+ body.set('id', String(cardId(card) ?? ''))
103
+ fetch(onMove, { method: 'POST', body }).catch(=> undefined)
104
+
105
+ onDrop := (e, to) =>
106
+ dropTarget = undefined
107
+ return unless dragged
108
+ e.preventDefault()
109
+ { card, from } := dragged
110
+ dragged = undefined
111
+ return if from === to
112
+ moves = { ...moves, [cardId(card)]: to }
113
+ submitMove(card, to)
114
+ </script>
115
+
116
+ <style>
117
+ .kanban-root {
118
+ display: grid;
119
+ grid-auto-flow: column;
120
+ grid-auto-columns: minmax(14rem, 1fr);
121
+ gap: var(--spacing-3, 0.75rem);
122
+ align-items: start;
123
+ overflow-x: auto;
124
+ }
125
+ .kanban-column {
126
+ background: var(--color-surface-2, color-mix(in oklab, currentColor 4%, transparent));
127
+ border-radius: var(--radius-lg, 0.5rem);
128
+ padding: var(--spacing-2, 0.5rem);
129
+ display: grid;
130
+ gap: var(--spacing-2, 0.5rem);
131
+ }
132
+ .kanban-column-active {
133
+ outline: 2px dashed var(--color-accent, currentColor);
134
+ outline-offset: -2px;
135
+ }
136
+ .kanban-column-header {
137
+ display: flex;
138
+ align-items: center;
139
+ justify-content: space-between;
140
+ font-size: 0.8125rem;
141
+ font-weight: 600;
142
+ padding-inline: var(--spacing-1, 0.25rem);
143
+ }
144
+ .kanban-column-count {
145
+ opacity: 0.6;
146
+ font-variant-numeric: tabular-nums;
147
+ }
148
+ .kanban-cards {
149
+ display: grid;
150
+ gap: var(--spacing-2, 0.5rem);
151
+ min-height: 2rem;
152
+ }
153
+ .kanban-card {
154
+ background: var(--color-surface, canvas);
155
+ border: 1px solid var(--color-border, color-mix(in oklab, currentColor 12%, transparent));
156
+ border-radius: var(--radius-md, 0.375rem);
157
+ padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
158
+ display: grid;
159
+ gap: 0.125rem;
160
+ }
161
+ .kanban-card[draggable='true'] {
162
+ cursor: grab;
163
+ }
164
+ .kanban-card-clickable {
165
+ cursor: pointer;
166
+ }
167
+ .kanban-card-title {
168
+ font-size: 0.875rem;
169
+ font-weight: 500;
170
+ }
171
+ .kanban-card-subtitle {
172
+ font-size: 0.75rem;
173
+ opacity: 0.7;
174
+ }
175
+ .kanban-empty {
176
+ font-size: 0.75rem;
177
+ opacity: 0.6;
178
+ padding: var(--spacing-2, 0.5rem);
179
+ text-align: center;
180
+ }
181
+ </style>
@@ -0,0 +1,141 @@
1
+ ul.listbox(
2
+ role="listbox"
3
+ tabindex="0"
4
+ aria-multiselectable!="{multiple ? 'true' : undefined}"
5
+ aria-activedescendant!="{items.length > 0 ? optionId(active) : undefined}"
6
+ aria-disabled!="{disabled ? 'true' : undefined}"
7
+ class!="{rootClasses}"
8
+ onkeydown!="{onKeyDown}"
9
+ )
10
+ +if('normalized.length === 0')
11
+ li.listbox-empty {emptyMessage}
12
+ +each('normalized as item, i (item.value)')
13
+ li.listbox-option(
14
+ role="option"
15
+ id!="{optionId(i)}"
16
+ aria-selected!="{isSelected(item.value)}"
17
+ aria-disabled!="{item.disabled ? 'true' : undefined}"
18
+ data-active!="{i === active ? 'true' : undefined}"
19
+ onpointerdown!="{(e) => { e.preventDefault(); pick(item, i) }}"
20
+ )
21
+ Icon(name="lucide:check" size="size-3.5" class!="{isSelected(item.value) ? '' : 'opacity-0'}")
22
+ span {item.label}
23
+
24
+ <script>
25
+ import { cn } from '@human-synthesis/norns-ui/cn'
26
+ import Icon from './Icon.n'
27
+
28
+ {
29
+ items = []
30
+ value = $bindable(undefined)
31
+ multiple = false
32
+ disabled = false
33
+ emptyMessage = 'No options'
34
+ onchange = undefined
35
+ class: extra = ''
36
+ } .= $props()
37
+
38
+ uid := Math.random().toString(36).slice(2, 8)
39
+ optionId := (i) => `listbox-${uid}-${i}`
40
+
41
+ normalized := $derived items.map (it) =>
42
+ typeof it === 'object' and it !== null ? it : { value: it, label: String(it) }
43
+
44
+ selected := $derived.by =>
45
+ return new Set(Array.isArray(value) ? value : []) if multiple
46
+ new Set(value === undefined ? [] : [value])
47
+
48
+ isSelected := (v) => selected.has(v)
49
+
50
+ active .= $state 0
51
+
52
+ pick := (item, i) =>
53
+ return if disabled or item.disabled
54
+ active = i
55
+ if multiple
56
+ current := Array.isArray(value) ? value : []
57
+ value = isSelected(item.value)
58
+ ? current.filter (v) => v !== item.value
59
+ : [...current, item.value]
60
+ else
61
+ value = item.value
62
+ onchange?(value)
63
+
64
+ step := (delta) =>
65
+ return if normalized.length === 0
66
+ next .= active
67
+ loop
68
+ next = Math.min(normalized.length - 1, Math.max(0, next + delta))
69
+ break unless normalized[next]?.disabled and next > 0 and next < normalized.length - 1
70
+ active = next
71
+
72
+ onKeyDown := (e) =>
73
+ return if disabled
74
+ switch e.key
75
+ when 'ArrowDown'
76
+ e.preventDefault()
77
+ step 1
78
+ when 'ArrowUp'
79
+ e.preventDefault()
80
+ step -1
81
+ when 'Home'
82
+ e.preventDefault()
83
+ active = 0
84
+ when 'End'
85
+ e.preventDefault()
86
+ active = Math.max(0, normalized.length - 1)
87
+ when 'Enter', ' '
88
+ e.preventDefault()
89
+ item := normalized[active]
90
+ pick(item, active) if item
91
+
92
+ rootClasses := $derived cn('listbox-root', disabled && 'listbox-disabled', extra)
93
+ </script>
94
+
95
+ <style>
96
+ .listbox {
97
+ display: grid;
98
+ gap: 1px;
99
+ margin: 0;
100
+ padding: var(--spacing-1, 0.25rem);
101
+ list-style: none;
102
+ border: 1px solid var(--color-border, color-mix(in oklab, currentColor 12%, transparent));
103
+ border-radius: var(--radius-md, 0.375rem);
104
+ max-height: 16rem;
105
+ overflow-y: auto;
106
+ }
107
+ .listbox:focus-visible {
108
+ outline: 2px solid var(--color-accent, currentColor);
109
+ outline-offset: 1px;
110
+ }
111
+ .listbox-option {
112
+ display: flex;
113
+ align-items: center;
114
+ gap: var(--spacing-2, 0.5rem);
115
+ padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);
116
+ border-radius: var(--radius-sm, 0.25rem);
117
+ font-size: 0.875rem;
118
+ cursor: pointer;
119
+ user-select: none;
120
+ }
121
+ .listbox-option[data-active='true'] {
122
+ background: color-mix(in oklab, currentColor 8%, transparent);
123
+ }
124
+ .listbox-option[aria-selected='true'] {
125
+ font-weight: 500;
126
+ }
127
+ .listbox-option[aria-disabled='true'] {
128
+ opacity: 0.5;
129
+ cursor: not-allowed;
130
+ }
131
+ .listbox-disabled {
132
+ opacity: 0.6;
133
+ pointer-events: none;
134
+ }
135
+ .listbox-empty {
136
+ font-size: 0.8125rem;
137
+ opacity: 0.6;
138
+ padding: var(--spacing-2, 0.5rem);
139
+ text-align: center;
140
+ }
141
+ </style>
@@ -0,0 +1,13 @@
1
+ span.ui-spinner(role="status" aria-label!="{label}" class!="{classes}")
2
+
3
+ <script>
4
+ import { cn } from '@human-synthesis/norns-ui/cn'
5
+
6
+ {
7
+ size = 'size-4'
8
+ label = 'Loading'
9
+ class: extra = ''
10
+ } .= $props()
11
+
12
+ classes := cn size, extra
13
+ </script>
@@ -0,0 +1,81 @@
1
+ .table-root(class!="{rootClasses}")
2
+ DataTable(
3
+ columns!="{cols}"
4
+ rows!="{pageRows}"
5
+ striped!="{striped}"
6
+ dense!="{dense}"
7
+ stickyHeader!="{stickyHeader}"
8
+ emptyMessage!="{emptyMessage}"
9
+ onrowclick!="{onrowclick}"
10
+ bind:sortKey
11
+ bind:sortDir
12
+ )
13
+ +if('showFooter')
14
+ .table-footer
15
+ span.table-total {total} {total === 1 ? 'row' : 'rows'}
16
+ Pagination(bind:page total!="{total}" pageSize!="{size}")
17
+
18
+ <script>
19
+ import { cn } from '@human-synthesis/norns-ui/cn'
20
+ import DataTable from './DataTable.n'
21
+ import Pagination from './Pagination.n'
22
+
23
+ {
24
+ data = []
25
+ columns = undefined
26
+ pageSize = 20
27
+ striped = false
28
+ dense = false
29
+ stickyHeader = false
30
+ emptyMessage = 'No data'
31
+ onrowclick
32
+ class: extra = ''
33
+ } .= $props()
34
+
35
+ sortKey .= $state(undefined)
36
+ sortDir .= $state('asc')
37
+ page .= $state(1)
38
+
39
+ rows := $derived Array.isArray(data) ? data : []
40
+ size := $derived Math.max(0, Number(pageSize) || 0)
41
+
42
+ labelOf := (key) => {
43
+ const spaced = String(key).replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2')
44
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1)
45
+ }
46
+
47
+ cols := $derived.by => {
48
+ const source = columns ?? Object.keys(rows[0] ?? {})
49
+ const list = typeof source === 'string'
50
+ ? source.split(',').map((s) => s.trim()).filter(Boolean)
51
+ : source
52
+ return list.map((col) =>
53
+ typeof col === 'string'
54
+ ? { key: col, label: labelOf(col), sortable: true }
55
+ : { sortable: true, ...col }
56
+ )
57
+ }
58
+
59
+ sorted := $derived.by => {
60
+ if (!sortKey) return rows
61
+ const key = sortKey
62
+ const dir = sortDir === 'desc' ? -1 : 1
63
+ return [...rows].sort((a, b) => {
64
+ const x = a?.[key]
65
+ const y = b?.[key]
66
+ if (x === y) return 0
67
+ const base = typeof x === 'number' && typeof y === 'number'
68
+ ? x - y
69
+ : String(x ?? '').localeCompare(String(y ?? ''))
70
+ return dir * (base > 0 ? 1 : base < 0 ? -1 : 0)
71
+ })
72
+ }
73
+
74
+ total := $derived sorted.length
75
+ totalPages := $derived size > 0 ? Math.max(1, Math.ceil(total / size)) : 1
76
+ current := $derived Math.min(page, totalPages)
77
+ pageRows := $derived size > 0 ? sorted.slice((current - 1) * size, current * size) : sorted
78
+ showFooter := $derived size > 0 && total > size
79
+
80
+ rootClasses := $derived cn(extra)
81
+ </script>
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Valibot props contracts for the palette components a generated Page can
3
+ * bind (U-02). The norns generator validates each page `components:` entry
4
+ * against these before emitting, so a bad binding is a structured refusal
5
+ * at generate time instead of a runtime surprise.
6
+ *
7
+ * A contract describes the *normalized spec entry*: the entry's first key
8
+ * (the component slot) is renamed to `data` when it binds a Query and to
9
+ * `action` when it binds an Action, matching the prop the emitter actually
10
+ * passes. Values are what TRON specs can hold: unit addresses for Query/
11
+ * Action bindings, scalars for everything else. Snippet/function props are
12
+ * not spec-bindable and are deliberately absent.
13
+ *
14
+ * Components without a contract here are not refused — they may be custom
15
+ * `.n` components, which carry their own Component-unit contract.
16
+ */
17
+
18
+ import * as v from 'valibot';
19
+
20
+ const NAME = '[A-Za-z_][A-Za-z0-9_]*';
21
+ export const QUERY_ADDRESS = new RegExp(`^${NAME}\\.Query\\.${NAME}$`);
22
+ export const ACTION_ADDRESS = new RegExp(`^${NAME}\\.Action\\.${NAME}$`);
23
+
24
+ export const query = () =>
25
+ v.pipe(v.string(), v.regex(QUERY_ADDRESS, 'expected a Query address (module.Query.name)'));
26
+
27
+ export const action = () =>
28
+ v.pipe(v.string(), v.regex(ACTION_ADDRESS, 'expected an Action address (module.Action.name)'));
29
+
30
+ /** A pass-through prop value — string/number/boolean literal from the spec. */
31
+ export const literal = () => v.union([v.string(), v.number(), v.boolean()]);
32
+
33
+ const opt = (schema) => v.optional(schema);
34
+
35
+ export const contracts = {
36
+ Table: v.strictObject({
37
+ data: query(),
38
+ columns: opt(literal()),
39
+ pageSize: opt(literal()),
40
+ striped: opt(literal()),
41
+ dense: opt(literal()),
42
+ stickyHeader: opt(literal()),
43
+ emptyMessage: opt(literal()),
44
+ class: opt(literal())
45
+ }),
46
+ DataTable: v.strictObject({
47
+ data: query(),
48
+ columns: opt(literal()),
49
+ striped: opt(literal()),
50
+ dense: opt(literal()),
51
+ stickyHeader: opt(literal()),
52
+ emptyMessage: opt(literal()),
53
+ sortKey: opt(literal()),
54
+ sortDir: opt(literal()),
55
+ class: opt(literal())
56
+ }),
57
+ Kanban: v.strictObject({
58
+ data: query(),
59
+ onMove: opt(action()),
60
+ columns: opt(literal()),
61
+ title: opt(literal()),
62
+ subtitle: opt(literal()),
63
+ emptyMessage: opt(literal()),
64
+ class: opt(literal())
65
+ }),
66
+ Chart: v.strictObject({
67
+ data: query(),
68
+ type: opt(literal()),
69
+ x: opt(literal()),
70
+ y: opt(literal()),
71
+ label: opt(literal()),
72
+ labels: opt(literal()),
73
+ class: opt(literal())
74
+ }),
75
+ Form: v.strictObject({
76
+ action: action(),
77
+ method: opt(literal()),
78
+ enctype: opt(literal()),
79
+ class: opt(literal())
80
+ }),
81
+ Field: v.strictObject({
82
+ label: opt(literal()),
83
+ help: opt(literal()),
84
+ error: opt(literal()),
85
+ name: opt(literal()),
86
+ required: opt(literal()),
87
+ id: opt(literal()),
88
+ class: opt(literal())
89
+ }),
90
+ Input: v.strictObject({
91
+ name: opt(literal()),
92
+ type: opt(literal()),
93
+ value: opt(literal()),
94
+ placeholder: opt(literal()),
95
+ required: opt(literal()),
96
+ size: opt(literal()),
97
+ class: opt(literal())
98
+ }),
99
+ Select: v.strictObject({
100
+ name: opt(literal()),
101
+ value: opt(literal()),
102
+ required: opt(literal()),
103
+ class: opt(literal())
104
+ }),
105
+ Btn: v.strictObject({
106
+ variant: opt(literal()),
107
+ size: opt(literal()),
108
+ type: opt(literal()),
109
+ icon: opt(literal()),
110
+ href: opt(literal()),
111
+ disabled: opt(literal()),
112
+ class: opt(literal())
113
+ }),
114
+ Card: v.strictObject({
115
+ padded: opt(literal()),
116
+ interactive: opt(literal()),
117
+ href: opt(literal()),
118
+ class: opt(literal())
119
+ }),
120
+ Badge: v.strictObject({
121
+ variant: opt(literal()),
122
+ size: opt(literal()),
123
+ class: opt(literal())
124
+ }),
125
+ Banner: v.strictObject({
126
+ variant: opt(literal()),
127
+ icon: opt(literal()),
128
+ class: opt(literal())
129
+ }),
130
+ Pagination: v.strictObject({
131
+ page: opt(literal()),
132
+ total: opt(literal()),
133
+ pageSize: opt(literal()),
134
+ siblingCount: opt(literal()),
135
+ class: opt(literal())
136
+ })
137
+ };
package/src/index.js CHANGED
@@ -83,6 +83,7 @@ export { default as DateRangePicker } from './components/DateRangePicker.n';
83
83
  export { default as TimePicker } from './components/TimePicker.n';
84
84
  export { default as Calendar } from './components/Calendar.n';
85
85
  export { default as DataTable } from './components/DataTable.n';
86
+ export { default as Table } from './components/Table.n';
86
87
 
87
88
  // 0.0.5 — CSS-only motion components
88
89
  export { default as AvatarGroup } from './components/AvatarGroup.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 { contracts } from './contracts.js';
@@ -1604,6 +1604,14 @@
1604
1604
  }
1605
1605
  .data-table-sort:hover { color: var(--color-primary-600); }
1606
1606
 
1607
+ /* Table — paginated wrapper around DataTable */
1608
+ .table-root { @apply flex w-full flex-col gap-3; }
1609
+ .table-footer { @apply flex items-center justify-between gap-3; }
1610
+ .table-total {
1611
+ @apply text-xs;
1612
+ color: var(--color-fg-muted);
1613
+ }
1614
+
1607
1615
  /* ============================================================ */
1608
1616
  /* Tree */
1609
1617
  /* ============================================================ */
@@ -0,0 +1,25 @@
1
+ import type { Component } from 'svelte';
2
+ import type { DataTableColumn } from './DataTable.js';
3
+
4
+ export type TableColumn = DataTableColumn;
5
+
6
+ export type TableProps = {
7
+ /** Rows to render — typically a generated Query result bound by a Page spec. */
8
+ data?: Record<string, unknown>[];
9
+ /**
10
+ * Columns: array of keys/column objects, or a comma-separated key string
11
+ * (the form spec literals arrive in). Defaults to the first row's keys.
12
+ */
13
+ columns?: string | Array<string | TableColumn>;
14
+ /** Client-side page size. 0 disables pagination. Accepts numeric strings. */
15
+ pageSize?: number | string;
16
+ striped?: boolean;
17
+ dense?: boolean;
18
+ stickyHeader?: boolean;
19
+ emptyMessage?: string;
20
+ onrowclick?: (row: Record<string, unknown>, index: number) => void;
21
+ class?: string;
22
+ };
23
+
24
+ declare const Table: Component<TableProps>;
25
+ export default Table;