@human-synthesis/norns-ui 0.0.10 → 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 +5 -3
- package/src/auto-import.js +10 -0
- package/src/components/Chart.n +129 -0
- package/src/components/Kanban.n +181 -0
- package/src/components/Listbox.n +141 -0
- package/src/components/Spinner.n +13 -0
- package/src/components/Table.n +81 -0
- package/src/contracts.js +137 -0
- package/src/index.js +2 -0
- package/src/styles/atoms.css +8 -0
- package/src/types/Table.d.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@human-synthesis/norns-ui",
|
|
3
|
-
"version": "0.0
|
|
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,13 +29,14 @@
|
|
|
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
|
|
38
|
-
"@human-synthesis/norns-core": "^0.0.
|
|
38
|
+
"@human-synthesis/norns": "^0.1.0",
|
|
39
|
+
"@human-synthesis/norns-core": "^0.0.10",
|
|
39
40
|
"svelte": "^5.0.0",
|
|
40
41
|
"tailwindcss": "^4.0.0"
|
|
41
42
|
},
|
|
@@ -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",
|
package/src/auto-import.js
CHANGED
|
@@ -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>
|
package/src/contracts.js
ADDED
|
@@ -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';
|
package/src/styles/atoms.css
CHANGED
|
@@ -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;
|