@rokkit/states 1.0.0-next.136 → 1.0.0-next.138

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/states",
3
- "version": "1.0.0-next.136",
3
+ "version": "1.0.0-next.138",
4
4
  "description": "Contains generic data manipulation functions that can be used in various components.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,69 +1,103 @@
1
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(...flatVisibleNodes(item[fields.children], getNestedFields(fields), itemPath, expandedKeys))
29
+ }
30
+ }
31
+
2
32
  /**
3
- *
4
33
  * @param {Array<*>} items
5
34
  * @param {import('@rokkit/core').FieldMapping} fields
6
35
  * @param {Array<number>} path
7
- * @param {Set<string>|null} expandedKeys - When provided, expansion is determined by key membership; falls back to item field
8
- * @returns {Array<{ key: string, value: any, level: number, hasChildren: boolean }>}
36
+ * @param {Set<string>|null} expandedKeys
37
+ * @returns {Array}
9
38
  */
10
- export function flatVisibleNodes(items, fields = DEFAULT_FIELDS, path = [], expandedKeys = null) {
39
+ function collectVisibleNodes(items, fields, path, expandedKeys) {
11
40
  const data = []
12
- if (!items || !Array.isArray(items)) return data
13
-
14
41
  const level = path.length
42
+ for (let i = 0; i < items.length; i++) {
43
+ visitNode(data, { item: items[i], index: i, fields, path, level, expandedKeys })
44
+ }
45
+ return data
46
+ }
15
47
 
16
- items.forEach((item, index) => {
17
- const itemPath = [...path, index]
18
- const key = getKeyFromPath(itemPath)
19
- const hasChildren = Array.isArray(item[fields.children]) && item[fields.children].length > 0
20
- const expanded = hasChildren && (expandedKeys ? expandedKeys.has(key) : item[fields.expanded])
48
+ // eslint-disable-next-line complexity
49
+ export function flatVisibleNodes(items, fields = DEFAULT_FIELDS, path = [], expandedKeys = null) {
50
+ if (!items || !Array.isArray(items)) return []
51
+ return collectVisibleNodes(items, fields, path, expandedKeys)
52
+ }
21
53
 
22
- data.push({ key, value: item, level, hasChildren })
54
+ /**
55
+ * Merge child lookup entries into parent lookup.
56
+ * @param {SvelteMap} lookup
57
+ * @param {SvelteMap} childLookup
58
+ */
59
+ function mergeChildLookup(lookup, childLookup) {
60
+ for (const [childKey, childValue] of childLookup.entries()) {
61
+ lookup.set(childKey, childValue)
62
+ }
63
+ }
23
64
 
24
- if (expanded) {
25
- const childFields = getNestedFields(fields)
26
- data.push(...flatVisibleNodes(item[fields.children], childFields, itemPath, expandedKeys))
27
- }
28
- })
29
- return data
65
+ /**
66
+ * Create a lookup entry for an item.
67
+ * @param {*} item
68
+ * @param {*} norm Normalised item object
69
+ * @param {*} fields
70
+ * @returns {object}
71
+ */
72
+ function makeLookupEntry(item, norm, fields) {
73
+ return {
74
+ value: item,
75
+ original: item,
76
+ label: String(norm[fields.label] ?? ''),
77
+ get: (fieldName) => norm[fields[fieldName] ?? fieldName]
78
+ }
30
79
  }
31
80
 
32
81
  /**
33
- * Derives a flat lookup table for the given items, using index paths as keys.
34
- * Each value is a lightweight wrapper exposing the original item as both
35
- * `.value` and `.original` for backward compatibility, plus a `.get(field)`
36
- * method that reads from the item via field mapping.
37
- *
38
- * @param {Array<*>} items - Source items array
39
- * @param {import('@rokkit/core').FieldMapping} fields - Field mappings configuration
40
- * @param {Array<number>} path - Current path in the tree
41
- * @returns {Map<string, { value: *, original: *, label: string, get: (f: string) => * }>}
82
+ * @param {SvelteMap} lookup Accumulator map
83
+ * @param {{ item: *, index: number, fields: *, path: Array<number> }} ctx
42
84
  */
85
+ // eslint-disable-next-line complexity
86
+ function visitLookupNode(lookup, ctx) {
87
+ const { item, index, fields, path } = ctx
88
+ const itemPath = [...path, index]
89
+ const key = getKeyFromPath(itemPath)
90
+ const norm = typeof item === 'object' && item !== null ? item : { [fields.label]: item }
91
+ lookup.set(key, makeLookupEntry(item, norm, fields))
92
+ const children = norm[fields.children] ?? []
93
+ if (Array.isArray(children) && children.length > 0) {
94
+ mergeChildLookup(lookup, deriveLookupWithProxy(children, getNestedFields(fields), itemPath))
95
+ }
96
+ }
97
+
43
98
  export function deriveLookupWithProxy(items, fields = DEFAULT_FIELDS, path = []) {
44
- const lookup = new Map()
99
+ const lookup = new SvelteMap()
45
100
  if (!items || !Array.isArray(items)) return lookup
46
-
47
- items.forEach((item, index) => {
48
- const itemPath = [...path, index]
49
- const key = getKeyFromPath(itemPath)
50
- const norm = typeof item === 'object' && item !== null ? item : { [fields.label]: item }
51
- const entry = {
52
- value: item,
53
- original: item,
54
- label: String(norm[fields.label] ?? ''),
55
- get: (fieldName) => norm[fields[fieldName] ?? fieldName]
56
- }
57
-
58
- lookup.set(key, entry)
59
- const children = norm[fields.children] ?? []
60
- if (Array.isArray(children) && children.length > 0) {
61
- const childFields = getNestedFields(fields)
62
- const childLookup = deriveLookupWithProxy(children, childFields, itemPath)
63
- for (const [childKey, childValue] of childLookup.entries()) {
64
- lookup.set(childKey, childValue)
65
- }
66
- }
67
- })
101
+ items.forEach((item, index) => visitLookupNode(lookup, { item, index, fields, path }))
68
102
  return lookup
69
103
  }
@@ -67,31 +67,37 @@ export class LazyWrapper extends Wrapper {
67
67
  super.expand(_path)
68
68
  }
69
69
 
70
+ /**
71
+ * Fetch children for an unloaded lazy sentinel proxy and expand it.
72
+ * @param {*} proxy
73
+ */
74
+ #fetchAndExpand(proxy) {
75
+ proxy.fetch().then(() => {
76
+ proxy.expanded = true
77
+ })
78
+ }
79
+
80
+ /**
81
+ * Resolve proxy for a path or focused key, or null if not found.
82
+ * @param {string|null} path
83
+ * @returns {*|null}
84
+ */
85
+ #resolveProxy(path) {
86
+ const key = path ?? this.focusedKey
87
+ if (!key) return null
88
+ return this.lookup.get(key) ?? null
89
+ }
90
+
70
91
  /**
71
92
  * Select item at path (or focusedKey). If the target is a group/expandable
72
93
  * node with proxy.loaded === false, fetch children first then expand.
73
94
  * Otherwise delegates to Wrapper's select().
74
95
  */
75
96
  select(path) {
76
- const key = path ?? this.focusedKey
77
- if (!key) return
78
- const proxy = this.lookup.get(key)
97
+ const proxy = this.#resolveProxy(path)
79
98
  if (!proxy) return
80
-
81
- // Group with children: delegate to super (toggle expansion)
82
- if (proxy.hasChildren) {
83
- super.select(path)
84
- return
85
- }
86
-
87
- // Lazy sentinel: fetch children, then expand
88
- if (proxy.loaded === false) {
89
- proxy.fetch().then(() => {
90
- proxy.expanded = true
91
- })
92
- return
93
- }
94
-
99
+ if (proxy.hasChildren) return super.select(path)
100
+ if (proxy.loaded === false) return this.#fetchAndExpand(proxy)
95
101
  super.select(path)
96
102
  }
97
103
 
@@ -101,23 +107,9 @@ export class LazyWrapper extends Wrapper {
101
107
  * Otherwise delegates to Wrapper's toggle().
102
108
  */
103
109
  toggle(path) {
104
- const key = path ?? this.focusedKey
105
- if (!key) return
106
- const proxy = this.lookup.get(key)
110
+ const proxy = this.#resolveProxy(path)
107
111
  if (!proxy) return
108
-
109
- // Group with children: normal toggle
110
- if (proxy.hasChildren) {
111
- super.toggle(path)
112
- return
113
- }
114
-
115
- // Lazy sentinel: fetch children, then expand
116
- if (proxy.loaded === false) {
117
- proxy.fetch().then(() => {
118
- proxy.expanded = true
119
- })
120
- return
121
- }
112
+ if (proxy.hasChildren) return super.toggle(path)
113
+ if (proxy.loaded === false) this.#fetchAndExpand(proxy)
122
114
  }
123
115
  }
@@ -29,23 +29,36 @@ export class ListController {
29
29
  this.init(value)
30
30
  }
31
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
+ // eslint-disable-next-line complexity
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
+
32
55
  /**
33
56
  * Scan items for pre-existing expanded flags and populate expandedKeys
34
57
  * @private
35
58
  */
36
59
  #initExpandedKeys(items, fields, path = []) {
37
60
  if (!items || !Array.isArray(items)) return
38
- items.forEach((item, index) => {
39
- if (item === null || item === undefined || typeof item !== 'object') return
40
- const itemPath = [...path, index]
41
- const children = item[fields.children]
42
- if (Array.isArray(children) && children.length > 0) {
43
- if (item[fields.expanded]) {
44
- this.expandedKeys.add(getKeyFromPath(itemPath))
45
- }
46
- this.#initExpandedKeys(children, getNestedFields(fields), itemPath)
47
- }
48
- })
61
+ items.forEach((item, index) => this.#initExpandedItem(item, index, fields, path))
49
62
  }
50
63
 
51
64
  /**
@@ -233,6 +246,45 @@ export class ListController {
233
246
  }
234
247
  }
235
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(Math.min(anchorIndex, targetIndex), Math.max(anchorIndex, targetIndex), targetIndex)
285
+ return true
286
+ }
287
+
236
288
  /**
237
289
  * Select all non-disabled items between the anchor and the given key (inclusive).
238
290
  * Used for Shift+click range selection in multiselect mode.
@@ -242,29 +294,29 @@ export class ListController {
242
294
  selectRange(selectedKey) {
243
295
  const key = selectedKey ?? this.focusedKey
244
296
  if (!this.lookup.has(key)) return false
245
-
246
297
  if (!this.#options.multiselect) return this.select(key)
298
+ return this.#applyRangeSelection(key)
299
+ }
247
300
 
248
- const anchorKey = this.#anchorKey ?? this.focusedKey
249
- if (!anchorKey) return this.select(key)
250
-
251
- const anchorIndex = this.data.findIndex((row) => row.key === anchorKey)
252
- const targetIndex = this.data.findIndex((row) => row.key === key)
253
- if (anchorIndex < 0 || targetIndex < 0) return false
254
-
255
- const start = Math.min(anchorIndex, targetIndex)
256
- const end = Math.max(anchorIndex, targetIndex)
257
-
258
- this.selectedKeys.clear()
259
- for (let i = start; i <= end; i++) {
260
- if (!this.#isDisabled(i)) {
261
- this.selectedKeys.add(this.data[i].key)
262
- }
263
- }
301
+ /**
302
+ * Compute the start index for a findByText search.
303
+ * @private
304
+ */
305
+ #findStartIndex(startAfterKey) {
306
+ if (startAfterKey === null) return 0
307
+ const idx = this.data.findIndex((row) => row.key === startAfterKey)
308
+ return idx >= 0 ? idx + 1 : 0
309
+ }
264
310
 
265
- // Move focus but don't change anchor (anchor stays for subsequent Shift+clicks)
266
- this.moveToIndex(targetIndex)
267
- return true
311
+ /**
312
+ * Check if an item at idx matches the query prefix.
313
+ * @private
314
+ */
315
+ #matchesText(idx, q) {
316
+ if (this.#isDisabled(idx)) return false
317
+ const entry = this.lookup.get(this.data[idx].key)
318
+ const text = entry?.label ?? ''
319
+ return String(text).toLowerCase().startsWith(q)
268
320
  }
269
321
 
270
322
  /**
@@ -277,19 +329,10 @@ export class ListController {
277
329
  */
278
330
  findByText(query, startAfterKey = null) {
279
331
  const q = query.toLowerCase()
280
- let startIndex = 0
281
- if (startAfterKey !== null) {
282
- const idx = this.data.findIndex((row) => row.key === startAfterKey)
283
- if (idx >= 0) startIndex = idx + 1
284
- }
332
+ const startIndex = this.#findStartIndex(startAfterKey)
285
333
  for (let i = 0; i < this.data.length; i++) {
286
334
  const idx = (startIndex + i) % this.data.length
287
- if (this.#isDisabled(idx)) continue
288
- const entry = this.lookup.get(this.data[idx].key)
289
- const text = entry?.label ?? ''
290
- if (String(text).toLowerCase().startsWith(q)) {
291
- return this.data[idx].key
292
- }
335
+ if (this.#matchesText(idx, q)) return this.data[idx].key
293
336
  }
294
337
  return null
295
338
  }
@@ -61,6 +61,21 @@ class MessagesStore {
61
61
  return this.#messages
62
62
  }
63
63
 
64
+ /**
65
+ * Merge a single key from custom into merged target.
66
+ * @param {Record<string, unknown>} merged
67
+ * @param {Record<string, unknown>} custom
68
+ * @param {string} key
69
+ */
70
+ #mergeKey(merged, custom, key) {
71
+ const isObject = (v) => typeof v === 'object' && v !== null
72
+ if (isObject(custom[key]) && isObject(merged[key])) {
73
+ merged[key] = { ...merged[key], ...custom[key] }
74
+ } else {
75
+ merged[key] = custom[key]
76
+ }
77
+ }
78
+
64
79
  /**
65
80
  * Set custom messages (merges with defaults)
66
81
  * @param {Partial<import('./types').Messages>} custom
@@ -68,16 +83,7 @@ class MessagesStore {
68
83
  set(custom) {
69
84
  const merged = { ...defaultMessages }
70
85
  for (const key of Object.keys(custom)) {
71
- if (
72
- typeof custom[key] === 'object' &&
73
- custom[key] !== null &&
74
- typeof merged[key] === 'object' &&
75
- merged[key] !== null
76
- ) {
77
- merged[key] = { ...merged[key], ...custom[key] }
78
- } else {
79
- merged[key] = custom[key]
80
- }
86
+ this.#mergeKey(merged, custom, key)
81
87
  }
82
88
  this.#messages = merged
83
89
  }
@@ -65,6 +65,27 @@ export class ProxyItem {
65
65
  // every access, so $derived can track their expanded state.
66
66
  #children = $derived(this.#buildChildren())
67
67
 
68
+ /**
69
+ * Normalise a raw value to an object for uniform field access.
70
+ * @param {*} raw
71
+ * @returns {object}
72
+ */
73
+ #normalizeItem(raw) {
74
+ return raw !== null && typeof raw === 'object'
75
+ ? raw
76
+ : { [this.#fields.label]: raw, [this.#fields.value]: raw }
77
+ }
78
+
79
+ /**
80
+ * Sync initial control state from item fields when present.
81
+ */
82
+ #syncControlState() {
83
+ const ef = this.#fields.expanded
84
+ const sf = this.#fields.selected
85
+ if (ef in this.#item) this.#expanded = Boolean(this.#item[ef])
86
+ if (sf in this.#item) this.#selected = Boolean(this.#item[sf])
87
+ }
88
+
68
89
  /**
69
90
  * @param {*} raw Raw item — object or primitive (string, number, …)
70
91
  * @param {Partial<typeof BASE_FIELDS>} [fields]
@@ -76,22 +97,9 @@ export class ProxyItem {
76
97
  this.#raw = raw
77
98
  this.#key = key
78
99
  this.#level = level
79
-
80
- // Normalise primitives: #item is always an object.
81
- // Both text and value fields point to the primitive so all accessors work uniformly.
82
- this.#item =
83
- raw !== null && typeof raw === 'object'
84
- ? raw
85
- : { [this.#fields.label]: raw, [this.#fields.value]: raw }
86
-
87
- // Stable unique id: read from item field, or auto-generate
100
+ this.#item = this.#normalizeItem(raw)
88
101
  this.#id = this.#item[this.#fields.id] ?? `proxy-${_nextId++}`
89
-
90
- // Sync initial control state from #item fields when present
91
- const ef = this.#fields.expanded
92
- const sf = this.#fields.selected
93
- if (ef in this.#item) this.#expanded = Boolean(this.#item[ef])
94
- if (sf in this.#item) this.#selected = Boolean(this.#item[sf])
102
+ this.#syncControlState()
95
103
  }
96
104
 
97
105
  // ─── Internal: build wrapped children ────────────────────────────────────
@@ -283,12 +291,21 @@ export class LazyProxyItem extends ProxyItem {
283
291
  * @param {number} [level]
284
292
  * @param {((value: unknown, raw: unknown) => Promise<unknown[]>) | null} [lazyLoad]
285
293
  */
294
+ // eslint-disable-next-line max-params
286
295
  constructor(raw, fields = {}, key = '', level = 0, lazyLoad = null) {
287
296
  super(raw, fields, key, level)
288
297
  this.#lazyLoad = lazyLoad
289
- // Loaded if: no lazyLoad function, children already exist as an array, or no children field (leaf)
290
- // Only sentinel nodes (children: true) are considered unloaded
291
- this.#loaded = lazyLoad === null || this.get('children') !== true
298
+ this.#loaded = this.#resolveLoaded(lazyLoad)
299
+ }
300
+
301
+ /**
302
+ * Determine initial loaded state.
303
+ * Loaded if no lazyLoad function, or children is not a sentinel (true).
304
+ * @param {Function|null} lazyLoad
305
+ * @returns {boolean}
306
+ */
307
+ #resolveLoaded(lazyLoad) {
308
+ return lazyLoad === null || this.get('children') !== true
292
309
  }
293
310
 
294
311
  get loaded() {
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { BASE_FIELDS, normalizeFields } from '@rokkit/core'
18
+ import { SvelteMap } from 'svelte/reactivity'
18
19
  import { ProxyItem } from './proxy-item.svelte.js'
19
20
 
20
21
  // ─── Tree line type computation ────────────────────────────────────────────────
@@ -31,6 +32,19 @@ const NEXT_LINE = {
31
32
 
32
33
  // ─── Reactive tree traversal utilities ─────────────────────────────────────────
33
34
 
35
+ /**
36
+ * Compute the lineTypes array for a single node.
37
+ * @param {string[]} parentLineTypes
38
+ * @param {string} position - 'child' or 'last'
39
+ * @param {boolean} isExpandable
40
+ * @returns {string[]}
41
+ */
42
+ function computeLineTypes(parentLineTypes, position, isExpandable) {
43
+ const inherited = parentLineTypes.slice(0, -1).map((t) => NEXT_LINE[t] ?? 'empty')
44
+ if (parentLineTypes.length > 0) inherited.push(position)
45
+ return isExpandable ? [...inherited, 'icon'] : inherited
46
+ }
47
+
34
48
  /**
35
49
  * Build flat view by walking proxy.children ($derived) recursively.
36
50
  * Reads proxy.expanded ($state) and proxy.children ($derived), so any
@@ -48,33 +62,28 @@ const NEXT_LINE = {
48
62
  * @param {string[]} [parentLineTypes] Line types of the parent node (for computing inherited connectors)
49
63
  * @returns {{ key: string, proxy: ProxyItem, level: number, hasChildren: boolean, isExpandable: boolean, type: string, lineTypes: string[] }[]}
50
64
  */
65
+ /**
66
+ * Visit a single proxy node and push entries to result.
67
+ * @param {Array} result
68
+ * @param {ProxyItem} proxy
69
+ * @param {string[]} parentLineTypes
70
+ * @param {string} position - 'child' or 'last'
71
+ */
72
+ function visitProxy(result, proxy, parentLineTypes, position) {
73
+ const children = proxy.children // reads $derived — registers dependency
74
+ const hasChildren = children.length > 0
75
+ const isExpandable = hasChildren || proxy.get('children') === true // sentinel: lazy-loadable
76
+ const lineTypes = computeLineTypes(parentLineTypes, position, isExpandable)
77
+ result.push({ key: proxy.key, proxy, level: proxy.level, hasChildren, isExpandable, type: proxy.type, lineTypes })
78
+ if (hasChildren && proxy.expanded) {
79
+ result.push(...buildReactiveFlatView(children, lineTypes))
80
+ }
81
+ }
82
+
51
83
  function buildReactiveFlatView(proxies, parentLineTypes = []) {
52
84
  const result = []
53
85
  for (let i = 0; i < proxies.length; i++) {
54
- const proxy = proxies[i]
55
- const children = proxy.children // reads $derived — registers dependency
56
- const hasChildren = children.length > 0
57
- const isExpandable = hasChildren || proxy.get('children') === true // sentinel: lazy-loadable
58
- const isLast = i === proxies.length - 1
59
- const position = isLast ? 'last' : 'child'
60
-
61
- // Compute line types: inherit parent's continuations + current position + icon/empty
62
- const inherited = parentLineTypes.slice(0, -1).map((t) => NEXT_LINE[t] ?? 'empty')
63
- if (parentLineTypes.length > 0) inherited.push(position)
64
- const lineTypes = isExpandable ? [...inherited, 'icon'] : inherited
65
-
66
- result.push({
67
- key: proxy.key,
68
- proxy,
69
- level: proxy.level,
70
- hasChildren,
71
- isExpandable,
72
- type: proxy.type,
73
- lineTypes
74
- })
75
- if (hasChildren && proxy.expanded) {
76
- result.push(...buildReactiveFlatView(children, lineTypes))
77
- }
86
+ visitProxy(result, proxies[i], parentLineTypes, i === proxies.length - 1 ? 'last' : 'child')
78
87
  }
79
88
  return result
80
89
  }
@@ -88,7 +97,7 @@ function buildReactiveFlatView(proxies, parentLineTypes = []) {
88
97
  * @param {Map<string, ProxyItem>} [map]
89
98
  * @returns {Map<string, ProxyItem>}
90
99
  */
91
- function buildReactiveLookup(proxies, map = new Map()) {
100
+ function buildReactiveLookup(proxies, map = new SvelteMap()) {
92
101
  for (const proxy of proxies) {
93
102
  map.set(proxy.key, proxy)
94
103
  const children = proxy.children
@@ -121,13 +130,20 @@ export class ProxyTree {
121
130
  */
122
131
  constructor(items = [], fields = {}, options = {}) {
123
132
  this.#fields = { ...BASE_FIELDS, ...normalizeFields(fields) }
124
- this.#factory =
125
- options.createProxy ?? ((raw, f, key, level) => new ProxyItem(raw, f, key, level))
133
+ this.#factory = this.#resolveFactory(options.createProxy)
126
134
  this.#rootProxies = (items ?? []).map((raw, i) =>
127
135
  this.#factory(raw, this.#fields, String(i), 1)
128
136
  )
129
137
  }
130
138
 
139
+ /**
140
+ * @param {Function|undefined} createProxy
141
+ * @returns {Function}
142
+ */
143
+ #resolveFactory(createProxy) {
144
+ return createProxy ?? ((raw, f, key, level) => new ProxyItem(raw, f, key, level))
145
+ }
146
+
131
147
  // ─── Read accessors ──────────────────────────────────────────────────────
132
148
 
133
149
  /** @returns {ProxyItem[]} Root proxy array */
@@ -39,46 +39,67 @@ export class TableController {
39
39
  // =========================================================================
40
40
 
41
41
  /**
42
- * Toggle sort on a column. Cycles: none → ascending → descending → none.
43
- * @param {string} columnName - Column to sort by
44
- * @param {boolean} [extend=false] - If true (Shift+click), add to sort stack
42
+ * Compute the next sort state for multi-column (extend) mode.
43
+ * @param {string} columnName
44
+ * @param {string} nextDirection
45
+ * @returns {Array}
45
46
  */
46
- sortBy(columnName, extend = false) {
47
- const col = this.columns.find((c) => c.name === columnName)
48
- if (!col || col.sortable === false) return
49
-
50
- // Determine next direction
51
- const cycle = { none: 'ascending', ascending: 'descending', descending: 'none' }
52
- const nextDirection = cycle[col.sorted ?? 'none']
53
-
54
- if (extend) {
55
- // Multi-column sort: add/update/remove this column in the sort stack
56
- const existing = this.sortState.findIndex((s) => s.column === columnName)
57
- if (nextDirection === 'none') {
58
- // Remove from stack
59
- this.sortState = this.sortState.filter((s) => s.column !== columnName)
60
- } else if (existing >= 0) {
61
- // Update direction in place
62
- this.sortState = this.sortState.map((s) =>
63
- s.column === columnName ? { ...s, direction: nextDirection } : s
64
- )
65
- } else {
66
- // Add to stack
67
- this.sortState = [...this.sortState, { column: columnName, direction: nextDirection }]
68
- }
69
- } else {
70
- // Single column sort: replace entire sort state
71
- this.sortState =
72
- nextDirection === 'none' ? [] : [{ column: columnName, direction: nextDirection }]
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
+ )
73
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
+ }
74
69
 
75
- // Update column sorted flags
70
+ /**
71
+ * Sync column sorted flags from current sortState.
72
+ */
73
+ #syncColumnFlags() {
76
74
  this.columns = this.columns.map((c) => {
77
75
  const sort = this.sortState.find((s) => s.column === c.name)
78
76
  return { ...c, sorted: sort ? sort.direction : 'none' }
79
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
+ }
80
89
 
81
- // Apply sort and update list
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()
82
103
  this.#applySortAndUpdate()
83
104
  }
84
105
 
@@ -104,6 +104,18 @@ export class Wrapper {
104
104
  }
105
105
  }
106
106
 
107
+ /**
108
+ * Move focus to the parent key by stripping the last segment.
109
+ * No-op at root level.
110
+ */
111
+ #focusParent() {
112
+ const parts = this.#focusedKey.split('-')
113
+ if (parts.length > 1) {
114
+ parts.pop()
115
+ this.#focusedKey = parts.join('-')
116
+ }
117
+ }
118
+
107
119
  /**
108
120
  * Collapse focused group, or move focus to parent if already collapsed / leaf.
109
121
  * At root level with no parent: no-op.
@@ -115,18 +127,24 @@ export class Wrapper {
115
127
  if (node.hasChildren && node.proxy.expanded) {
116
128
  node.proxy.expanded = false
117
129
  } else {
118
- // Move to parent: strip the last segment from the key
119
- const parts = this.#focusedKey.split('-')
120
- if (parts.length > 1) {
121
- parts.pop()
122
- this.#focusedKey = parts.join('-')
123
- }
124
- // At root level (no '-'): no-op — already at root
130
+ this.#focusParent()
125
131
  }
126
132
  }
127
133
 
128
134
  // ─── IWrapper: selection actions ───────────────────────────────────────────
129
135
 
136
+ /**
137
+ * Fire value-change callbacks for a leaf selection.
138
+ * @param {*} proxy
139
+ */
140
+ #selectLeaf(proxy) {
141
+ if (proxy.value !== this.#selectedValue) {
142
+ this.#selectedValue = proxy.value
143
+ this.#onchange?.(proxy.value, proxy)
144
+ }
145
+ this.#onselect?.(proxy.value, proxy)
146
+ }
147
+
130
148
  /**
131
149
  * Select item at path (or focusedKey when path is null).
132
150
  * Groups toggle expanded. Leaves fire onchange (value differs) and onselect callbacks.
@@ -140,11 +158,7 @@ export class Wrapper {
140
158
  if (proxy.hasChildren) {
141
159
  proxy.expanded = !proxy.expanded
142
160
  } else {
143
- if (proxy.value !== this.#selectedValue) {
144
- this.#selectedValue = proxy.value
145
- this.#onchange?.(proxy.value, proxy)
146
- }
147
- this.#onselect?.(proxy.value, proxy)
161
+ this.#selectLeaf(proxy)
148
162
  }
149
163
  }
150
164
 
@@ -212,6 +226,17 @@ export class Wrapper {
212
226
  if (!nav.length) return null
213
227
  const q = query.toLowerCase()
214
228
  const startIdx = startAfterKey ? nav.findIndex((n) => n.key === startAfterKey) + 1 : 0
229
+ return this.#searchNav(nav, q, startIdx)
230
+ }
231
+
232
+ /**
233
+ * Search navigable items for a text prefix match starting from startIdx.
234
+ * @param {Array} nav
235
+ * @param {string} q
236
+ * @param {number} startIdx
237
+ * @returns {string|null}
238
+ */
239
+ #searchNav(nav, q, startIdx) {
215
240
  for (let i = 0; i < nav.length; i++) {
216
241
  const node = nav[(startIdx + i) % nav.length]
217
242
  if (node.proxy.label.toLowerCase().startsWith(q)) return node.key