@rokkit/states 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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @rokkit/states
2
2
 
3
- Reactive state management for Rokkit UI components — ProxyItem, ProxyTree, Wrapper, ListController.
3
+ Reactive state management for Rokkit UI components — ProxyItem, ProxyTree, ProxyTable, Wrapper, LazyWrapper.
4
4
 
5
5
  ## Installation
6
6
 
@@ -16,7 +16,7 @@ bun add @rokkit/states
16
16
 
17
17
  - **ProxyItem / ProxyTree** — normalize arbitrary data objects into a unified, field-mapped interface for rendering
18
18
  - **Wrapper** — navigation controller for persistent components (List, Tree, Tabs). Owns focus, movement, selection, and expansion state
19
- - **ListController** — lower-level reactive base for selection and expansion state
19
+ - **ProxyTable** — tabular data layer that adds columns + sort to a flat ProxyTree
20
20
  - **Utilities** — i18n messages, theme mode tracking, media query breakpoints
21
21
 
22
22
  These classes are used internally by `@rokkit/ui` components. You can also use them directly to build custom components or drive navigation logic outside of the standard components.
@@ -86,17 +86,20 @@ wrapper.collapse(pathKey)
86
86
  wrapper.moveToValue(value) // sync focus to match an external value
87
87
  ```
88
88
 
89
- ### ListControllerbase reactive state
89
+ ### ProxyTabletabular data layer
90
90
 
91
91
  ```js
92
- import { ListController } from '@rokkit/states'
92
+ import { ProxyTable, Wrapper } from '@rokkit/states'
93
93
 
94
- const ctrl = new ListController()
94
+ const table = new ProxyTable(rows, { columns, fields, onsort })
95
+ const wrapper = new Wrapper(table, { onselect, multiselect, collapsible: false })
95
96
 
96
- ctrl.selectedKeys // SvelteSet of selected path keys
97
- ctrl.expandedKeys // SvelteSet of expanded path keys
98
- ctrl.focusedKey // currently focused key
99
- ctrl.data // flat array of visible nodes
97
+ table.columns // [{ name, label, sortable, sorted }, …]
98
+ table.sortState // [{ column, direction }, …] in priority order
99
+ table.sortBy('name', false) // cycle: none → ascending → descending → none
100
+ table.sortBy('age', true) // multi-column sort (Shift+click)
101
+ table.clearSort() // restore original order
102
+ table.update(newRows) // re-applies any active sort
100
103
  ```
101
104
 
102
105
  ### vibe — reactive theme mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/states",
3
- "version": "1.1.1",
3
+ "version": "1.1.4",
4
4
  "description": "Contains generic data manipulation functions that can be used in various components.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,8 +37,8 @@
37
37
  }
38
38
  },
39
39
  "dependencies": {
40
- "@rokkit/core": "1.1.1",
41
- "@rokkit/data": "1.1.1",
40
+ "@rokkit/core": "1.1.4",
41
+ "@rokkit/data": "1.1.4",
42
42
  "d3-array": "^3.2.4",
43
43
  "ramda": "^0.32.0",
44
44
  "svelte": "^5.53.5"
package/src/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  export { alerts } from './alerts.svelte.js'
2
- export { TableController } from './table-controller.svelte.js'
3
2
  export { vibe } from './vibe.svelte.js'
4
- export { ListController } from './list-controller.svelte.js'
5
3
  export { messages } from './messages.svelte.js'
6
4
  export { ProxyItem, LazyProxyItem, BASE_FIELDS } from './proxy-item.svelte.js'
7
5
  export { ProxyTree } from './proxy-tree.svelte.js'
6
+ export { ProxyTable } from './proxy-table.svelte.js'
7
+ export { ProxyTableTree } from './proxy-table-tree.svelte.js'
8
8
  export { Wrapper } from './wrapper.svelte.js'
9
9
  export { LazyWrapper } from './lazy-wrapper.svelte.js'
10
10
  export { watchMedia, defaultBreakpoints } from './media.svelte.js'
@@ -0,0 +1,54 @@
1
+ /**
2
+ * ProxyTableTree
3
+ *
4
+ * Hierarchical analog of ProxyTable. Accepts nested rows (each row may
5
+ * carry a `children: []` array) and exposes the same columns + sortState
6
+ * API as ProxyTable.
7
+ *
8
+ * Sort semantics differ from the flat case: sorting is applied within
9
+ * each parent's children array, so the parent/child structure is
10
+ * preserved. A single top-level sort by 'name' reorders siblings at
11
+ * every depth but never lifts a child out of its parent.
12
+ *
13
+ * Use the `nestByPath` / `nestByColumns` helpers from `@rokkit/data`
14
+ * to convert path-string or column-array flat shapes into the nested
15
+ * shape this class consumes.
16
+ */
17
+
18
+ import { BASE_FIELDS } from '@rokkit/core'
19
+ import { ProxyTable } from './proxy-table.svelte.js'
20
+
21
+ export class ProxyTableTree extends ProxyTable {
22
+ #childField
23
+
24
+ /**
25
+ * @param {Array<Record<string, unknown>>} [data] Nested rows.
26
+ * @param {{ columns?: Array, fields?: object, onsort?: Function }} [options]
27
+ */
28
+ constructor(data = [], options = {}) {
29
+ super(data, options)
30
+ const fields = options.fields ?? {}
31
+ this.#childField = fields.children ?? BASE_FIELDS.children
32
+ }
33
+
34
+ /**
35
+ * Recursively apply the current sortState to a nested row array.
36
+ * Sorts siblings within each parent — children stay attached to their
37
+ * own parent regardless of the sort order chosen.
38
+ *
39
+ * @param {Array<Record<string, unknown>>} rows
40
+ * @returns {Array<Record<string, unknown>>}
41
+ */
42
+ _sortedData(rows) {
43
+ if (this.sortState.length === 0) return rows
44
+ const sorted = [...rows].sort((a, b) => this._compareRows(a, b))
45
+ const field = this.#childField
46
+ return sorted.map((row) => {
47
+ const children = row[field]
48
+ if (Array.isArray(children) && children.length > 0) {
49
+ return { ...row, [field]: this._sortedData(children) }
50
+ }
51
+ return row
52
+ })
53
+ }
54
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * ProxyTable
3
+ *
4
+ * Tabular analog of ProxyTree. Owns flat row data plus column metadata and
5
+ * sort state. Composes ProxyTree's reactive data layer (flatView + lookup)
6
+ * so a plain Wrapper can navigate over a ProxyTable without modification.
7
+ *
8
+ * Splits cleanly into:
9
+ * ProxyTree — owns rows-as-proxies + flatView + lookup
10
+ * ProxyTable adds columns + sortState + sortBy/clearSort/update
11
+ * Wrapper navigates over either
12
+ *
13
+ * Sort semantics:
14
+ * - sortBy(name) single-column sort (clears any prior sort)
15
+ * - sortBy(name, true) multi-column sort (extends the sort stack;
16
+ * repeat with same name to cycle the direction;
17
+ * direction 'none' removes that column from the stack)
18
+ * - Three-state cycle per column: none → ascending → descending → none
19
+ * - clearSort() resets to original data order
20
+ */
21
+
22
+ import { ascending, descending } from 'd3-array'
23
+ import { deriveColumns } from '@rokkit/data'
24
+ import { ProxyTree } from './proxy-tree.svelte.js'
25
+
26
+ // ─── ProxyTable ───────────────────────────────────────────────────────────────
27
+
28
+ export class ProxyTable extends ProxyTree {
29
+ columns = $state([])
30
+ sortState = $state([])
31
+
32
+ #rawData
33
+ #onsort
34
+
35
+ /**
36
+ * @param {Array<Record<string, unknown>>} [data]
37
+ * @param {{ columns?: Array, fields?: object, onsort?: Function }} [options]
38
+ */
39
+ constructor(data = [], options = {}) {
40
+ super(data, options.fields)
41
+ this.#rawData = data
42
+ this.#onsort = options.onsort
43
+ this.columns = options.columns?.length
44
+ ? options.columns.map((c) => ({ sortable: true, sorted: 'none', ...c }))
45
+ : deriveColumns(data)
46
+ }
47
+
48
+ // ─── Updates ─────────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Replace underlying data. Re-applies any active sort so the visible
52
+ * rows stay in their sorted order.
53
+ * @param {Array<Record<string, unknown>>} data
54
+ */
55
+ update(data) {
56
+ this.#rawData = data
57
+ if (this.sortState.length === 0) {
58
+ this.replace(data)
59
+ } else {
60
+ this.replace(this._sortedData(this.#rawData))
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Protected accessor for subclasses that need to re-sort the underlying
66
+ * data (e.g. ProxyTableTree on column-update).
67
+ */
68
+ get _rawData() {
69
+ return this.#rawData
70
+ }
71
+
72
+ /**
73
+ * Replace column definitions. Preserves any existing sort indicators
74
+ * for columns that survive the rename.
75
+ * @param {Array} columns
76
+ */
77
+ updateColumns(columns) {
78
+ const prior = Object.fromEntries(this.columns.map((c) => [c.name, c.sorted ?? 'none']))
79
+ this.columns = columns.map((c) => ({
80
+ sortable: true,
81
+ sorted: prior[c.name] ?? 'none',
82
+ ...c
83
+ }))
84
+ }
85
+
86
+ // ─── Sort API ────────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Toggle sort on a column. Direction cycle: none → ascending → descending → none.
90
+ * Pass `extend=true` (Shift+click) to push onto the multi-column stack.
91
+ * Fires the `onsort` callback after applying.
92
+ *
93
+ * @param {string} columnName
94
+ * @param {boolean} [extend]
95
+ */
96
+ sortBy(columnName, extend = false) {
97
+ const col = this.columns.find((c) => c.name === columnName)
98
+ if (!col || col.sortable === false) return
99
+ const nextDirection = this.#nextSortDirection(col)
100
+ this.sortState = extend
101
+ ? this.#extendSortState(columnName, nextDirection)
102
+ : this.#singleSortState(columnName, nextDirection)
103
+ this.#syncColumnFlags()
104
+ this.replace(this._sortedData(this.#rawData))
105
+ this.#onsort?.(this.sortState)
106
+ }
107
+
108
+ /** Clear all sort state and restore the original data order. */
109
+ clearSort() {
110
+ this.sortState = []
111
+ this.columns = this.columns.map((c) => ({ ...c, sorted: 'none' }))
112
+ this.replace(this.#rawData)
113
+ this.#onsort?.(this.sortState)
114
+ }
115
+
116
+ // ─── Sort internals ──────────────────────────────────────────────────────
117
+
118
+ #nextSortDirection(col) {
119
+ const cycle = { none: 'ascending', ascending: 'descending', descending: 'none' }
120
+ return cycle[col.sorted ?? 'none']
121
+ }
122
+
123
+ #singleSortState(columnName, nextDirection) {
124
+ return nextDirection === 'none' ? [] : [{ column: columnName, direction: nextDirection }]
125
+ }
126
+
127
+ #extendSortState(columnName, nextDirection) {
128
+ const existing = this.sortState.findIndex((s) => s.column === columnName)
129
+ if (nextDirection === 'none') {
130
+ return this.sortState.filter((s) => s.column !== columnName)
131
+ }
132
+ if (existing >= 0) {
133
+ return this.sortState.map((s) =>
134
+ s.column === columnName ? { ...s, direction: nextDirection } : s
135
+ )
136
+ }
137
+ return [...this.sortState, { column: columnName, direction: nextDirection }]
138
+ }
139
+
140
+ #syncColumnFlags() {
141
+ this.columns = this.columns.map((c) => {
142
+ const sort = this.sortState.find((s) => s.column === c.name)
143
+ return { ...c, sorted: sort ? sort.direction : 'none' }
144
+ })
145
+ }
146
+
147
+ /**
148
+ * Apply the current sortState to a row array. Subclasses (ProxyTableTree)
149
+ * override to walk hierarchical structures.
150
+ *
151
+ * @param {Array<Record<string, unknown>>} rows
152
+ * @returns {Array<Record<string, unknown>>}
153
+ */
154
+ _sortedData(rows) {
155
+ if (this.sortState.length === 0) return rows
156
+ return [...rows].sort((a, b) => this._compareRows(a, b))
157
+ }
158
+
159
+ /**
160
+ * Compare two rows under the current sortState. Used by `_sortedData()`
161
+ * and by subclasses that sort over a hierarchical row tree.
162
+ *
163
+ * @param {Record<string, unknown>} a
164
+ * @param {Record<string, unknown>} b
165
+ * @returns {number}
166
+ */
167
+ _compareRows(a, b) {
168
+ for (const { column, direction } of this.sortState) {
169
+ const cmp = direction === 'ascending' ? ascending : descending
170
+ const result = cmp(a[column], b[column])
171
+ if (result !== 0) return result
172
+ }
173
+ return 0
174
+ }
175
+ }
@@ -182,6 +182,19 @@ export class ProxyTree {
182
182
  this.#rootProxies = [...this.#rootProxies, ...newProxies]
183
183
  }
184
184
 
185
+ /**
186
+ * Replace all root items. Reassigns #rootProxies to trigger $derived.
187
+ * Keys are regenerated from the new item positions — focusedKey
188
+ * continuity across replace is the caller's concern.
189
+ *
190
+ * @param {unknown[]} items Raw items to use as new roots
191
+ */
192
+ replace(items) {
193
+ this.#rootProxies = (items ?? []).map((raw, i) =>
194
+ this.#factory(raw, this.#fields, String(i), 1)
195
+ )
196
+ }
197
+
185
198
  /**
186
199
  * Add children to an existing proxy node.
187
200
  * Uses proxy.set('children', rawItems) so ProxyItem's version counter
@@ -15,8 +15,15 @@
15
15
  *
16
16
  * Dropdown variants (Select, Menu) extend this class and override cancel() / blur()
17
17
  * to close the dropdown and return focus to the trigger.
18
+ *
19
+ * Multi-select opt-in: pass `multiselect: true` to enable extend(path) and
20
+ * range(path) — ctrl/cmd-click and shift-click respectively. When false
21
+ * (default), both methods are no-ops so existing single-select consumers
22
+ * are unaffected.
18
23
  */
19
24
 
25
+ import { SvelteSet } from 'svelte/reactivity'
26
+
20
27
  export class Wrapper {
21
28
  // ─── Data ──────────────────────────────────────────────────────────────────
22
29
 
@@ -44,15 +51,22 @@ export class Wrapper {
44
51
 
45
52
  #collapsible
46
53
 
54
+ // ─── Multi-select ──────────────────────────────────────────────────────────
55
+
56
+ #multiselect
57
+ #selectedKeys = new SvelteSet()
58
+ #anchorKey = $state(null)
59
+
47
60
  /**
48
61
  * @param {import('./proxy-tree.svelte.js').ProxyTree} proxyTree
49
- * @param {{ onselect?: Function, onchange?: Function, collapsible?: boolean }} [options]
62
+ * @param {{ onselect?: Function, onchange?: Function, collapsible?: boolean, multiselect?: boolean }} [options]
50
63
  */
51
64
  constructor(proxyTree, options = {}) {
52
65
  this.#proxyTree = proxyTree
53
66
  this.#onselect = options.onselect
54
67
  this.#onchange = options.onchange
55
68
  this.#collapsible = options.collapsible ?? true
69
+ this.#multiselect = options.multiselect ?? false
56
70
  }
57
71
 
58
72
  // ─── IWrapper: state read by Navigator ─────────────────────────────────────
@@ -142,9 +156,16 @@ export class Wrapper {
142
156
 
143
157
  /**
144
158
  * Fire value-change callbacks for a leaf selection.
159
+ * In multi-select mode, replaces selectedKeys with just this key
160
+ * (matching ListController's single-click semantics).
145
161
  * @param {*} proxy
162
+ * @param {string} key
146
163
  */
147
- #selectLeaf(proxy) {
164
+ #selectLeaf(proxy, key) {
165
+ if (this.#multiselect) {
166
+ this.#selectedKeys.clear()
167
+ this.#selectedKeys.add(key)
168
+ }
148
169
  if (proxy.value !== this.#selectedValue) {
149
170
  this.#selectedValue = proxy.value
150
171
  this.#onchange?.(proxy.value, proxy)
@@ -156,11 +177,11 @@ export class Wrapper {
156
177
  * Select item at path (or focusedKey when path is null).
157
178
  * Groups toggle expanded (only when collapsible=true). Leaves fire onchange and onselect callbacks.
158
179
  */
159
- #selectProxy(proxy) {
180
+ #selectProxy(proxy, key) {
160
181
  if (proxy.hasChildren) {
161
182
  if (this.#collapsible) proxy.expanded = !proxy.expanded
162
183
  } else {
163
- this.#selectLeaf(proxy)
184
+ this.#selectLeaf(proxy, key)
164
185
  }
165
186
  }
166
187
 
@@ -168,8 +189,9 @@ export class Wrapper {
168
189
  const key = path ?? this.#focusedKey
169
190
  if (!key) return
170
191
  this.#focusedKey = key
192
+ this.#anchorKey = key
171
193
  const proxy = this.#proxyTree.lookup.get(key)
172
- if (proxy) this.#selectProxy(proxy)
194
+ if (proxy) this.#selectProxy(proxy, key)
173
195
  }
174
196
 
175
197
  /**
@@ -218,11 +240,74 @@ export class Wrapper {
218
240
  /** Persistent list: no-op. Override in dropdown wrappers to close + restore trigger focus. */
219
241
  blur() {}
220
242
 
221
- /** Multiselect toggle — not yet implemented. */
222
- extend(_path) {}
243
+ /**
244
+ * Multi-select toggle — Ctrl/Cmd-click or Ctrl/Cmd-Space.
245
+ * When `multiselect: false` (default) this is a no-op so single-select
246
+ * consumers see the same behavior as before.
247
+ *
248
+ * Toggles `path` in `selectedKeys`, sets anchor for subsequent range select,
249
+ * and fires `onselect` for leaf targets. Group targets update focus + anchor
250
+ * but do not toggle into selectedKeys (groups aren't selectable values).
251
+ *
252
+ * @param {string|null} path
253
+ */
254
+ extend(path) {
255
+ if (!this.#multiselect) return
256
+ const key = path ?? this.#focusedKey
257
+ if (!key) return
258
+ const proxy = this.#proxyTree.lookup.get(key)
259
+ if (!proxy) return
260
+
261
+ this.#focusedKey = key
262
+ this.#anchorKey = key
263
+ if (proxy.hasChildren) return
264
+
265
+ if (this.#selectedKeys.has(key)) {
266
+ this.#selectedKeys.delete(key)
267
+ } else {
268
+ this.#selectedKeys.add(key)
269
+ }
270
+ this.#onselect?.(proxy.value, proxy)
271
+ }
272
+
273
+ /**
274
+ * Multi-select range — Shift-click or Shift-Space.
275
+ * Selects every navigable item from the anchor (last single-click target,
276
+ * or the focused item if no anchor) to `path`, inclusive. Replaces any
277
+ * existing selection.
278
+ *
279
+ * When `multiselect: false` (default) this is a no-op.
280
+ *
281
+ * @param {string|null} path
282
+ */
283
+ range(path) {
284
+ if (!this.#multiselect) return
285
+ const key = path ?? this.#focusedKey
286
+ if (!key) return
287
+
288
+ const anchorKey = this.#anchorKey ?? this.#focusedKey
289
+ if (!anchorKey) {
290
+ this.select(path)
291
+ return
292
+ }
293
+
294
+ const nav = this.#navigable
295
+ const anchorIdx = nav.findIndex((n) => n.key === anchorKey)
296
+ const targetIdx = nav.findIndex((n) => n.key === key)
297
+ if (anchorIdx < 0 || targetIdx < 0) return
298
+
299
+ const lo = Math.min(anchorIdx, targetIdx)
300
+ const hi = Math.max(anchorIdx, targetIdx)
301
+
302
+ this.#selectedKeys.clear()
303
+ for (let i = lo; i <= hi; i++) {
304
+ this.#selectedKeys.add(nav[i].key)
305
+ }
306
+ this.#focusedKey = key
223
307
 
224
- /** Multiselect range — not yet implemented. */
225
- range(_path) {}
308
+ const proxy = this.#proxyTree.lookup.get(key)
309
+ if (proxy && !proxy.hasChildren) this.#onselect?.(proxy.value, proxy)
310
+ }
226
311
 
227
312
  // ─── IWrapper: typeahead ───────────────────────────────────────────────────
228
313
 
@@ -269,4 +354,31 @@ export class Wrapper {
269
354
  get proxyTree() {
270
355
  return this.#proxyTree
271
356
  }
357
+
358
+ /**
359
+ * Reactive set of selected keys. Tracks meaningfully only when
360
+ * `multiselect: true` — in single-select mode it mirrors the most
361
+ * recent `select()` target (one key at a time).
362
+ *
363
+ * @returns {SvelteSet<string>}
364
+ */
365
+ get selectedKeys() {
366
+ return this.#selectedKeys
367
+ }
368
+
369
+ /**
370
+ * In multi-select mode: array of selected leaf values (in selectedKeys order).
371
+ * In single-select mode: the last selected value (back-compat with `select()`).
372
+ *
373
+ * @returns {unknown[] | unknown}
374
+ */
375
+ get selected() {
376
+ if (!this.#multiselect) return this.#selectedValue
377
+ const out = []
378
+ for (const key of this.#selectedKeys) {
379
+ const proxy = this.#proxyTree.lookup.get(key)
380
+ if (proxy !== undefined) out.push(proxy.value)
381
+ }
382
+ return out
383
+ }
272
384
  }
@@ -1,105 +0,0 @@
1
- import { getKeyFromPath, DEFAULT_FIELDS, getNestedFields } from '@rokkit/core'
2
- import { SvelteMap } from 'svelte/reactivity'
3
-
4
- /**
5
- * @param {*} item
6
- * @param {import('@rokkit/core').FieldMapping} fields
7
- * @param {string} key
8
- * @param {Set<string>|null} expandedKeys
9
- * @returns {boolean}
10
- */
11
- function isExpanded(item, fields, key, expandedKeys) {
12
- const hasChildren = Array.isArray(item[fields.children]) && item[fields.children].length > 0
13
- if (!hasChildren) return false
14
- return expandedKeys ? expandedKeys.has(key) : item[fields.expanded]
15
- }
16
-
17
- /**
18
- * @param {Array<*>} data Accumulator array
19
- * @param {{ item: *, index: number, fields: *, path: Array<number>, level: number, expandedKeys: Set<string>|null }} ctx
20
- */
21
- function visitNode(data, ctx) {
22
- const { item, index, fields, path, level, expandedKeys } = ctx
23
- const itemPath = [...path, index]
24
- const key = getKeyFromPath(itemPath)
25
- const hasChildren = Array.isArray(item[fields.children]) && item[fields.children].length > 0
26
- data.push({ key, value: item, level, hasChildren })
27
- if (isExpanded(item, fields, key, expandedKeys)) {
28
- data.push(
29
- ...flatVisibleNodes(item[fields.children], getNestedFields(fields), itemPath, expandedKeys)
30
- )
31
- }
32
- }
33
-
34
- /**
35
- * @param {Array<*>} items
36
- * @param {import('@rokkit/core').FieldMapping} fields
37
- * @param {Array<number>} path
38
- * @param {Set<string>|null} expandedKeys
39
- * @returns {Array}
40
- */
41
- function collectVisibleNodes(items, fields, path, expandedKeys) {
42
- const data = []
43
- const level = path.length
44
- for (let i = 0; i < items.length; i++) {
45
- visitNode(data, { item: items[i], index: i, fields, path, level, expandedKeys })
46
- }
47
- return data
48
- }
49
-
50
-
51
- export function flatVisibleNodes(items, fields = DEFAULT_FIELDS, path = [], expandedKeys = null) {
52
- if (!items || !Array.isArray(items)) return []
53
- return collectVisibleNodes(items, fields, path, expandedKeys)
54
- }
55
-
56
- /**
57
- * Merge child lookup entries into parent lookup.
58
- * @param {SvelteMap} lookup
59
- * @param {SvelteMap} childLookup
60
- */
61
- function mergeChildLookup(lookup, childLookup) {
62
- for (const [childKey, childValue] of childLookup.entries()) {
63
- lookup.set(childKey, childValue)
64
- }
65
- }
66
-
67
- /**
68
- * Create a lookup entry for an item.
69
- * @param {*} item
70
- * @param {*} norm Normalised item object
71
- * @param {*} fields
72
- * @returns {object}
73
- */
74
- function makeLookupEntry(item, norm, fields) {
75
- return {
76
- value: item,
77
- original: item,
78
- label: String(norm[fields.label] ?? ''),
79
- get: (fieldName) => norm[fields[fieldName] ?? fieldName]
80
- }
81
- }
82
-
83
- /**
84
- * @param {SvelteMap} lookup Accumulator map
85
- * @param {{ item: *, index: number, fields: *, path: Array<number> }} ctx
86
- */
87
-
88
- function visitLookupNode(lookup, ctx) {
89
- const { item, index, fields, path } = ctx
90
- const itemPath = [...path, index]
91
- const key = getKeyFromPath(itemPath)
92
- const norm = typeof item === 'object' && item !== null ? item : { [fields.label]: item }
93
- lookup.set(key, makeLookupEntry(item, norm, fields))
94
- const children = norm[fields.children] ?? []
95
- if (Array.isArray(children) && children.length > 0) {
96
- mergeChildLookup(lookup, deriveLookupWithProxy(children, getNestedFields(fields), itemPath))
97
- }
98
- }
99
-
100
- export function deriveLookupWithProxy(items, fields = DEFAULT_FIELDS, path = []) {
101
- const lookup = new SvelteMap()
102
- if (!items || !Array.isArray(items)) return lookup
103
- items.forEach((item, index) => visitLookupNode(lookup, { item, index, fields, path }))
104
- return lookup
105
- }
@@ -1,347 +0,0 @@
1
- import { FieldMapper, DEFAULT_FIELDS, getKeyFromPath, getNestedFields } from '@rokkit/core'
2
- import { equals } from 'ramda'
3
- import { SvelteSet } from 'svelte/reactivity'
4
- import { deriveLookupWithProxy, flatVisibleNodes } from './derive.svelte'
5
-
6
- export class ListController {
7
- items = $state(null)
8
- fields = DEFAULT_FIELDS
9
- mappers = []
10
- #options = $state({})
11
- // lookup = new Map()
12
- selectedKeys = new SvelteSet()
13
- expandedKeys = new SvelteSet()
14
- focusedKey = $state(null)
15
- #currentIndex = -1
16
- #anchorKey = null
17
-
18
- selected = $derived(Array.from(this.selectedKeys).map((key) => this.lookup.get(key).value))
19
- focused = $derived(this.lookup.get(this.focusedKey)?.value)
20
- data = $derived(flatVisibleNodes(this.items, this.fields, [], this.expandedKeys))
21
- lookup = $derived(deriveLookupWithProxy(this.items, this.fields))
22
-
23
- constructor(items, value, fields, options) {
24
- this.items = items
25
- this.fields = { ...DEFAULT_FIELDS, ...fields }
26
- this.mappers.push(new FieldMapper(fields))
27
- this.#options = { multiselect: false, ...options }
28
- this.#initExpandedKeys(items, this.fields)
29
- this.init(value)
30
- }
31
-
32
- /**
33
- * Process a group item's children for expanded key initialization.
34
- * @private
35
- */
36
- #initExpandedGroup(item, itemPath, children, fields) {
37
- if (item[fields.expanded]) {
38
- this.expandedKeys.add(getKeyFromPath(itemPath))
39
- }
40
- this.#initExpandedKeys(children, getNestedFields(fields), itemPath)
41
- }
42
-
43
- /**
44
- * Process a single item for expanded key initialization.
45
- * @private
46
- */
47
-
48
- #initExpandedItem(item, index, fields, path) {
49
- if (item === null || item === undefined || typeof item !== 'object') return
50
- const children = item[fields.children]
51
- if (!Array.isArray(children) || children.length === 0) return
52
- this.#initExpandedGroup(item, [...path, index], children, fields)
53
- }
54
-
55
- /**
56
- * Scan items for pre-existing expanded flags and populate expandedKeys
57
- * @private
58
- */
59
- #initExpandedKeys(items, fields, path = []) {
60
- if (!items || !Array.isArray(items)) return
61
- items.forEach((item, index) => this.#initExpandedItem(item, index, fields, path))
62
- }
63
-
64
- /**
65
- * @private
66
- * @param {Array<*>} items
67
- * @param {*} value
68
- */
69
- init(value) {
70
- // items.forEach((item, index) => this.lookup.set(String(index), item))
71
- this.moveToValue(value)
72
- }
73
-
74
- get isNested() {
75
- return this.mappers.length > 1
76
- }
77
-
78
- get currentKey() {
79
- return this.focusedKey
80
- }
81
-
82
- get currentIndex() {
83
- return this.#currentIndex
84
- }
85
-
86
- /**
87
- * @private
88
- * @param {*} value
89
- * @returns
90
- */
91
- findByValue(value) {
92
- // Try exact match first (full object comparison)
93
- let index = this.data.findIndex((row) => equals(row.value, value))
94
-
95
- // Fallback: match by extracted value field (e.g. primitive 'a' against { text: 'A', value: 'a' })
96
- if (index < 0) {
97
- const valueField = this.fields.value
98
- index = this.data.findIndex(
99
- (row) =>
100
- typeof row.value === 'object' &&
101
- row.value !== null &&
102
- equals(row.value[valueField], value)
103
- )
104
- }
105
-
106
- return index < 0 ? { index } : { index, ...this.data[index] }
107
- }
108
-
109
- /**
110
- * @private
111
- * @param {*} value
112
- * @returns
113
- */
114
- moveToValue(value = null) {
115
- const { index, key } = this.findByValue(value)
116
-
117
- this.selectedKeys.clear()
118
- if (index >= 0) {
119
- this.moveToIndex(index)
120
- this.selectedKeys.add(key)
121
- } else {
122
- this.focusedKey = null
123
- this.#currentIndex = -1
124
- }
125
- return true
126
- }
127
-
128
- /**
129
- *
130
- * @param {string|number} path - path key string (e.g. "0", "1-0", "2-1-3")
131
- * @returns
132
- */
133
- moveTo(path) {
134
- const key = String(path)
135
- const index = this.data.findIndex((row) => row.key === key)
136
- return index >= 0 ? this.moveToIndex(index) : false
137
- }
138
-
139
- /**
140
- * @private
141
- * @param {number} index
142
- */
143
- moveToIndex(index) {
144
- if (index >= 0 && index < this.data.length && this.#currentIndex !== index) {
145
- this.#currentIndex = index
146
- this.focusedKey = this.data[index].key
147
- return true
148
- }
149
- return false
150
- }
151
-
152
- /**
153
- * @private
154
- * @param {number} index
155
- * @returns {boolean}
156
- */
157
- #isDisabled(index) {
158
- const item = this.data[index]?.value
159
- if (item === null || item === undefined || typeof item !== 'object') return false
160
- return item[this.fields.disabled] === true
161
- }
162
-
163
- movePrev() {
164
- if (this.#currentIndex < 0) return this.moveLast()
165
- for (let i = this.#currentIndex - 1; i >= 0; i--) {
166
- if (!this.#isDisabled(i)) return this.moveToIndex(i)
167
- }
168
- return false
169
- }
170
-
171
- moveNext() {
172
- for (let i = this.#currentIndex + 1; i < this.data.length; i++) {
173
- if (!this.#isDisabled(i)) return this.moveToIndex(i)
174
- }
175
- return false
176
- }
177
-
178
- moveFirst() {
179
- for (let i = 0; i < this.data.length; i++) {
180
- if (!this.#isDisabled(i)) return this.moveToIndex(i)
181
- }
182
- return false
183
- }
184
-
185
- moveLast() {
186
- for (let i = this.data.length - 1; i >= 0; i--) {
187
- if (!this.#isDisabled(i)) return this.moveToIndex(i)
188
- }
189
- return false
190
- }
191
-
192
- /**
193
- * Toggles the selection.
194
- * @private
195
- * @param {string} key
196
- */
197
- toggleSelection(key) {
198
- if (this.selectedKeys.has(key)) {
199
- this.selectedKeys.delete(key)
200
- } else {
201
- this.selectedKeys.add(key)
202
- }
203
-
204
- return true
205
- }
206
-
207
- /**
208
- *
209
- * @param {string} selectedKey
210
- * @returns
211
- */
212
- select(selectedKey) {
213
- const key = selectedKey ?? this.focusedKey
214
-
215
- if (!this.lookup.has(key)) return false
216
-
217
- if (this.focusedKey !== key) {
218
- const { index } = this.findByValue(this.lookup.get(key).value)
219
- this.moveToIndex(index)
220
- }
221
-
222
- if (!this.selectedKeys.has(key)) {
223
- this.selectedKeys.clear()
224
- this.selectedKeys.add(key)
225
- }
226
-
227
- this.#anchorKey = key
228
- return true
229
- }
230
-
231
- /**
232
- *
233
- * @param {string} selectedKey
234
- * @returns
235
- */
236
- extendSelection(selectedKey) {
237
- const key = selectedKey ?? this.focusedKey
238
-
239
- if (!this.lookup.has(key)) return false
240
-
241
- if (this.#options.multiselect) {
242
- this.#anchorKey = key
243
- return this.toggleSelection(key)
244
- } else {
245
- return this.select(key)
246
- }
247
- }
248
-
249
- /**
250
- * Select non-disabled items in index range [start, end] inclusive.
251
- * @private
252
- */
253
- #selectIndexRange(start, end, targetIndex) {
254
- this.selectedKeys.clear()
255
- for (let i = start; i <= end; i++) {
256
- if (!this.#isDisabled(i)) {
257
- this.selectedKeys.add(this.data[i].key)
258
- }
259
- }
260
- this.moveToIndex(targetIndex)
261
- }
262
-
263
- /**
264
- * Find indices for anchor and target keys. Returns null if either is missing.
265
- * @private
266
- */
267
- #findRangeIndices(anchorKey, targetKey) {
268
- const anchorIndex = this.data.findIndex((row) => row.key === anchorKey)
269
- const targetIndex = this.data.findIndex((row) => row.key === targetKey)
270
- if (anchorIndex < 0 || targetIndex < 0) return null
271
- return { anchorIndex, targetIndex }
272
- }
273
-
274
- /**
275
- * Apply range selection using pre-computed anchor and target indices.
276
- * @private
277
- */
278
- #applyRangeSelection(key) {
279
- const anchorKey = this.#anchorKey ?? this.focusedKey
280
- if (!anchorKey) return this.select(key)
281
- const indices = this.#findRangeIndices(anchorKey, key)
282
- if (!indices) return false
283
- const { anchorIndex, targetIndex } = indices
284
- this.#selectIndexRange(
285
- Math.min(anchorIndex, targetIndex),
286
- Math.max(anchorIndex, targetIndex),
287
- targetIndex
288
- )
289
- return true
290
- }
291
-
292
- /**
293
- * Select all non-disabled items between the anchor and the given key (inclusive).
294
- * Used for Shift+click range selection in multiselect mode.
295
- * @param {string} selectedKey
296
- * @returns {boolean}
297
- */
298
- selectRange(selectedKey) {
299
- const key = selectedKey ?? this.focusedKey
300
- if (!this.lookup.has(key)) return false
301
- if (!this.#options.multiselect) return this.select(key)
302
- return this.#applyRangeSelection(key)
303
- }
304
-
305
- /**
306
- * Compute the start index for a findByText search.
307
- * @private
308
- */
309
- #findStartIndex(startAfterKey) {
310
- if (startAfterKey === null) return 0
311
- const idx = this.data.findIndex((row) => row.key === startAfterKey)
312
- return idx >= 0 ? idx + 1 : 0
313
- }
314
-
315
- /**
316
- * Check if an item at idx matches the query prefix.
317
- * @private
318
- */
319
- #matchesText(idx, q) {
320
- if (this.#isDisabled(idx)) return false
321
- const entry = this.lookup.get(this.data[idx].key)
322
- const text = entry?.label ?? ''
323
- return String(text).toLowerCase().startsWith(q)
324
- }
325
-
326
- /**
327
- * Find the first visible, non-disabled item whose text starts with `query`.
328
- * Search wraps around and starts after `startAfterKey` for cycling.
329
- *
330
- * @param {string} query - Prefix to match (case-insensitive)
331
- * @param {string|null} [startAfterKey] - Key to start searching after (for cycling)
332
- * @returns {string|null} The matching item's key, or null
333
- */
334
- findByText(query, startAfterKey = null) {
335
- const q = query.toLowerCase()
336
- const startIndex = this.#findStartIndex(startAfterKey)
337
- for (let i = 0; i < this.data.length; i++) {
338
- const idx = (startIndex + i) % this.data.length
339
- if (this.#matchesText(idx, q)) return this.data[idx].key
340
- }
341
- return null
342
- }
343
-
344
- update(items) {
345
- this.items = items
346
- }
347
- }
@@ -1,221 +0,0 @@
1
- import { ascending, descending } from 'd3-array'
2
- import { deriveColumns } from '@rokkit/data'
3
- import { ListController } from './list-controller.svelte.js'
4
-
5
- /**
6
- * TableController — manages table state via composition over ListController.
7
- *
8
- * Handles column metadata, sorting (single and multi-column), and delegates
9
- * row focus/selection/navigation to an internal ListController.
10
- */
11
- export class TableController {
12
- columns = $state([])
13
- sortState = $state([])
14
-
15
- #list
16
- #rawData
17
- #fields
18
-
19
- /**
20
- * @param {Array<Record<string, unknown>>} data - Row data
21
- * @param {Object} [options]
22
- * @param {Array} [options.columns] - Column definitions (auto-derived if empty)
23
- * @param {Object} [options.fields] - Row-level field mapping
24
- * @param {*} [options.value] - Initial selected value
25
- * @param {boolean} [options.multiselect] - Enable multi-row selection
26
- */
27
- constructor(data = [], options = {}) {
28
- const { columns, fields, value, multiselect } = options
29
- this.#rawData = data
30
- this.#fields = fields
31
- this.columns = columns?.length
32
- ? columns.map((c) => ({ sortable: true, sorted: 'none', ...c }))
33
- : deriveColumns(data)
34
- this.#list = new ListController(data, value, fields, { multiselect })
35
- }
36
-
37
- // =========================================================================
38
- // Sort
39
- // =========================================================================
40
-
41
- /**
42
- * Compute the next sort state for multi-column (extend) mode.
43
- * @param {string} columnName
44
- * @param {string} nextDirection
45
- * @returns {Array}
46
- */
47
- #extendSortState(columnName, nextDirection) {
48
- const existing = this.sortState.findIndex((s) => s.column === columnName)
49
- if (nextDirection === 'none') {
50
- return this.sortState.filter((s) => s.column !== columnName)
51
- }
52
- if (existing >= 0) {
53
- return this.sortState.map((s) =>
54
- s.column === columnName ? { ...s, direction: nextDirection } : s
55
- )
56
- }
57
- return [...this.sortState, { column: columnName, direction: nextDirection }]
58
- }
59
-
60
- /**
61
- * Compute new sort state for a single-column sort.
62
- * @param {string} columnName
63
- * @param {string} nextDirection
64
- * @returns {Array}
65
- */
66
- #singleSortState(columnName, nextDirection) {
67
- return nextDirection === 'none' ? [] : [{ column: columnName, direction: nextDirection }]
68
- }
69
-
70
- /**
71
- * Sync column sorted flags from current sortState.
72
- */
73
- #syncColumnFlags() {
74
- this.columns = this.columns.map((c) => {
75
- const sort = this.sortState.find((s) => s.column === c.name)
76
- return { ...c, sorted: sort ? sort.direction : 'none' }
77
- })
78
- }
79
-
80
- /**
81
- * Determine the next sort direction for a column by cycling.
82
- * @param {object} col Column object with sorted property
83
- * @returns {string}
84
- */
85
- #nextSortDirection(col) {
86
- const cycle = { none: 'ascending', ascending: 'descending', descending: 'none' }
87
- return cycle[col.sorted ?? 'none']
88
- }
89
-
90
- /**
91
- * Toggle sort on a column. Cycles: none → ascending → descending → none.
92
- * @param {string} columnName - Column to sort by
93
- * @param {boolean} [extend=false] - If true (Shift+click), add to sort stack
94
- */
95
- sortBy(columnName, extend = false) {
96
- const col = this.columns.find((c) => c.name === columnName)
97
- if (!col || col.sortable === false) return
98
- const nextDirection = this.#nextSortDirection(col)
99
- this.sortState = extend
100
- ? this.#extendSortState(columnName, nextDirection)
101
- : this.#singleSortState(columnName, nextDirection)
102
- this.#syncColumnFlags()
103
- this.#applySortAndUpdate()
104
- }
105
-
106
- /**
107
- * Clear all sort state and restore original data order.
108
- */
109
- clearSort() {
110
- this.sortState = []
111
- this.columns = this.columns.map((c) => ({ ...c, sorted: 'none' }))
112
- this.#list.update(this.#rawData)
113
- }
114
-
115
- /**
116
- * Apply current sortState to rawData and feed sorted data to list controller.
117
- * @private
118
- */
119
- #applySortAndUpdate() {
120
- if (this.sortState.length === 0) {
121
- this.#list.update(this.#rawData)
122
- return
123
- }
124
-
125
- const sorted = [...this.#rawData].sort((a, b) => {
126
- for (const { column, direction } of this.sortState) {
127
- const comparator = direction === 'ascending' ? ascending : descending
128
- const result = comparator(a[column], b[column])
129
- if (result !== 0) return result
130
- }
131
- return 0
132
- })
133
-
134
- this.#list.update(sorted)
135
- }
136
-
137
- // =========================================================================
138
- // Data access (delegated to ListController)
139
- // =========================================================================
140
-
141
- get data() {
142
- return this.#list.data
143
- }
144
-
145
- get lookup() {
146
- return this.#list.lookup
147
- }
148
-
149
- get focusedKey() {
150
- return this.#list.focusedKey
151
- }
152
-
153
- set focusedKey(v) {
154
- this.#list.focusedKey = v
155
- }
156
-
157
- get focused() {
158
- return this.#list.focused
159
- }
160
-
161
- get selected() {
162
- return this.#list.selected
163
- }
164
-
165
- get selectedKeys() {
166
- return this.#list.selectedKeys
167
- }
168
-
169
- // =========================================================================
170
- // Navigation (delegated to ListController)
171
- // =========================================================================
172
-
173
- moveFirst() {
174
- return this.#list.moveFirst()
175
- }
176
-
177
- moveLast() {
178
- return this.#list.moveLast()
179
- }
180
-
181
- moveNext() {
182
- return this.#list.moveNext()
183
- }
184
-
185
- movePrev() {
186
- return this.#list.movePrev()
187
- }
188
-
189
- moveTo(path) {
190
- return this.#list.moveTo(path)
191
- }
192
-
193
- moveToIndex(index) {
194
- return this.#list.moveToIndex(index)
195
- }
196
-
197
- // =========================================================================
198
- // Selection (delegated to ListController)
199
- // =========================================================================
200
-
201
- select(key) {
202
- return this.#list.select(key)
203
- }
204
-
205
- extendSelection(key) {
206
- return this.#list.extendSelection(key)
207
- }
208
-
209
- // =========================================================================
210
- // Update
211
- // =========================================================================
212
-
213
- /**
214
- * Update the data source. Re-applies current sort if active.
215
- * @param {Array<Record<string, unknown>>} data
216
- */
217
- update(data) {
218
- this.#rawData = data
219
- this.#applySortAndUpdate()
220
- }
221
- }
@@ -1,137 +0,0 @@
1
- /**
2
- * Handles navigation through a flattened data structure
3
- */
4
- export class Traversal {
5
- #dataProvider
6
- #currentKey = $state(null)
7
- #currentIndex = $derived(this.getCurrentIndex())
8
-
9
- /**
10
- * @param {object} dataProvider - Data provider component
11
- */
12
- constructor(dataProvider) {
13
- this.#dataProvider = dataProvider
14
- }
15
-
16
- /**
17
- * Gets the current focused key
18
- * @returns {string|null} The current key or null if none selected
19
- */
20
- get currentKey() {
21
- return this.#currentKey
22
- }
23
-
24
- /**
25
- * Gets the current focused index
26
- * @returns {number} The current index or -1 if none selected
27
- */
28
- get currentIndex() {
29
- return this.#currentIndex
30
- }
31
-
32
- /**
33
- * Gets the currently focused item
34
- * @returns {object|null} The focused item or null
35
- */
36
- get focused() {
37
- return this.#currentKey ? this.#dataProvider.getItemByKey(this.#currentKey) : null
38
- }
39
-
40
- /**
41
- * Calculates the current index based on the current key
42
- * @private
43
- * @returns {number} The current index or -1 if not found
44
- */
45
- getCurrentIndex() {
46
- if (!this.#currentKey) return -1
47
-
48
- const index = this.#dataProvider.getIndexForKey(this.#currentKey)
49
- return index !== undefined ? index : -1
50
- }
51
-
52
- /**
53
- * Focuses on an item by its key
54
- * @param {string} key - Key of the item to focus
55
- * @returns {boolean} True if focus changed, false otherwise
56
- */
57
- moveToKey(key) {
58
- if (!key || !this.#dataProvider.lookup.has(key)) return false
59
- if (this.#currentKey === key) return false
60
-
61
- this.#currentKey = key
62
- return true
63
- }
64
-
65
- /**
66
- * Focuses on an item by its index in the flattened list
67
- * @param {number} index - Index of the item to focus
68
- * @returns {boolean} True if focus changed, false otherwise
69
- */
70
- moveToIndex(index) {
71
- const nodes = this.#dataProvider.nodes
72
-
73
- if (index < 0 || index >= nodes.length) return false
74
- if (this.#currentIndex === index) return false
75
-
76
- this.#currentKey = nodes[index].key
77
- return true
78
- }
79
-
80
- /**
81
- * Focuses on an item by finding its value in the data
82
- * @param {*} value - Value to find and focus
83
- * @returns {boolean} True if found and focused, false otherwise
84
- */
85
- moveToValue(value) {
86
- if (!value) {
87
- this.#currentKey = null
88
- return true
89
- }
90
-
91
- const key = this.#dataProvider.getKeyForValue(value)
92
- if (!key) return false
93
-
94
- return this.moveToKey(key)
95
- }
96
-
97
- /**
98
- * Moves focus to the previous visible item
99
- * @returns {boolean} True if moved, false if at the beginning
100
- */
101
- movePrev() {
102
- if (this.#currentIndex > 0) {
103
- return this.moveToIndex(this.#currentIndex - 1)
104
- } else if (this.#currentIndex < 0 && this.#dataProvider.nodes.length > 0) {
105
- return this.moveLast() // If not focused, go to last item
106
- }
107
- return false
108
- }
109
-
110
- /**
111
- * Moves focus to the next visible item
112
- * @returns {boolean} True if moved, false if at the end
113
- */
114
- moveNext() {
115
- if (this.#currentIndex < this.#dataProvider.nodes.length - 1) {
116
- return this.moveToIndex(this.#currentIndex + 1)
117
- }
118
- return false
119
- }
120
-
121
- /**
122
- * Moves focus to the first item
123
- * @returns {boolean} True if moved, false otherwise
124
- */
125
- moveFirst() {
126
- return this.#dataProvider.nodes.length > 0 ? this.moveToIndex(0) : false
127
- }
128
-
129
- /**
130
- * Moves focus to the last item
131
- * @returns {boolean} True if moved, false otherwise
132
- */
133
- moveLast() {
134
- const lastIndex = this.#dataProvider.nodes.length - 1
135
- return lastIndex >= 0 ? this.moveToIndex(lastIndex) : false
136
- }
137
- }