@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
|
@@ -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,64 @@
|
|
|
1
|
+
pre.live-log(class!="{classes}" bind:this!="{el}" aria-live="polite")
|
|
2
|
+
| {lines.join('\n')}
|
|
3
|
+
|
|
4
|
+
<script>
|
|
5
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
6
|
+
import { streamSource as openStream, roomChannel as openRoom } from '@human-synthesis/norns/live-client'
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
streamSource = undefined
|
|
10
|
+
roomChannel = undefined
|
|
11
|
+
receive = '*'
|
|
12
|
+
maxLines = 200
|
|
13
|
+
class: extra = ''
|
|
14
|
+
} .= $props()
|
|
15
|
+
|
|
16
|
+
lines .= $state([])
|
|
17
|
+
el .= $state(undefined)
|
|
18
|
+
|
|
19
|
+
classes := $derived cn('live-log', extra)
|
|
20
|
+
|
|
21
|
+
append := (frame) =>
|
|
22
|
+
line := typeof frame === 'string' ? frame : JSON.stringify(frame)
|
|
23
|
+
lines = [...lines, line].slice(-Math.max(1, Number(maxLines) || 200))
|
|
24
|
+
|
|
25
|
+
$effect =>
|
|
26
|
+
return unless roomChannel
|
|
27
|
+
ch := openRoom(roomChannel)
|
|
28
|
+
off := ch.on(receive, append)
|
|
29
|
+
=>
|
|
30
|
+
off()
|
|
31
|
+
ch.close()
|
|
32
|
+
|
|
33
|
+
$effect =>
|
|
34
|
+
return unless streamSource
|
|
35
|
+
let stopped = false
|
|
36
|
+
(async =>
|
|
37
|
+
try
|
|
38
|
+
for await (const frame of openStream(streamSource))
|
|
39
|
+
return if stopped
|
|
40
|
+
append(frame)
|
|
41
|
+
catch
|
|
42
|
+
return
|
|
43
|
+
)()
|
|
44
|
+
=> stopped = true
|
|
45
|
+
|
|
46
|
+
$effect =>
|
|
47
|
+
lines
|
|
48
|
+
el?.scrollTo?.(0, el.scrollHeight)
|
|
49
|
+
return
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<style>
|
|
53
|
+
.live-log {
|
|
54
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
55
|
+
font-size: 0.8125rem;
|
|
56
|
+
line-height: 1.5;
|
|
57
|
+
max-height: 20rem;
|
|
58
|
+
overflow-y: auto;
|
|
59
|
+
margin: 0;
|
|
60
|
+
padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
|
|
61
|
+
border-radius: var(--radius-md, 0.375rem);
|
|
62
|
+
background: var(--color-surface-2, color-mix(in oklab, currentColor 5%, transparent));
|
|
63
|
+
}
|
|
64
|
+
</style>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
.presence-avatars(class!="{classes}" role="status" aria-label!="{`${clients} ${label}`}")
|
|
2
|
+
AvatarGroup(items!="{people}" max!="{max}" size!="{size}" label!="{label}")
|
|
3
|
+
+if('showCount')
|
|
4
|
+
span.presence-avatars-count {clients} {label}
|
|
5
|
+
|
|
6
|
+
<script>
|
|
7
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
8
|
+
import { roomChannel as openRoom } from '@human-synthesis/norns/live-client'
|
|
9
|
+
import AvatarGroup from './AvatarGroup.n'
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
roomChannel = undefined
|
|
13
|
+
max = 5
|
|
14
|
+
size = 'sm'
|
|
15
|
+
label = 'online'
|
|
16
|
+
showCount = true
|
|
17
|
+
class: extra = ''
|
|
18
|
+
} .= $props()
|
|
19
|
+
|
|
20
|
+
clients .= $state(0)
|
|
21
|
+
|
|
22
|
+
classes := $derived cn('presence-avatars', extra)
|
|
23
|
+
people := $derived Array.from({ length: Math.min(clients, Number(max) || 5) }, (_, i) => ({ name: `Guest ${i + 1}` }))
|
|
24
|
+
|
|
25
|
+
$effect =>
|
|
26
|
+
return unless roomChannel
|
|
27
|
+
ch := openRoom(roomChannel)
|
|
28
|
+
off := ch.on('presence', (frame) => clients = Number(frame?.clients) || 0)
|
|
29
|
+
=>
|
|
30
|
+
off()
|
|
31
|
+
ch.close()
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<style>
|
|
35
|
+
.presence-avatars {
|
|
36
|
+
display: inline-flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
gap: var(--spacing-2, 0.5rem);
|
|
39
|
+
}
|
|
40
|
+
.presence-avatars-count {
|
|
41
|
+
font-size: 0.8125rem;
|
|
42
|
+
opacity: 0.7;
|
|
43
|
+
font-variant-numeric: tabular-nums;
|
|
44
|
+
}
|
|
45
|
+
</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,64 @@
|
|
|
1
|
+
.stream-text(class!="{classes}" aria-live="polite" aria-busy!="{streaming}")
|
|
2
|
+
| {text}
|
|
3
|
+
+if('streaming')
|
|
4
|
+
span.stream-text-cursor(aria-hidden="true")
|
|
5
|
+
|
|
6
|
+
<script>
|
|
7
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
8
|
+
import { streamSource as openStream } from '@human-synthesis/norns/live-client'
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
streamSource = undefined
|
|
12
|
+
input = undefined
|
|
13
|
+
autostart = true
|
|
14
|
+
onframe = undefined
|
|
15
|
+
ondone = undefined
|
|
16
|
+
class: extra = ''
|
|
17
|
+
} .= $props()
|
|
18
|
+
|
|
19
|
+
text .= $state('')
|
|
20
|
+
streaming .= $state(false)
|
|
21
|
+
|
|
22
|
+
classes := $derived cn('stream-text', extra)
|
|
23
|
+
|
|
24
|
+
start := async (override) =>
|
|
25
|
+
return unless streamSource
|
|
26
|
+
return if streaming
|
|
27
|
+
text = ''
|
|
28
|
+
streaming = true
|
|
29
|
+
body := override ?? input
|
|
30
|
+
try
|
|
31
|
+
opts := body === undefined ? {} : { input: body }
|
|
32
|
+
for await (const frame of openStream(streamSource, opts))
|
|
33
|
+
delta := typeof frame === 'string' ? frame : (frame?.delta ?? '')
|
|
34
|
+
text += delta
|
|
35
|
+
onframe?.(frame)
|
|
36
|
+
finally
|
|
37
|
+
streaming = false
|
|
38
|
+
ondone?.(text)
|
|
39
|
+
|
|
40
|
+
$effect =>
|
|
41
|
+
start() if autostart
|
|
42
|
+
return
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<style>
|
|
46
|
+
.stream-text {
|
|
47
|
+
white-space: pre-wrap;
|
|
48
|
+
overflow-wrap: anywhere;
|
|
49
|
+
}
|
|
50
|
+
.stream-text-cursor {
|
|
51
|
+
display: inline-block;
|
|
52
|
+
width: 0.55ch;
|
|
53
|
+
height: 1em;
|
|
54
|
+
margin-left: 0.15ch;
|
|
55
|
+
vertical-align: text-bottom;
|
|
56
|
+
background: currentColor;
|
|
57
|
+
animation: stream-text-blink 1s steps(2, start) infinite;
|
|
58
|
+
}
|
|
59
|
+
@keyframes stream-text-blink {
|
|
60
|
+
to {
|
|
61
|
+
visibility: hidden;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
</style>
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
cell!="{cell}"
|
|
11
|
+
empty!="{empty}"
|
|
12
|
+
bind:sortKey
|
|
13
|
+
bind:sortDir
|
|
14
|
+
)
|
|
15
|
+
+if('showFooter')
|
|
16
|
+
.table-footer
|
|
17
|
+
span.table-total {total} {total === 1 ? 'row' : 'rows'}
|
|
18
|
+
Pagination(bind:page total!="{total}" pageSize!="{size}")
|
|
19
|
+
|
|
20
|
+
<script>
|
|
21
|
+
import { cn } from '@human-synthesis/norns-ui/cn'
|
|
22
|
+
import DataTable from './DataTable.n'
|
|
23
|
+
import Pagination from './Pagination.n'
|
|
24
|
+
|
|
25
|
+
{
|
|
26
|
+
data = []
|
|
27
|
+
columns = undefined
|
|
28
|
+
pageSize = 20
|
|
29
|
+
striped = false
|
|
30
|
+
dense = false
|
|
31
|
+
stickyHeader = false
|
|
32
|
+
emptyMessage = 'No data'
|
|
33
|
+
onrowclick
|
|
34
|
+
cell = undefined
|
|
35
|
+
empty = undefined
|
|
36
|
+
class: extra = ''
|
|
37
|
+
} .= $props()
|
|
38
|
+
|
|
39
|
+
sortKey .= $state(undefined)
|
|
40
|
+
sortDir .= $state('asc')
|
|
41
|
+
page .= $state(1)
|
|
42
|
+
|
|
43
|
+
rows := $derived Array.isArray(data) ? data : []
|
|
44
|
+
size := $derived Math.max(0, Number(pageSize) || 0)
|
|
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
|
+
cols := $derived.by => {
|
|
52
|
+
const source = columns ?? Object.keys(rows[0] ?? {})
|
|
53
|
+
const list = typeof source === 'string'
|
|
54
|
+
? source.split(',').map((s) => s.trim()).filter(Boolean)
|
|
55
|
+
: source
|
|
56
|
+
return list.map((col) =>
|
|
57
|
+
typeof col === 'string'
|
|
58
|
+
? { key: col, label: labelOf(col), sortable: true }
|
|
59
|
+
: { sortable: true, ...col }
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
sorted := $derived.by => {
|
|
64
|
+
if (!sortKey) return rows
|
|
65
|
+
const key = sortKey
|
|
66
|
+
const dir = sortDir === 'desc' ? -1 : 1
|
|
67
|
+
return [...rows].sort((a, b) => {
|
|
68
|
+
const x = a?.[key]
|
|
69
|
+
const y = b?.[key]
|
|
70
|
+
if (x === y) return 0
|
|
71
|
+
const base = typeof x === 'number' && typeof y === 'number'
|
|
72
|
+
? x - y
|
|
73
|
+
: String(x ?? '').localeCompare(String(y ?? ''))
|
|
74
|
+
return dir * (base > 0 ? 1 : base < 0 ? -1 : 0)
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
total := $derived sorted.length
|
|
79
|
+
totalPages := $derived size > 0 ? Math.max(1, Math.ceil(total / size)) : 1
|
|
80
|
+
current := $derived Math.min(page, totalPages)
|
|
81
|
+
pageRows := $derived size > 0 ? sorted.slice((current - 1) * size, current * size) : sorted
|
|
82
|
+
showFooter := $derived size > 0 && total > size
|
|
83
|
+
|
|
84
|
+
rootClasses := $derived cn(extra)
|
|
85
|
+
</script>
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
ol(class!="{classes}")
|
|
2
|
-
+each('
|
|
2
|
+
+each('list as item, i')
|
|
3
3
|
li.timeline-item
|
|
4
4
|
.timeline-dot(class!="{item.variant ? `timeline-dot-${item.variant}` : ''}")
|
|
5
5
|
+if('item.icon')
|
|
6
6
|
Icon(name!="{item.icon}" size="size-3")
|
|
7
|
-
+if('i <
|
|
7
|
+
+if('i < list.length - 1')
|
|
8
8
|
.timeline-line
|
|
9
9
|
.timeline-body
|
|
10
10
|
+if('item.time')
|
|
@@ -19,9 +19,11 @@ ol(class!="{classes}")
|
|
|
19
19
|
import Icon from './Icon.n'
|
|
20
20
|
|
|
21
21
|
{
|
|
22
|
-
items =
|
|
22
|
+
items = undefined
|
|
23
|
+
data = undefined
|
|
23
24
|
class: extra = ''
|
|
24
25
|
} := $props()
|
|
25
26
|
|
|
27
|
+
list := items ?? data ?? []
|
|
26
28
|
classes := cn 'timeline', extra
|
|
27
29
|
</script>
|
package/src/components/Tree.n
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
ul.tree(class!="{classes}" role="tree")
|
|
2
|
-
+each('
|
|
2
|
+
+each('list as node')
|
|
3
3
|
| {@render renderNode(node, 0)}
|
|
4
4
|
|
|
5
5
|
+snippet('renderNode', node, depth)
|
|
@@ -33,12 +33,14 @@ ul.tree(class!="{classes}" role="tree")
|
|
|
33
33
|
import Icon from './Icon.n'
|
|
34
34
|
|
|
35
35
|
{
|
|
36
|
-
items =
|
|
36
|
+
items = undefined
|
|
37
|
+
data = undefined
|
|
37
38
|
expanded = $bindable([])
|
|
38
39
|
selected = $bindable(undefined)
|
|
39
40
|
class: extra = ''
|
|
40
41
|
} .= $props()
|
|
41
42
|
|
|
43
|
+
list := items ?? data ?? []
|
|
42
44
|
classes := cn 'tree', extra
|
|
43
45
|
|
|
44
46
|
hasChildren := (node) => Array.isArray(node.children) && node.children.length > 0
|