@human-synthesis/norns-ui 0.0.11 → 0.2.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 +6 -2
- package/src/auto-import.js +18 -1
- package/src/components/Chart.n +129 -0
- package/src/components/ChatThread.n +97 -0
- package/src/components/DataTable.n +10 -2
- package/src/components/Feed.n +71 -0
- package/src/components/Kanban.n +191 -0
- package/src/components/Listbox.n +141 -0
- package/src/components/LiveLog.n +64 -0
- package/src/components/PresenceAvatars.n +45 -0
- package/src/components/Spinner.n +13 -0
- package/src/components/StreamText.n +64 -0
- package/src/components/Table.n +85 -0
- package/src/components/Timeline.n +5 -3
- package/src/components/Tree.n +4 -2
- package/src/contracts.js +272 -0
- package/src/index.js +2 -0
- package/src/lib/behaviors/index.js +1 -0
- package/src/lib/behaviors/optimistic.svelte.js +24 -0
- package/src/palette-manifest.json +4188 -0
- package/src/styles/atoms.css +8 -0
- package/src/types/Chart.d.ts +21 -0
- package/src/types/ChatThread.d.ts +27 -0
- package/src/types/DataTable.d.ts +5 -1
- package/src/types/Feed.d.ts +19 -0
- package/src/types/Kanban.d.ts +26 -0
- package/src/types/Listbox.d.ts +19 -0
- package/src/types/LiveLog.d.ts +16 -0
- package/src/types/PresenceAvatars.d.ts +17 -0
- package/src/types/Spinner.d.ts +12 -0
- package/src/types/StreamText.d.ts +18 -0
- package/src/types/Table.d.ts +29 -0
- package/src/types/Timeline.d.ts +2 -0
- package/src/types/Tree.d.ts +2 -0
- package/src/types/Window.d.ts +17 -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.2.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)",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"test": "bun test",
|
|
18
|
+
"build:manifest": "bun scripts/build-manifest.mjs",
|
|
18
19
|
"check:shims": "bun scripts/check-shims.mjs",
|
|
19
20
|
"check:shims:strict": "bun scripts/check-shims.mjs --strict"
|
|
20
21
|
},
|
|
@@ -29,12 +30,14 @@
|
|
|
29
30
|
"./styles/tokens": "./src/styles/tokens.css",
|
|
30
31
|
"./styles/atoms": "./src/styles/atoms.css",
|
|
31
32
|
"./components/*": "./src/components/*",
|
|
33
|
+
"./contracts": "./src/contracts.js",
|
|
34
|
+
"./manifest": "./src/palette-manifest.json",
|
|
32
35
|
"./motion": "./src/motion/index.js",
|
|
33
36
|
"./motion/*": "./src/motion/*",
|
|
34
37
|
"./package.json": "./package.json"
|
|
35
38
|
},
|
|
36
39
|
"peerDependencies": {
|
|
37
|
-
"@human-synthesis/norns": "^0.0
|
|
40
|
+
"@human-synthesis/norns": "^0.2.0",
|
|
38
41
|
"@human-synthesis/norns-core": "^0.0.10",
|
|
39
42
|
"svelte": "^5.0.0",
|
|
40
43
|
"tailwindcss": "^4.0.0"
|
|
@@ -49,6 +52,7 @@
|
|
|
49
52
|
},
|
|
50
53
|
"dependencies": {
|
|
51
54
|
"@floating-ui/dom": "^1.7.6",
|
|
55
|
+
"valibot": "^1.4.0",
|
|
52
56
|
"@fontsource-variable/outfit": "^5.2.8",
|
|
53
57
|
"@iconify-json/lucide": "^1.2.105",
|
|
54
58
|
"@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'),
|
|
@@ -113,7 +123,14 @@ export function presetUI() {
|
|
|
113
123
|
CopyButton: c('CopyButton'),
|
|
114
124
|
ThemeToggler: c('ThemeToggler'),
|
|
115
125
|
ShinyButton: c('ShinyButton'),
|
|
116
|
-
RippleButton: c('RippleButton')
|
|
126
|
+
RippleButton: c('RippleButton'),
|
|
127
|
+
|
|
128
|
+
// U-09 — realtime tier
|
|
129
|
+
StreamText: c('StreamText'),
|
|
130
|
+
ChatThread: c('ChatThread'),
|
|
131
|
+
Feed: c('Feed'),
|
|
132
|
+
PresenceAvatars: c('PresenceAvatars'),
|
|
133
|
+
LiveLog: c('LiveLog')
|
|
117
134
|
// Motion-driven components live in `@human-synthesis/norns-ui/motion`
|
|
118
135
|
// and are NOT registered here (opt-in import only).
|
|
119
136
|
},
|
|
@@ -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,97 @@
|
|
|
1
|
+
.chat-thread(class!="{classes}")
|
|
2
|
+
ul.chat-thread-list
|
|
3
|
+
+if('messages.length === 0')
|
|
4
|
+
li.chat-thread-empty {emptyMessage}
|
|
5
|
+
+each('messages as msg')
|
|
6
|
+
li.chat-thread-msg(class!="{msg.self ? 'chat-thread-msg-self' : ''}")
|
|
7
|
+
+if('message')
|
|
8
|
+
| {@render message(msg)}
|
|
9
|
+
+if('!message')
|
|
10
|
+
+if('msg.from')
|
|
11
|
+
span.chat-thread-from {msg.from}
|
|
12
|
+
span.chat-thread-text {msg.text}
|
|
13
|
+
form.chat-thread-composer(onsubmit!="{submit}")
|
|
14
|
+
input.input.chat-thread-input(bind:value!="{draft}" placeholder!="{placeholder}")
|
|
15
|
+
button.btn.btn-primary(type="submit" disabled!="{draft.trim() === ''}") {sendLabel}
|
|
16
|
+
|
|
17
|
+
<script>
|
|
18
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
19
|
+
import { roomChannel as openRoom } from '@human-synthesis/norns/live-client'
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
roomChannel = undefined
|
|
23
|
+
send = 'chat'
|
|
24
|
+
receive = undefined
|
|
25
|
+
placeholder = 'Message'
|
|
26
|
+
sendLabel = 'Send'
|
|
27
|
+
emptyMessage = 'No messages yet'
|
|
28
|
+
message = undefined
|
|
29
|
+
class: extra = ''
|
|
30
|
+
} .= $props()
|
|
31
|
+
|
|
32
|
+
messages .= $state([])
|
|
33
|
+
draft .= $state('')
|
|
34
|
+
channel .= $state(undefined)
|
|
35
|
+
|
|
36
|
+
classes := $derived cn('chat-thread', extra)
|
|
37
|
+
|
|
38
|
+
$effect =>
|
|
39
|
+
return unless roomChannel
|
|
40
|
+
ch := openRoom(roomChannel)
|
|
41
|
+
channel = ch
|
|
42
|
+
off := ch.on(receive ?? send, (frame) => messages = [...messages, frame])
|
|
43
|
+
=>
|
|
44
|
+
off()
|
|
45
|
+
ch.close()
|
|
46
|
+
channel = undefined
|
|
47
|
+
|
|
48
|
+
submit := (e) =>
|
|
49
|
+
e.preventDefault()
|
|
50
|
+
body := draft.trim()
|
|
51
|
+
return unless body and channel
|
|
52
|
+
channel.send(send, { text: body })
|
|
53
|
+
messages = [...messages, { text: body, self: true }]
|
|
54
|
+
draft = ''
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<style>
|
|
58
|
+
.chat-thread {
|
|
59
|
+
display: grid;
|
|
60
|
+
gap: var(--spacing-2, 0.5rem);
|
|
61
|
+
}
|
|
62
|
+
.chat-thread-list {
|
|
63
|
+
display: grid;
|
|
64
|
+
gap: var(--spacing-1, 0.25rem);
|
|
65
|
+
max-height: 24rem;
|
|
66
|
+
overflow-y: auto;
|
|
67
|
+
list-style: none;
|
|
68
|
+
margin: 0;
|
|
69
|
+
padding: 0;
|
|
70
|
+
}
|
|
71
|
+
.chat-thread-empty {
|
|
72
|
+
opacity: 0.6;
|
|
73
|
+
font-size: 0.875rem;
|
|
74
|
+
}
|
|
75
|
+
.chat-thread-msg {
|
|
76
|
+
display: flex;
|
|
77
|
+
gap: var(--spacing-2, 0.5rem);
|
|
78
|
+
align-items: baseline;
|
|
79
|
+
padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);
|
|
80
|
+
border-radius: var(--radius-md, 0.375rem);
|
|
81
|
+
background: var(--color-surface-2, color-mix(in oklab, currentColor 4%, transparent));
|
|
82
|
+
}
|
|
83
|
+
.chat-thread-msg-self {
|
|
84
|
+
background: color-mix(in oklab, var(--color-primary-500, currentColor) 12%, transparent);
|
|
85
|
+
}
|
|
86
|
+
.chat-thread-from {
|
|
87
|
+
font-weight: 600;
|
|
88
|
+
font-size: 0.8125rem;
|
|
89
|
+
}
|
|
90
|
+
.chat-thread-composer {
|
|
91
|
+
display: flex;
|
|
92
|
+
gap: var(--spacing-2, 0.5rem);
|
|
93
|
+
}
|
|
94
|
+
.chat-thread-input {
|
|
95
|
+
flex: 1;
|
|
96
|
+
}
|
|
97
|
+
</style>
|
|
@@ -17,12 +17,18 @@
|
|
|
17
17
|
+if('rows.length === 0')
|
|
18
18
|
tr.data-table-empty
|
|
19
19
|
td(colspan!="{columns.length}")
|
|
20
|
-
|
|
20
|
+
+if('empty')
|
|
21
|
+
| {@render empty()}
|
|
22
|
+
+if('!empty')
|
|
23
|
+
| {emptyMessage}
|
|
21
24
|
+each('rows as row, i')
|
|
22
25
|
tr(onclick!="{onrowclick ? () => onrowclick(row, i) : undefined}" class!="{onrowclick ? 'data-table-row-clickable' : ''}")
|
|
23
26
|
+each('columns as col')
|
|
24
27
|
td(class!="{col.cellClass ?? ''}")
|
|
25
|
-
|
|
28
|
+
+if('cell')
|
|
29
|
+
| {@render cell(row, col.key, row[col.key])}
|
|
30
|
+
+if('!cell')
|
|
31
|
+
| {row[col.key]}
|
|
26
32
|
|
|
27
33
|
<script>
|
|
28
34
|
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
@@ -38,6 +44,8 @@
|
|
|
38
44
|
sortKey = $bindable(undefined)
|
|
39
45
|
sortDir = $bindable('asc')
|
|
40
46
|
onrowclick
|
|
47
|
+
cell = undefined
|
|
48
|
+
empty = undefined
|
|
41
49
|
class: extra = ''
|
|
42
50
|
} .= $props()
|
|
43
51
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
ul.feed(class!="{classes}" aria-live="polite")
|
|
2
|
+
+if('entries.length === 0')
|
|
3
|
+
li.feed-empty {emptyMessage}
|
|
4
|
+
+each('entries as it')
|
|
5
|
+
li.feed-item
|
|
6
|
+
+if('item')
|
|
7
|
+
| {@render item(it)}
|
|
8
|
+
+if('!item')
|
|
9
|
+
| {typeof it === 'string' ? it : (it.text ?? JSON.stringify(it))}
|
|
10
|
+
|
|
11
|
+
<script>
|
|
12
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
13
|
+
import { streamSource as openStream, roomChannel as openRoom } from '@human-synthesis/norns/live-client'
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
streamSource = undefined
|
|
17
|
+
roomChannel = undefined
|
|
18
|
+
receive = '*'
|
|
19
|
+
max = 100
|
|
20
|
+
emptyMessage = 'Nothing yet'
|
|
21
|
+
item = undefined
|
|
22
|
+
class: extra = ''
|
|
23
|
+
} .= $props()
|
|
24
|
+
|
|
25
|
+
entries .= $state([])
|
|
26
|
+
|
|
27
|
+
classes := $derived cn('feed', extra)
|
|
28
|
+
|
|
29
|
+
append := (frame) =>
|
|
30
|
+
entries = [...entries, frame].slice(-Math.max(1, Number(max) || 100))
|
|
31
|
+
|
|
32
|
+
$effect =>
|
|
33
|
+
return unless roomChannel
|
|
34
|
+
ch := openRoom(roomChannel)
|
|
35
|
+
off := ch.on(receive, append)
|
|
36
|
+
=>
|
|
37
|
+
off()
|
|
38
|
+
ch.close()
|
|
39
|
+
|
|
40
|
+
$effect =>
|
|
41
|
+
return unless streamSource
|
|
42
|
+
let stopped = false
|
|
43
|
+
(async =>
|
|
44
|
+
try
|
|
45
|
+
for await (const frame of openStream(streamSource))
|
|
46
|
+
return if stopped
|
|
47
|
+
append(frame)
|
|
48
|
+
catch
|
|
49
|
+
return
|
|
50
|
+
)()
|
|
51
|
+
=> stopped = true
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
.feed {
|
|
56
|
+
display: grid;
|
|
57
|
+
gap: var(--spacing-1, 0.25rem);
|
|
58
|
+
list-style: none;
|
|
59
|
+
margin: 0;
|
|
60
|
+
padding: 0;
|
|
61
|
+
}
|
|
62
|
+
.feed-empty {
|
|
63
|
+
opacity: 0.6;
|
|
64
|
+
font-size: 0.875rem;
|
|
65
|
+
}
|
|
66
|
+
.feed-item {
|
|
67
|
+
padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);
|
|
68
|
+
border-radius: var(--radius-md, 0.375rem);
|
|
69
|
+
background: var(--color-surface-2, color-mix(in oklab, currentColor 4%, transparent));
|
|
70
|
+
}
|
|
71
|
+
</style>
|
|
@@ -0,0 +1,191 @@
|
|
|
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
|
|
15
|
+
+if('empty')
|
|
16
|
+
| {@render empty()}
|
|
17
|
+
+if('!empty')
|
|
18
|
+
| {emptyMessage}
|
|
19
|
+
+each('col.cards as card, i (cardId(card) ?? i)')
|
|
20
|
+
article.kanban-card(
|
|
21
|
+
draggable!="{movable}"
|
|
22
|
+
ondragstart!="{(e) => onDragStart(e, card, col.key)}"
|
|
23
|
+
onclick!="{oncardclick ? () => oncardclick(card) : undefined}"
|
|
24
|
+
class!="{oncardclick ? 'kanban-card-clickable' : ''}"
|
|
25
|
+
)
|
|
26
|
+
+if('renderCard')
|
|
27
|
+
| {@render renderCard(card)}
|
|
28
|
+
+if('!renderCard')
|
|
29
|
+
.kanban-card-title {titleOf(card)}
|
|
30
|
+
+if('subtitle && card[subtitle] != null')
|
|
31
|
+
.kanban-card-subtitle {card[subtitle]}
|
|
32
|
+
|
|
33
|
+
<script>
|
|
34
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
35
|
+
import { optimisticOverlay } from '@human-synthesis/norns-ui/behaviors/optimistic.svelte.js'
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
data = {}
|
|
39
|
+
columns = undefined
|
|
40
|
+
title = undefined
|
|
41
|
+
subtitle = undefined
|
|
42
|
+
onMove = undefined
|
|
43
|
+
oncardclick = undefined
|
|
44
|
+
emptyMessage = 'No cards'
|
|
45
|
+
card: renderCard = undefined
|
|
46
|
+
empty = undefined
|
|
47
|
+
class: extra = ''
|
|
48
|
+
} .= $props()
|
|
49
|
+
|
|
50
|
+
// local overlay so a drop lands instantly; server truth wins on reload
|
|
51
|
+
moves := optimisticOverlay()
|
|
52
|
+
|
|
53
|
+
movable := $derived onMove !== undefined
|
|
54
|
+
rootClasses := $derived cn('kanban', extra)
|
|
55
|
+
|
|
56
|
+
labelOf := (key) => {
|
|
57
|
+
const spaced = String(key).replace(/[_-]+/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
58
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
cardId := (card) => card?.id ?? card?.key ?? undefined
|
|
62
|
+
|
|
63
|
+
titleOf := (card) => {
|
|
64
|
+
if title then return card?.[title]
|
|
65
|
+
card?.title ?? card?.subject ?? card?.name ?? cardId(card) ?? ''
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
grouped := $derived (data && typeof data === 'object' && !Array.isArray(data)) ? data : {}
|
|
69
|
+
|
|
70
|
+
keys := $derived.by => {
|
|
71
|
+
const source = columns ?? Object.keys(grouped)
|
|
72
|
+
return typeof source === 'string'
|
|
73
|
+
? source.split(',').map((s) => s.trim()).filter(Boolean)
|
|
74
|
+
: source
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
cols := $derived.by => {
|
|
78
|
+
const out = keys.map (key) =>
|
|
79
|
+
{ key, label: labelOf(key), cards: [] }
|
|
80
|
+
const byKey = new Map(out.map((c) => [c.key, c]))
|
|
81
|
+
for (const [key, cards] of Object.entries(grouped))
|
|
82
|
+
for (const card of Array.isArray(cards) ? cards : [])
|
|
83
|
+
const to = moves.get(cardId(card), key)
|
|
84
|
+
byKey.get(to)?.cards.push(card)
|
|
85
|
+
out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
dragged .= $state(undefined)
|
|
89
|
+
dropTarget .= $state(undefined)
|
|
90
|
+
|
|
91
|
+
onDragStart := (e, card, from) =>
|
|
92
|
+
dragged = { card, from }
|
|
93
|
+
e.dataTransfer?.setData('text/plain', String(cardId(card) ?? ''))
|
|
94
|
+
if e.dataTransfer then e.dataTransfer.effectAllowed = 'move'
|
|
95
|
+
|
|
96
|
+
onDragOver := (e, key) =>
|
|
97
|
+
return unless movable and dragged
|
|
98
|
+
e.preventDefault()
|
|
99
|
+
dropTarget = key
|
|
100
|
+
|
|
101
|
+
onDragLeave := (key) =>
|
|
102
|
+
if dropTarget === key then dropTarget = undefined
|
|
103
|
+
|
|
104
|
+
submitMove := (card, to) =>
|
|
105
|
+
if typeof onMove is 'function'
|
|
106
|
+
onMove(card, to)
|
|
107
|
+
else if typeof onMove is 'string'
|
|
108
|
+
// generated pages bind onMove to a "?/action" form-action URL; the
|
|
109
|
+
// action's input contract is strict, so only `id` goes over the wire —
|
|
110
|
+
// the transition itself is what the bound action means by "move"
|
|
111
|
+
const body = new FormData()
|
|
112
|
+
body.set('id', String(cardId(card) ?? ''))
|
|
113
|
+
fetch(onMove, { method: 'POST', body }).catch(=> undefined)
|
|
114
|
+
|
|
115
|
+
onDrop := (e, to) =>
|
|
116
|
+
dropTarget = undefined
|
|
117
|
+
return unless dragged
|
|
118
|
+
e.preventDefault()
|
|
119
|
+
{ card, from } := dragged
|
|
120
|
+
dragged = undefined
|
|
121
|
+
return if from === to
|
|
122
|
+
moves.set(cardId(card), to)
|
|
123
|
+
submitMove(card, to)
|
|
124
|
+
</script>
|
|
125
|
+
|
|
126
|
+
<style>
|
|
127
|
+
.kanban-root {
|
|
128
|
+
display: grid;
|
|
129
|
+
grid-auto-flow: column;
|
|
130
|
+
grid-auto-columns: minmax(14rem, 1fr);
|
|
131
|
+
gap: var(--spacing-3, 0.75rem);
|
|
132
|
+
align-items: start;
|
|
133
|
+
overflow-x: auto;
|
|
134
|
+
}
|
|
135
|
+
.kanban-column {
|
|
136
|
+
background: var(--color-surface-2, color-mix(in oklab, currentColor 4%, transparent));
|
|
137
|
+
border-radius: var(--radius-lg, 0.5rem);
|
|
138
|
+
padding: var(--spacing-2, 0.5rem);
|
|
139
|
+
display: grid;
|
|
140
|
+
gap: var(--spacing-2, 0.5rem);
|
|
141
|
+
}
|
|
142
|
+
.kanban-column-active {
|
|
143
|
+
outline: 2px dashed var(--color-accent, currentColor);
|
|
144
|
+
outline-offset: -2px;
|
|
145
|
+
}
|
|
146
|
+
.kanban-column-header {
|
|
147
|
+
display: flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
justify-content: space-between;
|
|
150
|
+
font-size: 0.8125rem;
|
|
151
|
+
font-weight: 600;
|
|
152
|
+
padding-inline: var(--spacing-1, 0.25rem);
|
|
153
|
+
}
|
|
154
|
+
.kanban-column-count {
|
|
155
|
+
opacity: 0.6;
|
|
156
|
+
font-variant-numeric: tabular-nums;
|
|
157
|
+
}
|
|
158
|
+
.kanban-cards {
|
|
159
|
+
display: grid;
|
|
160
|
+
gap: var(--spacing-2, 0.5rem);
|
|
161
|
+
min-height: 2rem;
|
|
162
|
+
}
|
|
163
|
+
.kanban-card {
|
|
164
|
+
background: var(--color-surface, canvas);
|
|
165
|
+
border: 1px solid var(--color-border, color-mix(in oklab, currentColor 12%, transparent));
|
|
166
|
+
border-radius: var(--radius-md, 0.375rem);
|
|
167
|
+
padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
|
|
168
|
+
display: grid;
|
|
169
|
+
gap: 0.125rem;
|
|
170
|
+
}
|
|
171
|
+
.kanban-card[draggable='true'] {
|
|
172
|
+
cursor: grab;
|
|
173
|
+
}
|
|
174
|
+
.kanban-card-clickable {
|
|
175
|
+
cursor: pointer;
|
|
176
|
+
}
|
|
177
|
+
.kanban-card-title {
|
|
178
|
+
font-size: 0.875rem;
|
|
179
|
+
font-weight: 500;
|
|
180
|
+
}
|
|
181
|
+
.kanban-card-subtitle {
|
|
182
|
+
font-size: 0.75rem;
|
|
183
|
+
opacity: 0.7;
|
|
184
|
+
}
|
|
185
|
+
.kanban-empty {
|
|
186
|
+
font-size: 0.75rem;
|
|
187
|
+
opacity: 0.6;
|
|
188
|
+
padding: var(--spacing-2, 0.5rem);
|
|
189
|
+
text-align: center;
|
|
190
|
+
}
|
|
191
|
+
</style>
|