@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 +1 -1
- package/src/derive.svelte.js +84 -50
- package/src/lazy-wrapper.svelte.js +27 -35
- package/src/list-controller.svelte.js +85 -42
- package/src/messages.svelte.js +16 -10
- package/src/proxy-item.svelte.js +35 -18
- package/src/proxy-tree.svelte.js +43 -27
- package/src/table-controller.svelte.js +53 -32
- package/src/wrapper.svelte.js +37 -12
package/package.json
CHANGED
package/src/derive.svelte.js
CHANGED
|
@@ -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
|
|
8
|
-
* @returns {Array
|
|
36
|
+
* @param {Set<string>|null} expandedKeys
|
|
37
|
+
* @returns {Array}
|
|
9
38
|
*/
|
|
10
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
*
|
|
34
|
-
*
|
|
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
|
|
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
|
|
77
|
-
if (!key) return
|
|
78
|
-
const proxy = this.lookup.get(key)
|
|
97
|
+
const proxy = this.#resolveProxy(path)
|
|
79
98
|
if (!proxy) return
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
105
|
-
if (!key) return
|
|
106
|
-
const proxy = this.lookup.get(key)
|
|
110
|
+
const proxy = this.#resolveProxy(path)
|
|
107
111
|
if (!proxy) return
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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.#
|
|
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
|
}
|
package/src/messages.svelte.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/proxy-item.svelte.js
CHANGED
|
@@ -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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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() {
|
package/src/proxy-tree.svelte.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
*
|
|
43
|
-
* @param {string} columnName
|
|
44
|
-
* @param {
|
|
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
|
-
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/wrapper.svelte.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|