@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.
@@ -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('items as item, i')
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 < items.length - 1')
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>
@@ -1,5 +1,5 @@
1
1
  ul.tree(class!="{classes}" role="tree")
2
- +each('items as node')
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