@rokkit/ui 1.1.1 → 1.1.4
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 -5
- package/src/components/Table.svelte +44 -95
- package/src/components/Toolbar.svelte +32 -109
- package/src/components/TreeTable.svelte +317 -0
- package/src/components/index.ts +1 -0
- package/src/index.ts +1 -0
- package/src/types/index.ts +1 -0
- package/src/types/tree-table.ts +103 -0
- package/src/types/tree.ts +12 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rokkit/ui",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.4",
|
|
4
4
|
"description": "Data driven UI components for Rokkit applications",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -44,10 +44,10 @@
|
|
|
44
44
|
"dropdown"
|
|
45
45
|
],
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@rokkit/core": "1.1.
|
|
48
|
-
"@rokkit/data": "1.1.
|
|
49
|
-
"@rokkit/states": "1.1.
|
|
50
|
-
"@rokkit/actions": "1.1.
|
|
47
|
+
"@rokkit/core": "1.1.4",
|
|
48
|
+
"@rokkit/data": "1.1.4",
|
|
49
|
+
"@rokkit/states": "1.1.4",
|
|
50
|
+
"@rokkit/actions": "1.1.4",
|
|
51
51
|
"marked": "^15.0.0",
|
|
52
52
|
"dompurify": "^3.0.0"
|
|
53
53
|
},
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { TableProps, TableColumn, TableSortIcons } from '../types/table.js'
|
|
3
3
|
import { defaultTableSortIcons } from '../types/table.js'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { untrack } from 'svelte'
|
|
4
|
+
import { ProxyItem, ProxyTable, Wrapper, messages } from '@rokkit/states'
|
|
5
|
+
import { Navigator } from '@rokkit/actions'
|
|
7
6
|
|
|
8
7
|
let {
|
|
9
8
|
data = [],
|
|
@@ -29,109 +28,59 @@
|
|
|
29
28
|
|
|
30
29
|
const icons = $derived<TableSortIcons>({ ...defaultTableSortIcons, ...userIcons })
|
|
31
30
|
|
|
32
|
-
// ───
|
|
31
|
+
// ─── ProxyTable + Wrapper ───────────────────────────────────────
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
fields: userFields,
|
|
39
|
-
value,
|
|
40
|
-
multiselect: selectable === 'multi'
|
|
41
|
-
})
|
|
33
|
+
const multiselect = $derived(selectable === 'multi')
|
|
34
|
+
|
|
35
|
+
const proxyTable = $derived(
|
|
36
|
+
new ProxyTable(data, { columns: userColumns, fields: userFields, onsort })
|
|
42
37
|
)
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
const wrapper = $derived(
|
|
40
|
+
new Wrapper(proxyTable, {
|
|
41
|
+
onselect: (_v: unknown, proxy: ProxyItem) => {
|
|
42
|
+
if (selectable === false || disabled) return
|
|
43
|
+
onselect?.(proxy.value, proxy.value as Record<string, unknown>)
|
|
44
|
+
},
|
|
45
|
+
collapsible: false,
|
|
46
|
+
multiselect
|
|
47
|
+
})
|
|
48
|
+
)
|
|
51
49
|
|
|
52
|
-
// Sync
|
|
50
|
+
// Sync value bindings out
|
|
53
51
|
$effect(() => {
|
|
54
|
-
|
|
52
|
+
wrapper.moveToValue(value)
|
|
55
53
|
})
|
|
56
|
-
|
|
57
|
-
// Sync columns changes
|
|
58
54
|
$effect(() => {
|
|
59
|
-
if (
|
|
60
|
-
controller.columns = userColumns.map((c) => ({
|
|
61
|
-
sortable: true,
|
|
62
|
-
sorted: 'none',
|
|
63
|
-
...c
|
|
64
|
-
}))
|
|
65
|
-
}
|
|
55
|
+
if (multiselect) values = (wrapper.selected as unknown[]).slice()
|
|
66
56
|
})
|
|
67
57
|
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
function focusByKey(el: HTMLElement, key: string) {
|
|
71
|
-
const target = el.querySelector(`[data-path="${key}"]`) as HTMLElement | null
|
|
72
|
-
if (target && target !== document.activeElement) {
|
|
73
|
-
target.focus()
|
|
74
|
-
target.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
58
|
+
// Mount Navigator on the table root
|
|
59
|
+
let tableRef = $state<HTMLElement | null>(null)
|
|
78
60
|
$effect(() => {
|
|
79
61
|
if (!tableRef) return
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (detail.name === 'move') {
|
|
85
|
-
const key = controller.focusedKey
|
|
86
|
-
if (key) focusByKey(el, key)
|
|
87
|
-
}
|
|
88
|
-
if (detail.name === 'select') handleSelectAction()
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
el.addEventListener('action', onAction)
|
|
92
|
-
return () => el.removeEventListener('action', onAction)
|
|
62
|
+
const w = wrapper
|
|
63
|
+
const dir = getComputedStyle(tableRef).direction === 'rtl' ? 'rtl' : 'ltr'
|
|
64
|
+
const nav = new Navigator(tableRef, w, { orientation: 'vertical', dir })
|
|
65
|
+
return () => nav.destroy()
|
|
93
66
|
})
|
|
94
67
|
|
|
95
|
-
|
|
96
|
-
const target = event.target as HTMLElement
|
|
97
|
-
if (!target) return
|
|
98
|
-
const path = target.dataset.path
|
|
99
|
-
if (path !== undefined) {
|
|
100
|
-
controller.moveTo(path)
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function handleSelectAction() {
|
|
105
|
-
if (selectable === false || disabled) return
|
|
106
|
-
|
|
107
|
-
const key = controller.focusedKey
|
|
108
|
-
if (!key) return
|
|
109
|
-
|
|
110
|
-
const proxy = controller.lookup.get(key)
|
|
111
|
-
if (!proxy) return
|
|
112
|
-
|
|
113
|
-
onselect?.(proxy.value, proxy.value as Record<string, unknown>)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// ─── Sort ───────────────────────────────────────────────────────
|
|
68
|
+
// ─── Sort + cell rendering helpers ──────────────────────────────
|
|
117
69
|
|
|
118
70
|
function handleSort(event: MouseEvent, column: TableColumn) {
|
|
119
71
|
if (column.sortable === false || disabled) return
|
|
120
|
-
|
|
121
|
-
onsort?.(controller.sortState)
|
|
72
|
+
proxyTable.sortBy(column.name, event.shiftKey)
|
|
122
73
|
}
|
|
123
74
|
|
|
124
|
-
// ─── Cell rendering helpers ─────────────────────────────────────
|
|
125
|
-
|
|
126
75
|
function getCellValue(row: Record<string, unknown>, column: TableColumn): unknown {
|
|
127
76
|
const fieldName = column.fields?.text ?? column.name
|
|
128
77
|
return row[fieldName]
|
|
129
78
|
}
|
|
130
79
|
|
|
131
80
|
function formatCellValue(row: Record<string, unknown>, column: TableColumn): string {
|
|
132
|
-
const
|
|
133
|
-
if (column.formatter) return column.formatter(
|
|
134
|
-
return
|
|
81
|
+
const cellValue = getCellValue(row, column)
|
|
82
|
+
if (column.formatter) return column.formatter(cellValue, row)
|
|
83
|
+
return cellValue !== null && cellValue !== undefined ? String(cellValue) : ''
|
|
135
84
|
}
|
|
136
85
|
|
|
137
86
|
function getCellIcon(row: Record<string, unknown>, column: TableColumn): string | null {
|
|
@@ -151,8 +100,6 @@
|
|
|
151
100
|
data-disabled={disabled || undefined}
|
|
152
101
|
data-table-responsive={responsive || undefined}
|
|
153
102
|
class={className || undefined}
|
|
154
|
-
onfocusin={handleFocusIn}
|
|
155
|
-
use:navigator={{ wrapper: controller, orientation: 'vertical' }}
|
|
156
103
|
>
|
|
157
104
|
<table role="grid" aria-label={caption} data-table-striped={striped || undefined}>
|
|
158
105
|
{#if caption}
|
|
@@ -161,10 +108,10 @@
|
|
|
161
108
|
|
|
162
109
|
<thead data-table-header>
|
|
163
110
|
{#if headerSnippet}
|
|
164
|
-
{@render headerSnippet(
|
|
111
|
+
{@render headerSnippet(proxyTable.columns, proxyTable.sortState)}
|
|
165
112
|
{:else}
|
|
166
113
|
<tr>
|
|
167
|
-
{#each
|
|
114
|
+
{#each proxyTable.columns as column (column.name)}
|
|
168
115
|
<th
|
|
169
116
|
data-table-header-cell
|
|
170
117
|
data-column={column.name}
|
|
@@ -192,25 +139,27 @@
|
|
|
192
139
|
</thead>
|
|
193
140
|
|
|
194
141
|
<tbody data-table-body>
|
|
195
|
-
{#if
|
|
142
|
+
{#if wrapper.flatView.length === 0}
|
|
196
143
|
{#if emptySnippet}
|
|
197
144
|
<tr data-table-empty-row>
|
|
198
|
-
<td colspan={
|
|
145
|
+
<td colspan={proxyTable.columns.length}>
|
|
199
146
|
{@render emptySnippet()}
|
|
200
147
|
</td>
|
|
201
148
|
</tr>
|
|
202
149
|
{:else}
|
|
203
150
|
<tr data-table-empty-row>
|
|
204
|
-
<td data-table-empty colspan={
|
|
151
|
+
<td data-table-empty colspan={proxyTable.columns.length}
|
|
152
|
+
>{messages.table.empty}</td
|
|
153
|
+
>
|
|
205
154
|
</tr>
|
|
206
155
|
{/if}
|
|
207
156
|
{:else}
|
|
208
|
-
{#each
|
|
209
|
-
{@const row = entry.value as Record<string, unknown>}
|
|
210
|
-
{@const isSelected =
|
|
211
|
-
{@const isFocused =
|
|
157
|
+
{#each wrapper.flatView as entry, rowIndex (entry.key)}
|
|
158
|
+
{@const row = entry.proxy.value as Record<string, unknown>}
|
|
159
|
+
{@const isSelected = wrapper.selectedKeys.has(entry.key)}
|
|
160
|
+
{@const isFocused = wrapper.focusedKey === entry.key}
|
|
212
161
|
{#if rowSnippet}
|
|
213
|
-
{@render rowSnippet(row,
|
|
162
|
+
{@render rowSnippet(row, proxyTable.columns, rowIndex, isSelected)}
|
|
214
163
|
{:else}
|
|
215
164
|
<tr
|
|
216
165
|
data-table-row
|
|
@@ -221,7 +170,7 @@
|
|
|
221
170
|
aria-rowindex={rowIndex + 1}
|
|
222
171
|
tabindex={isFocused ? 0 : -1}
|
|
223
172
|
>
|
|
224
|
-
{#each
|
|
173
|
+
{#each proxyTable.columns as column (column.name)}
|
|
225
174
|
{#if cellSnippet}
|
|
226
175
|
<td data-table-cell data-column={column.name} data-label={column.label ?? column.name} style:text-align={column.align}>
|
|
227
176
|
{@render cellSnippet(getCellValue(row, column), column, row)}
|
|
@@ -6,11 +6,8 @@
|
|
|
6
6
|
ToolbarItemHandlers
|
|
7
7
|
} from '../types/toolbar.js'
|
|
8
8
|
import { getSnippet } from '../types/menu.js'
|
|
9
|
-
import { ProxyItem, messages } from '@rokkit/states'
|
|
10
|
-
import {
|
|
11
|
-
import { SvelteMap } from 'svelte/reactivity'
|
|
12
|
-
import { navigator } from '@rokkit/actions'
|
|
13
|
-
import { untrack } from 'svelte'
|
|
9
|
+
import { ProxyItem, ProxyTree, Wrapper, messages } from '@rokkit/states'
|
|
10
|
+
import { Navigator } from '@rokkit/actions'
|
|
14
11
|
|
|
15
12
|
const {
|
|
16
13
|
items = [],
|
|
@@ -40,125 +37,53 @@
|
|
|
40
37
|
return new ProxyItem(item, userFields)
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
// ───
|
|
44
|
-
|
|
45
|
-
/** Only interactive items are tracked by the controller */
|
|
46
|
-
function isInteractive(item: ToolbarItem): boolean {
|
|
47
|
-
const proxy = createProxy(item)
|
|
48
|
-
const type = proxy.type
|
|
49
|
-
return type !== 'separator' && type !== 'spacer'
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const interactiveItems = $derived(items.filter(isInteractive))
|
|
53
|
-
|
|
54
|
-
/** Map from item → its data-path key (index within interactive items) */
|
|
55
|
-
const itemPathMap = $derived.by(() => {
|
|
56
|
-
const map = new SvelteMap<unknown, string>()
|
|
57
|
-
let idx = 0
|
|
58
|
-
for (const item of items) {
|
|
59
|
-
if (isInteractive(item)) {
|
|
60
|
-
map.set(item, String(idx))
|
|
61
|
-
idx++
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return map
|
|
65
|
-
})
|
|
40
|
+
// ─── Wrapper + Navigator ────────────────────────────────────
|
|
66
41
|
|
|
67
42
|
const orientation = $derived<'horizontal' | 'vertical'>(
|
|
68
43
|
position === 'left' || position === 'right' ? 'vertical' : 'horizontal'
|
|
69
44
|
)
|
|
70
45
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
})
|
|
76
|
-
let containerRef: HTMLElement | null = $state(null)
|
|
77
|
-
|
|
78
|
-
$effect(() => {
|
|
79
|
-
controller.update(interactiveItems)
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
// Sync controller position when a toolbar item receives focus (e.g. Tab from outside)
|
|
83
|
-
$effect(() => {
|
|
84
|
-
if (!containerRef) return
|
|
85
|
-
const el = containerRef
|
|
86
|
-
|
|
87
|
-
function onFocusIn(event: Event) {
|
|
88
|
-
const target = event.target as HTMLElement
|
|
89
|
-
const path = target.closest('[data-path]')?.getAttribute('data-path')
|
|
90
|
-
if (path) {
|
|
91
|
-
controller.moveTo(path)
|
|
92
|
-
}
|
|
93
|
-
}
|
|
46
|
+
function handleItemClick(proxy: ProxyItem) {
|
|
47
|
+
if (proxy.disabled || disabled) return
|
|
48
|
+
onclick?.(proxy.value, proxy.original as ToolbarItem)
|
|
49
|
+
}
|
|
94
50
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
51
|
+
const proxyTree = $derived(new ProxyTree(items, userFields))
|
|
52
|
+
const wrapper = $derived(
|
|
53
|
+
new Wrapper(proxyTree, {
|
|
54
|
+
onselect: (_value: unknown, proxy: ProxyItem) => handleItemClick(proxy),
|
|
55
|
+
collapsible: false
|
|
56
|
+
})
|
|
57
|
+
)
|
|
98
58
|
|
|
99
|
-
|
|
100
|
-
const target = el.querySelector(`[data-path="${key}"]`) as HTMLElement | null
|
|
101
|
-
if (target && target !== document.activeElement) target.focus()
|
|
102
|
-
}
|
|
59
|
+
let containerRef: HTMLElement | null = $state(null)
|
|
103
60
|
|
|
104
|
-
//
|
|
61
|
+
// Mount Navigator on the root; destroy + re-mount when wrapper recreates.
|
|
105
62
|
$effect(() => {
|
|
106
63
|
if (!containerRef) return
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (key) focusKeyedItem(el, key)
|
|
114
|
-
}
|
|
115
|
-
if (detail.name === 'select') handleSelectAction()
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
el.addEventListener('action', onAction)
|
|
119
|
-
return () => el.removeEventListener('action', onAction)
|
|
64
|
+
const w = wrapper
|
|
65
|
+
const dir = getComputedStyle(containerRef).direction === 'rtl' ? 'rtl' : 'ltr'
|
|
66
|
+
const nav = new Navigator(containerRef, w, { orientation, dir })
|
|
67
|
+
// Park focus on the first interactive item so Tab into the toolbar lands there.
|
|
68
|
+
w.first(null)
|
|
69
|
+
return () => nav.destroy()
|
|
120
70
|
})
|
|
121
71
|
|
|
122
|
-
/**
|
|
123
|
-
* Handle the navigator's select action (Enter/Space or click on data-path item)
|
|
124
|
-
*/
|
|
125
|
-
function handleSelectAction() {
|
|
126
|
-
const key = controller.focusedKey
|
|
127
|
-
if (!key) return
|
|
128
|
-
const proxy = controller.lookup.get(key)
|
|
129
|
-
if (!proxy) return
|
|
130
|
-
const itemProxy = createProxy(proxy.value)
|
|
131
|
-
handleItemClick(itemProxy)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Get the data-path key for an item
|
|
136
|
-
*/
|
|
137
|
-
function getPathKey(item: ToolbarItem): string | undefined {
|
|
138
|
-
return itemPathMap.get(item)
|
|
139
|
-
}
|
|
140
|
-
|
|
141
72
|
// ─── Item Handlers ─────────────────────────────────────────────
|
|
142
73
|
|
|
143
|
-
function handleItemClick(proxy: ProxyItem) {
|
|
144
|
-
if (proxy.disabled || disabled) return
|
|
145
|
-
onclick?.(proxy.value, proxy.original as ToolbarItem)
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function handleItemKeyDown(event: KeyboardEvent, proxy: ProxyItem) {
|
|
149
|
-
if (event.key === 'Enter' || event.key === ' ') {
|
|
150
|
-
event.preventDefault()
|
|
151
|
-
handleItemClick(proxy)
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
74
|
/**
|
|
156
|
-
*
|
|
75
|
+
* Handlers passed to custom snippets so they can wire click/keydown
|
|
76
|
+
* back through the toolbar's onclick + disabled semantics.
|
|
157
77
|
*/
|
|
158
78
|
function createHandlers(proxy: ProxyItem): ToolbarItemHandlers {
|
|
159
79
|
return {
|
|
160
80
|
onclick: () => handleItemClick(proxy),
|
|
161
|
-
onkeydown: (event: KeyboardEvent) =>
|
|
81
|
+
onkeydown: (event: KeyboardEvent) => {
|
|
82
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
83
|
+
event.preventDefault()
|
|
84
|
+
handleItemClick(proxy)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
162
87
|
}
|
|
163
88
|
}
|
|
164
89
|
|
|
@@ -224,11 +149,10 @@
|
|
|
224
149
|
<div data-toolbar-divider aria-hidden="true"></div>
|
|
225
150
|
{/snippet}
|
|
226
151
|
|
|
227
|
-
{#snippet renderItem(proxy: ProxyItem,
|
|
152
|
+
{#snippet renderItem(proxy: ProxyItem, pathKey: string)}
|
|
228
153
|
{@const customSnippet = resolveItemSnippet(proxy)}
|
|
229
154
|
{@const handlers = createHandlers(proxy)}
|
|
230
155
|
{@const itemType = proxy.type}
|
|
231
|
-
{@const pathKey = getPathKey(item)}
|
|
232
156
|
|
|
233
157
|
{#if itemType === 'separator'}
|
|
234
158
|
{@render separator()}
|
|
@@ -261,13 +185,12 @@
|
|
|
261
185
|
role="toolbar"
|
|
262
186
|
aria-label={label}
|
|
263
187
|
aria-disabled={disabled || undefined}
|
|
264
|
-
use:navigator={{ wrapper: controller, orientation }}
|
|
265
188
|
>
|
|
266
189
|
{#if hasItems}
|
|
267
190
|
<!-- Data-driven items -->
|
|
268
191
|
{#each items as item, index (index)}
|
|
269
192
|
{@const proxy = createProxy(item)}
|
|
270
|
-
{@render renderItem(proxy,
|
|
193
|
+
{@render renderItem(proxy, String(index))}
|
|
271
194
|
{/each}
|
|
272
195
|
{:else if hasSlots}
|
|
273
196
|
<!-- Slot-based content -->
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* TreeTable — hierarchical rows in a sortable grid.
|
|
4
|
+
*
|
|
5
|
+
* Accepts nested rows (each row may carry a `children: []` array).
|
|
6
|
+
* One designated "hierarchy" column owns the chevron + indent + tree
|
|
7
|
+
* connectors; other columns render plain cell values.
|
|
8
|
+
*
|
|
9
|
+
* Architecture mirrors Table.svelte:
|
|
10
|
+
* ProxyTableTree → Wrapper → Navigator class
|
|
11
|
+
*
|
|
12
|
+
* The difference is in the data layer: ProxyTableTree's sort is
|
|
13
|
+
* applied per-sibling group so the parent/child structure survives
|
|
14
|
+
* any column sort.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { TableColumn, TableSortIcons } from '../types/table.js'
|
|
18
|
+
import type { TreeTableColumn, TreeTableProps } from '../types/tree-table.js'
|
|
19
|
+
import { defaultTableSortIcons } from '../types/table.js'
|
|
20
|
+
import { ProxyItem, ProxyTableTree, Wrapper, messages } from '@rokkit/states'
|
|
21
|
+
import { Navigator } from '@rokkit/actions'
|
|
22
|
+
import { DEFAULT_STATE_ICONS } from '@rokkit/core'
|
|
23
|
+
import Connector from './Connector.svelte'
|
|
24
|
+
|
|
25
|
+
let {
|
|
26
|
+
data = [],
|
|
27
|
+
columns: userColumns,
|
|
28
|
+
value,
|
|
29
|
+
values = $bindable<unknown[]>([]),
|
|
30
|
+
selectable = 'single' as 'single' | 'multi' | false,
|
|
31
|
+
caption,
|
|
32
|
+
size = 'md',
|
|
33
|
+
striped = false,
|
|
34
|
+
disabled = false,
|
|
35
|
+
lineStyle = 'solid',
|
|
36
|
+
fields: userFields,
|
|
37
|
+
onselect,
|
|
38
|
+
onsort,
|
|
39
|
+
class: className = '',
|
|
40
|
+
icons: userIcons,
|
|
41
|
+
header: headerSnippet,
|
|
42
|
+
row: rowSnippet,
|
|
43
|
+
cell: cellSnippet,
|
|
44
|
+
empty: emptySnippet
|
|
45
|
+
}: TreeTableProps = $props()
|
|
46
|
+
|
|
47
|
+
const icons = $derived<TableSortIcons & { opened: string; closed: string }>({
|
|
48
|
+
...defaultTableSortIcons,
|
|
49
|
+
...DEFAULT_STATE_ICONS.node,
|
|
50
|
+
...userIcons
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// ─── ProxyTableTree + Wrapper ───────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const multiselect = $derived(selectable === 'multi')
|
|
56
|
+
|
|
57
|
+
const proxyTable = $derived(
|
|
58
|
+
new ProxyTableTree(data, { columns: userColumns, fields: userFields, onsort })
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const wrapper = $derived(
|
|
62
|
+
new Wrapper(proxyTable, {
|
|
63
|
+
onselect: (_v: unknown, proxy: ProxyItem) => {
|
|
64
|
+
if (selectable === false || disabled) return
|
|
65
|
+
onselect?.(proxy.value, proxy.value as Record<string, unknown>)
|
|
66
|
+
},
|
|
67
|
+
collapsible: true,
|
|
68
|
+
multiselect
|
|
69
|
+
})
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
$effect(() => {
|
|
73
|
+
wrapper.moveToValue(value)
|
|
74
|
+
})
|
|
75
|
+
$effect(() => {
|
|
76
|
+
if (multiselect) values = (wrapper.selected as unknown[]).slice()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// ─── Hierarchy column resolution ────────────────────────────────
|
|
80
|
+
|
|
81
|
+
const hierarchyColumn = $derived(
|
|
82
|
+
proxyTable.columns.find((c: TreeTableColumn) => c.hierarchy) ?? proxyTable.columns[0]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
function isHierarchyColumn(column: TreeTableColumn): boolean {
|
|
86
|
+
return hierarchyColumn?.name === column.name
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Mount Navigator on the table root
|
|
90
|
+
let tableRef = $state<HTMLElement | null>(null)
|
|
91
|
+
$effect(() => {
|
|
92
|
+
if (!tableRef) return
|
|
93
|
+
const w = wrapper
|
|
94
|
+
const dir = getComputedStyle(tableRef).direction === 'rtl' ? 'rtl' : 'ltr'
|
|
95
|
+
const nav = new Navigator(tableRef, w, { orientation: 'vertical', collapsible: true, dir })
|
|
96
|
+
return () => nav.destroy()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// ─── Sort + cell rendering helpers ──────────────────────────────
|
|
100
|
+
|
|
101
|
+
function handleSort(event: MouseEvent, column: TableColumn) {
|
|
102
|
+
if (column.sortable === false || disabled) return
|
|
103
|
+
proxyTable.sortBy(column.name, event.shiftKey)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getCellValue(row: Record<string, unknown>, column: TableColumn): unknown {
|
|
107
|
+
const fieldName = column.fields?.text ?? column.name
|
|
108
|
+
return row[fieldName]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function formatCellValue(row: Record<string, unknown>, column: TableColumn): string {
|
|
112
|
+
const cellValue = getCellValue(row, column)
|
|
113
|
+
if (column.formatter) return column.formatter(cellValue, row)
|
|
114
|
+
return cellValue !== null && cellValue !== undefined ? String(cellValue) : ''
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The hierarchy cell shows the same column at every level. Group rows
|
|
119
|
+
* synthesised by `nestByColumns` carry `__label` (the grouping value),
|
|
120
|
+
* which we render in place of the column's normal field — that way the
|
|
121
|
+
* tree column displays e.g. "EMEA", "France", "Paris" at each depth
|
|
122
|
+
* even though the actual grouping column changes per level.
|
|
123
|
+
*
|
|
124
|
+
* Leaf rows (no `__label`) fall through to the column's regular value.
|
|
125
|
+
*/
|
|
126
|
+
function formatHierarchyValue(row: Record<string, unknown>, column: TableColumn): string {
|
|
127
|
+
if (row.__label !== undefined && row.__label !== null) return String(row.__label)
|
|
128
|
+
return formatCellValue(row, column)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getCellIcon(row: Record<string, unknown>, column: TableColumn): string | null {
|
|
132
|
+
if (!column.fields?.icon) return null
|
|
133
|
+
const iconValue = row[column.fields.icon]
|
|
134
|
+
if (!iconValue) return null
|
|
135
|
+
if (column.iconFormatter) return column.iconFormatter(iconValue)
|
|
136
|
+
return String(iconValue)
|
|
137
|
+
}
|
|
138
|
+
</script>
|
|
139
|
+
|
|
140
|
+
<div
|
|
141
|
+
bind:this={tableRef}
|
|
142
|
+
data-table
|
|
143
|
+
data-tree-table
|
|
144
|
+
data-size={size}
|
|
145
|
+
data-selectable={selectable || undefined}
|
|
146
|
+
data-disabled={disabled || undefined}
|
|
147
|
+
data-line-style={lineStyle}
|
|
148
|
+
class={className || undefined}
|
|
149
|
+
>
|
|
150
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
|
|
151
|
+
<table role="treegrid" aria-label={caption} data-table-striped={striped || undefined}>
|
|
152
|
+
{#if caption}
|
|
153
|
+
<caption data-table-caption>{caption}</caption>
|
|
154
|
+
{/if}
|
|
155
|
+
|
|
156
|
+
<thead data-table-header>
|
|
157
|
+
{#if headerSnippet}
|
|
158
|
+
{@render headerSnippet(proxyTable.columns, proxyTable.sortState)}
|
|
159
|
+
{:else}
|
|
160
|
+
<tr>
|
|
161
|
+
{#each proxyTable.columns as column (column.name)}
|
|
162
|
+
<th
|
|
163
|
+
data-table-header-cell
|
|
164
|
+
data-column={column.name}
|
|
165
|
+
data-sortable={column.sortable !== false || undefined}
|
|
166
|
+
data-sort-order={column.sorted ?? 'none'}
|
|
167
|
+
data-hierarchy={isHierarchyColumn(column) || undefined}
|
|
168
|
+
scope="col"
|
|
169
|
+
aria-sort={column.sorted === 'ascending'
|
|
170
|
+
? 'ascending'
|
|
171
|
+
: column.sorted === 'descending'
|
|
172
|
+
? 'descending'
|
|
173
|
+
: 'none'}
|
|
174
|
+
style:width={column.width}
|
|
175
|
+
style:text-align={column.align}
|
|
176
|
+
onclick={(e) => handleSort(e, column)}
|
|
177
|
+
>
|
|
178
|
+
<span data-table-header-text>{column.label ?? column.name}</span>
|
|
179
|
+
{#if column.sortable !== false}
|
|
180
|
+
{@const sortIcon = icons[column.sorted ?? 'none'] ?? icons.none}
|
|
181
|
+
<span data-table-sort-icon class={sortIcon} aria-hidden="true"></span>
|
|
182
|
+
{/if}
|
|
183
|
+
</th>
|
|
184
|
+
{/each}
|
|
185
|
+
</tr>
|
|
186
|
+
{/if}
|
|
187
|
+
</thead>
|
|
188
|
+
|
|
189
|
+
<tbody data-table-body>
|
|
190
|
+
{#if wrapper.flatView.length === 0}
|
|
191
|
+
{#if emptySnippet}
|
|
192
|
+
<tr data-table-empty-row>
|
|
193
|
+
<td colspan={proxyTable.columns.length}>
|
|
194
|
+
{@render emptySnippet()}
|
|
195
|
+
</td>
|
|
196
|
+
</tr>
|
|
197
|
+
{:else}
|
|
198
|
+
<tr data-table-empty-row>
|
|
199
|
+
<td data-table-empty colspan={proxyTable.columns.length}
|
|
200
|
+
>{messages.table.empty}</td
|
|
201
|
+
>
|
|
202
|
+
</tr>
|
|
203
|
+
{/if}
|
|
204
|
+
{:else}
|
|
205
|
+
{#each wrapper.flatView as entry, rowIndex (entry.key)}
|
|
206
|
+
{@const proxy = entry.proxy}
|
|
207
|
+
{@const row = proxy.value as Record<string, unknown>}
|
|
208
|
+
{@const isSelected = wrapper.selectedKeys.has(entry.key)}
|
|
209
|
+
{@const isFocused = wrapper.focusedKey === entry.key}
|
|
210
|
+
{@const isGroup = row.__group === true}
|
|
211
|
+
{#if rowSnippet}
|
|
212
|
+
{@render rowSnippet(row, proxyTable.columns, rowIndex, isSelected)}
|
|
213
|
+
{:else}
|
|
214
|
+
<tr
|
|
215
|
+
data-table-row
|
|
216
|
+
data-tree-table-row
|
|
217
|
+
data-path={entry.key}
|
|
218
|
+
data-tree-level={entry.level - 1}
|
|
219
|
+
data-tree-has-children={entry.isExpandable || undefined}
|
|
220
|
+
data-selected={isSelected || undefined}
|
|
221
|
+
data-focused={isFocused || undefined}
|
|
222
|
+
data-group={isGroup || undefined}
|
|
223
|
+
aria-selected={isSelected}
|
|
224
|
+
aria-rowindex={rowIndex + 1}
|
|
225
|
+
aria-level={entry.level}
|
|
226
|
+
aria-expanded={entry.isExpandable ? proxy.expanded : undefined}
|
|
227
|
+
tabindex={isFocused ? 0 : -1}
|
|
228
|
+
>
|
|
229
|
+
{#each proxyTable.columns as column (column.name)}
|
|
230
|
+
{@const hierarchy = isHierarchyColumn(column)}
|
|
231
|
+
{#if cellSnippet}
|
|
232
|
+
<td
|
|
233
|
+
data-table-cell
|
|
234
|
+
data-column={column.name}
|
|
235
|
+
data-label={column.label ?? column.name}
|
|
236
|
+
data-hierarchy={hierarchy || undefined}
|
|
237
|
+
style:text-align={column.align}
|
|
238
|
+
>
|
|
239
|
+
{#if hierarchy}
|
|
240
|
+
<span data-tree-table-cell-prefix>
|
|
241
|
+
{#each entry.lineTypes as lineType, lineIndex (lineIndex)}
|
|
242
|
+
{#if lineType === 'icon'}
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
data-tree-table-toggle
|
|
246
|
+
data-accordion-trigger
|
|
247
|
+
aria-label={proxy.expanded ? 'Collapse' : 'Expand'}
|
|
248
|
+
tabindex={-1}
|
|
249
|
+
>
|
|
250
|
+
<span
|
|
251
|
+
class={proxy.expanded ? icons.opened : icons.closed}
|
|
252
|
+
aria-hidden="true"
|
|
253
|
+
></span>
|
|
254
|
+
</button>
|
|
255
|
+
{:else}
|
|
256
|
+
<Connector type={lineType} />
|
|
257
|
+
{/if}
|
|
258
|
+
{/each}
|
|
259
|
+
{#if !entry.isExpandable}
|
|
260
|
+
<span data-tree-table-empty aria-hidden="true"></span>
|
|
261
|
+
{/if}
|
|
262
|
+
</span>
|
|
263
|
+
{/if}
|
|
264
|
+
{@render cellSnippet(getCellValue(row, column), column, row)}
|
|
265
|
+
</td>
|
|
266
|
+
{:else}
|
|
267
|
+
{@const cellIcon = getCellIcon(row, column)}
|
|
268
|
+
<td
|
|
269
|
+
data-table-cell
|
|
270
|
+
data-column={column.name}
|
|
271
|
+
data-label={column.label ?? column.name}
|
|
272
|
+
data-hierarchy={hierarchy || undefined}
|
|
273
|
+
style:text-align={column.align}
|
|
274
|
+
>
|
|
275
|
+
{#if hierarchy}
|
|
276
|
+
<span data-tree-table-cell-prefix>
|
|
277
|
+
{#each entry.lineTypes as lineType, lineIndex (lineIndex)}
|
|
278
|
+
{#if lineType === 'icon'}
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
data-tree-table-toggle
|
|
282
|
+
data-accordion-trigger
|
|
283
|
+
aria-label={proxy.expanded ? 'Collapse' : 'Expand'}
|
|
284
|
+
tabindex={-1}
|
|
285
|
+
>
|
|
286
|
+
<span
|
|
287
|
+
class={proxy.expanded ? icons.opened : icons.closed}
|
|
288
|
+
aria-hidden="true"
|
|
289
|
+
></span>
|
|
290
|
+
</button>
|
|
291
|
+
{:else}
|
|
292
|
+
<Connector type={lineType} />
|
|
293
|
+
{/if}
|
|
294
|
+
{/each}
|
|
295
|
+
{#if !entry.isExpandable}
|
|
296
|
+
<span data-tree-table-empty aria-hidden="true"></span>
|
|
297
|
+
{/if}
|
|
298
|
+
</span>
|
|
299
|
+
{/if}
|
|
300
|
+
{#if cellIcon}
|
|
301
|
+
<span data-cell-icon class={cellIcon} aria-hidden="true"></span>
|
|
302
|
+
{/if}
|
|
303
|
+
<span data-cell-value
|
|
304
|
+
>{hierarchy
|
|
305
|
+
? formatHierarchyValue(row, column)
|
|
306
|
+
: formatCellValue(row, column)}</span
|
|
307
|
+
>
|
|
308
|
+
</td>
|
|
309
|
+
{/if}
|
|
310
|
+
{/each}
|
|
311
|
+
</tr>
|
|
312
|
+
{/if}
|
|
313
|
+
{/each}
|
|
314
|
+
{/if}
|
|
315
|
+
</tbody>
|
|
316
|
+
</table>
|
|
317
|
+
</div>
|
package/src/components/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ export { default as Rating } from './Rating.svelte'
|
|
|
32
32
|
export { default as Stepper } from './Stepper.svelte'
|
|
33
33
|
export { default as Switch } from './Switch.svelte'
|
|
34
34
|
export { default as Table } from './Table.svelte'
|
|
35
|
+
export { default as TreeTable } from './TreeTable.svelte'
|
|
35
36
|
export { default as SearchFilter } from './SearchFilter.svelte'
|
|
36
37
|
export { default as Range } from './Range.svelte'
|
|
37
38
|
export { default as Timeline } from './Timeline.svelte'
|
package/src/index.ts
CHANGED
package/src/types/index.ts
CHANGED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TreeTable Component Types
|
|
3
|
+
*
|
|
4
|
+
* Hierarchical analog of Table — accepts nested rows (each row may
|
|
5
|
+
* carry a `children: []` array) and renders the hierarchy in one
|
|
6
|
+
* designated column with chevron + indent + tree connectors.
|
|
7
|
+
*
|
|
8
|
+
* Reuses the column / sort / cell types from Table.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Snippet } from 'svelte'
|
|
12
|
+
import type { TableColumn, TableSortIcons, SortState, TableFields } from './table.js'
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Column extension
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Column definition for TreeTable — adds an optional `hierarchy` flag
|
|
20
|
+
* to mark which column renders the chevron + indent connectors. When
|
|
21
|
+
* no column is flagged, the first column is treated as the hierarchy
|
|
22
|
+
* column by default.
|
|
23
|
+
*/
|
|
24
|
+
export interface TreeTableColumn extends TableColumn {
|
|
25
|
+
/** Render chevron + tree connectors in this column. Defaults to the first column. */
|
|
26
|
+
hierarchy?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// Snippets
|
|
31
|
+
// =============================================================================
|
|
32
|
+
|
|
33
|
+
export type TreeTableHeaderSnippet = Snippet<[TreeTableColumn[], SortState[]]>
|
|
34
|
+
export type TreeTableRowSnippet = Snippet<
|
|
35
|
+
[Record<string, unknown>, TreeTableColumn[], number, boolean]
|
|
36
|
+
>
|
|
37
|
+
export type TreeTableCellSnippet = Snippet<
|
|
38
|
+
[unknown, TreeTableColumn, Record<string, unknown>]
|
|
39
|
+
>
|
|
40
|
+
export type TreeTableEmptySnippet = Snippet<[]>
|
|
41
|
+
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// Component Props
|
|
44
|
+
// =============================================================================
|
|
45
|
+
|
|
46
|
+
export interface TreeTableProps {
|
|
47
|
+
/** Nested row data — each row may carry a `children: []` array. */
|
|
48
|
+
data?: Record<string, unknown>[]
|
|
49
|
+
|
|
50
|
+
/** Column definitions (auto-derived from the first row if not provided). */
|
|
51
|
+
columns?: TreeTableColumn[]
|
|
52
|
+
|
|
53
|
+
/** Currently selected row value (single-select) */
|
|
54
|
+
value?: unknown
|
|
55
|
+
|
|
56
|
+
/** Currently selected row values (multi-select, bindable) */
|
|
57
|
+
values?: unknown[]
|
|
58
|
+
|
|
59
|
+
/** Selection mode: 'single' (default), 'multi', or false (no selection) */
|
|
60
|
+
selectable?: 'single' | 'multi' | false
|
|
61
|
+
|
|
62
|
+
/** Table caption for accessibility */
|
|
63
|
+
caption?: string
|
|
64
|
+
|
|
65
|
+
/** Size variant */
|
|
66
|
+
size?: 'sm' | 'md' | 'lg'
|
|
67
|
+
|
|
68
|
+
/** Enable alternating row colors */
|
|
69
|
+
striped?: boolean
|
|
70
|
+
|
|
71
|
+
/** Whether the entire table is disabled */
|
|
72
|
+
disabled?: boolean
|
|
73
|
+
|
|
74
|
+
/** Tree connector line style — same options as the Tree component */
|
|
75
|
+
lineStyle?: 'none' | 'solid' | 'dashed' | 'dotted'
|
|
76
|
+
|
|
77
|
+
/** Row-level field mapping (incl. the `children` field name). */
|
|
78
|
+
fields?: TableFields & { children?: string }
|
|
79
|
+
|
|
80
|
+
/** Called when a row is selected */
|
|
81
|
+
onselect?: (value: unknown, row: Record<string, unknown>) => void
|
|
82
|
+
|
|
83
|
+
/** Called when sort state changes */
|
|
84
|
+
onsort?: (sortState: SortState[]) => void
|
|
85
|
+
|
|
86
|
+
/** Additional CSS classes */
|
|
87
|
+
class?: string
|
|
88
|
+
|
|
89
|
+
/** Sort indicator icons */
|
|
90
|
+
icons?: TableSortIcons & { opened?: string; closed?: string }
|
|
91
|
+
|
|
92
|
+
/** Custom header snippet */
|
|
93
|
+
header?: TreeTableHeaderSnippet
|
|
94
|
+
|
|
95
|
+
/** Custom row snippet */
|
|
96
|
+
row?: TreeTableRowSnippet
|
|
97
|
+
|
|
98
|
+
/** Custom cell snippet */
|
|
99
|
+
cell?: TreeTableCellSnippet
|
|
100
|
+
|
|
101
|
+
/** Empty state snippet */
|
|
102
|
+
empty?: TreeTableEmptySnippet
|
|
103
|
+
}
|
package/src/types/tree.ts
CHANGED
|
@@ -24,7 +24,13 @@ import { DEFAULT_STATE_ICONS } from '@rokkit/core'
|
|
|
24
24
|
* Downstream code that needs string field names without null-guards
|
|
25
25
|
* should use `ResolvedTreeFields` rather than `TreeFields`.
|
|
26
26
|
*/
|
|
27
|
-
export interface TreeFields
|
|
27
|
+
export interface TreeFields {
|
|
28
|
+
// Index signature must allow `undefined` so the optional keys below
|
|
29
|
+
// (typed as `string | undefined`) are assignable. Extending
|
|
30
|
+
// `Record<string, string>` here would conflict with the optionals
|
|
31
|
+
// and produce TS2411 in any strict downstream consumer.
|
|
32
|
+
[field: string]: string | undefined
|
|
33
|
+
|
|
28
34
|
/**
|
|
29
35
|
* Name of the field on each tree item that holds its expanded state.
|
|
30
36
|
* The value at that field is expected to be a `boolean`.
|
|
@@ -64,7 +70,11 @@ export type ResolvedTreeFields = Required<TreeFields>
|
|
|
64
70
|
* builders.
|
|
65
71
|
*/
|
|
66
72
|
export function resolveTreeFields(fields: TreeFields = {}): ResolvedTreeFields {
|
|
67
|
-
|
|
73
|
+
// `Required<TreeFields>` cannot strip `undefined` from the index
|
|
74
|
+
// signature on its own, so spread-inference fails the contract even
|
|
75
|
+
// though `defaultTreeFields` provides every key. Cast through unknown
|
|
76
|
+
// to assert the post-merge shape.
|
|
77
|
+
return { ...defaultTreeFields, ...fields } as unknown as ResolvedTreeFields
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
/**
|