@rokkit/ui 1.1.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/ui",
3
- "version": "1.1.0",
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.0",
48
- "@rokkit/data": "1.1.0",
49
- "@rokkit/states": "1.1.0",
50
- "@rokkit/actions": "1.1.0",
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 { TableController, messages } from '@rokkit/states'
5
- import { navigator } from '@rokkit/actions'
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
- // ─── Controller ─────────────────────────────────────────────────
31
+ // ─── ProxyTable + Wrapper ───────────────────────────────────────
33
32
 
34
- let controller = untrack(
35
- () =>
36
- new TableController(data, {
37
- columns: userColumns,
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
- // Sync values binding from controller selection in multi-select mode
45
- $effect(() => {
46
- if (selectable === 'multi') {
47
- values = controller.selected.slice()
48
- }
49
- })
50
- let tableRef = $state<HTMLElement | null>(null)
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 data changes to controller
50
+ // Sync value bindings out
53
51
  $effect(() => {
54
- controller.update(data)
52
+ wrapper.moveToValue(value)
55
53
  })
56
-
57
- // Sync columns changes
58
54
  $effect(() => {
59
- if (userColumns) {
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
- // ─── Focus management ───────────────────────────────────────────
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 el = tableRef
81
-
82
- function onAction(event: Event) {
83
- const detail = (event as CustomEvent).detail
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
- function handleFocusIn(event: FocusEvent) {
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
- controller.sortBy(column.name, event.shiftKey)
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 value = getCellValue(row, column)
133
- if (column.formatter) return column.formatter(value, row)
134
- return value !== null && value !== undefined ? String(value) : ''
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(controller.columns, controller.sortState)}
111
+ {@render headerSnippet(proxyTable.columns, proxyTable.sortState)}
165
112
  {:else}
166
113
  <tr>
167
- {#each controller.columns as column (column.name)}
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 controller.data.length === 0}
142
+ {#if wrapper.flatView.length === 0}
196
143
  {#if emptySnippet}
197
144
  <tr data-table-empty-row>
198
- <td colspan={controller.columns.length}>
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={controller.columns.length}>{messages.table.empty}</td>
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 controller.data as entry, rowIndex (entry.key)}
209
- {@const row = entry.value as Record<string, unknown>}
210
- {@const isSelected = controller.selectedKeys.has(entry.key)}
211
- {@const isFocused = controller.focusedKey === entry.key}
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, controller.columns, rowIndex, isSelected)}
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 controller.columns as column (column.name)}
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 { ListController } from '@rokkit/states'
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
- // ─── Controller + Navigator ────────────────────────────────────
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
- let controller = untrack(() => {
72
- const c = new ListController(interactiveItems, undefined, userFields)
73
- c.moveFirst()
74
- return c
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
- el.addEventListener('focusin', onFocusIn)
96
- return () => el.removeEventListener('focusin', onFocusIn)
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
- function focusKeyedItem(el: HTMLElement, key: string) {
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
- // Focus the item matching controller.focusedKey on navigator action events
61
+ // Mount Navigator on the root; destroy + re-mount when wrapper recreates.
105
62
  $effect(() => {
106
63
  if (!containerRef) return
107
- const el = containerRef
108
-
109
- function onAction(event: Event) {
110
- const detail = (event as CustomEvent).detail
111
- if (detail.name === 'move') {
112
- const key = controller.focusedKey
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
- * Create handlers object for custom snippets
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) => handleItemKeyDown(event, proxy)
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, item: ToolbarItem)}
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, item)}
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>
@@ -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
@@ -34,6 +34,7 @@ export {
34
34
  Stepper,
35
35
  Switch,
36
36
  Table,
37
+ TreeTable,
37
38
  SearchFilter,
38
39
  Range,
39
40
  Timeline,
@@ -36,6 +36,7 @@ export * from './palette.js'
36
36
 
37
37
  // Table Types
38
38
  export * from './table.js'
39
+ export * from './tree-table.js'
39
40
 
40
41
  // SearchFilter Types
41
42
  export * from './search-filter.js'
@@ -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 extends Record<string, string> {
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
- return { ...defaultTreeFields, ...fields }
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
  /**