@rokkit/states 1.0.0-next.125 → 1.0.0-next.128
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -6
- package/src/constants.js +1 -0
- package/src/derive.svelte.js +30 -18
- package/src/index.js +7 -3
- package/src/lazy-wrapper.svelte.js +119 -0
- package/src/list-controller.svelte.js +128 -16
- package/src/media.svelte.js +24 -0
- package/src/messages.svelte.js +71 -0
- package/src/proxy-item.svelte.js +320 -0
- package/src/proxy-tree.svelte.js +158 -0
- package/src/table-controller.svelte.js +199 -0
- package/src/vibe.svelte.js +36 -5
- package/src/wrapper.svelte.js +231 -0
- package/src/nested-controller.svelte.js +0 -71
- package/src/proxy.svelte.js +0 -126
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProxyItem
|
|
3
|
+
*
|
|
4
|
+
* Wraps a raw item (object or primitive) and provides uniform field access.
|
|
5
|
+
*
|
|
6
|
+
* #raw — always the original input, never mutated.
|
|
7
|
+
* #item — the object used for all field accesses:
|
|
8
|
+
* for objects: same reference as #raw
|
|
9
|
+
* for primitives: { [fields.text]: raw, [fields.value]: raw }
|
|
10
|
+
* This normalisation means get() and all getters work through the
|
|
11
|
+
* same field-mapping path with no special-casing.
|
|
12
|
+
* #key — path-based identifier ('0', '0-1', '0-1-2', …) assigned by
|
|
13
|
+
* ProxyTree and propagated into children automatically.
|
|
14
|
+
* #level — nesting depth: always equals key.split('-').length.
|
|
15
|
+
* (1 = root, 2 = first-level children, 3 = grandchildren, …)
|
|
16
|
+
*
|
|
17
|
+
* get(fieldName) — maps semantic name → raw key → #item value.
|
|
18
|
+
* For field-mapped attributes only (text, value, icon, …).
|
|
19
|
+
* Structural props (key, level) and control state
|
|
20
|
+
* (expanded, selected) are accessed directly as properties.
|
|
21
|
+
*
|
|
22
|
+
* Direct getters: label, value, id — primary access.
|
|
23
|
+
* All other fields via get(fieldName).
|
|
24
|
+
*
|
|
25
|
+
* children — auto-wrapped as ProxyItem instances via $derived, with their
|
|
26
|
+
* keys and levels already set. Stable references so
|
|
27
|
+
* $derived correctly tracks nested expanded state.
|
|
28
|
+
*
|
|
29
|
+
* Control state (expanded / selected) — two modes:
|
|
30
|
+
* external: item has the field → proxy reads/writes through to #item
|
|
31
|
+
* internal: item lacks the field → proxy owns it as $state
|
|
32
|
+
* Primitive items always use internal mode (their #item has no state fields).
|
|
33
|
+
*
|
|
34
|
+
* ProxyItems are created once and never recreated — this keeps $state
|
|
35
|
+
* signals stable so $derived computations track them correctly.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { BASE_FIELDS, normalizeFields } from '@rokkit/core'
|
|
39
|
+
export { BASE_FIELDS }
|
|
40
|
+
|
|
41
|
+
// Auto-increment counter for generating stable unique IDs.
|
|
42
|
+
let _nextId = 1
|
|
43
|
+
|
|
44
|
+
// ─── ProxyItem ────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export class ProxyItem {
|
|
47
|
+
#raw // original input — never touched after construction
|
|
48
|
+
#item // normalised object used for all field accesses
|
|
49
|
+
#fields
|
|
50
|
+
#id // stable unique identifier — from item field or auto-generated
|
|
51
|
+
#key // path-based key e.g. '0', '0-1', '0-1-2'
|
|
52
|
+
#level // nesting depth: 1 = root
|
|
53
|
+
|
|
54
|
+
// Control state — always read from here so $derived tracks changes.
|
|
55
|
+
// Initialised from #item field when present (external mode).
|
|
56
|
+
#expanded = $state(false)
|
|
57
|
+
#selected = $state(false)
|
|
58
|
+
|
|
59
|
+
// Version counter — incremented by set() to trigger #children recomputation.
|
|
60
|
+
// $derived reads this so it re-derives when set('children', ...) is called.
|
|
61
|
+
#version = $state(0)
|
|
62
|
+
|
|
63
|
+
// Children auto-wrapped as ProxyItem instances with keys + levels assigned.
|
|
64
|
+
// $derived ensures stable references: same ProxyItem instances returned on
|
|
65
|
+
// every access, so $derived can track their expanded state.
|
|
66
|
+
#children = $derived(this.#buildChildren())
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {*} raw Raw item — object or primitive (string, number, …)
|
|
70
|
+
* @param {Partial<typeof BASE_FIELDS>} [fields]
|
|
71
|
+
* @param {string} [key] Path-based key assigned by ProxyTree
|
|
72
|
+
* @param {number} [level] Nesting depth (1 = root)
|
|
73
|
+
*/
|
|
74
|
+
constructor(raw, fields = {}, key = '', level = 0) {
|
|
75
|
+
this.#fields = { ...BASE_FIELDS, ...normalizeFields(fields) }
|
|
76
|
+
this.#raw = raw
|
|
77
|
+
this.#key = key
|
|
78
|
+
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
|
|
88
|
+
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])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Internal: build wrapped children ────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
#buildChildren() {
|
|
100
|
+
void this.#version // reactive dependency — triggers recompute after set()
|
|
101
|
+
const raw = this.#item[this.#fields.children]
|
|
102
|
+
if (!Array.isArray(raw) || raw.length === 0) return []
|
|
103
|
+
return raw.map(
|
|
104
|
+
(child, i) =>
|
|
105
|
+
this._createChild(
|
|
106
|
+
child,
|
|
107
|
+
this.#fields,
|
|
108
|
+
this.#key ? `${this.#key}-${i}` : String(i),
|
|
109
|
+
this.#level + 1
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Factory method for creating child proxies. Override in subclasses
|
|
116
|
+
* to produce specialised children (e.g. LazyProxyItem).
|
|
117
|
+
* @param {*} raw
|
|
118
|
+
* @param {Partial<typeof BASE_FIELDS>} fields
|
|
119
|
+
* @param {string} key
|
|
120
|
+
* @param {number} level
|
|
121
|
+
* @returns {ProxyItem}
|
|
122
|
+
*/
|
|
123
|
+
_createChild(raw, fields, key, level) {
|
|
124
|
+
return new ProxyItem(raw, fields, key, level)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Structural props ─────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
get key() {
|
|
130
|
+
return this.#key
|
|
131
|
+
}
|
|
132
|
+
get level() {
|
|
133
|
+
return this.#level
|
|
134
|
+
}
|
|
135
|
+
/** Stable unique identifier — from item's id field, or auto-generated. */
|
|
136
|
+
get id() {
|
|
137
|
+
return this.#id
|
|
138
|
+
}
|
|
139
|
+
/** The original input passed to the constructor — never mutated. */
|
|
140
|
+
get original() {
|
|
141
|
+
return this.#raw
|
|
142
|
+
}
|
|
143
|
+
/** The merged field-mapping configuration. */
|
|
144
|
+
get fields() {
|
|
145
|
+
return this.#fields
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Generic field accessor ───────────────────────────────────────────────
|
|
149
|
+
//
|
|
150
|
+
// Maps a semantic field name to the #item value via the fields config.
|
|
151
|
+
// For field-mapped attributes only: text, value, icon, href, description, …
|
|
152
|
+
// Falls back to using fieldName directly as a raw key when not in config.
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {string} fieldName Semantic name, e.g. 'icon', 'href', 'description'
|
|
156
|
+
* @returns {*}
|
|
157
|
+
*/
|
|
158
|
+
get(fieldName) {
|
|
159
|
+
const rawKey = this.#fields[fieldName] ?? fieldName
|
|
160
|
+
return this.#item[rawKey]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Write a value back to the underlying item through the field mapping.
|
|
165
|
+
* For object items, this modifies the original raw item (since #item === #raw).
|
|
166
|
+
* Increments the version counter so $derived(#buildChildren()) re-computes.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} fieldName Semantic name, e.g. 'children', 'text'
|
|
169
|
+
* @param {*} value
|
|
170
|
+
*/
|
|
171
|
+
set(fieldName, value) {
|
|
172
|
+
const rawKey = this.#fields[fieldName] ?? fieldName
|
|
173
|
+
this.#item[rawKey] = value
|
|
174
|
+
this.#version++
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Write directly to the original raw item, bypassing field mapping.
|
|
179
|
+
* Advanced operation for when the caller needs to update the source data.
|
|
180
|
+
* Accepts either (field, value) or an object for batch updates.
|
|
181
|
+
* Increments version so $derived(#buildChildren()) re-computes.
|
|
182
|
+
*
|
|
183
|
+
* @param {string|object} fieldOrBatch Raw key name, or { key: value, … }
|
|
184
|
+
* @param {*} [value]
|
|
185
|
+
*/
|
|
186
|
+
mutate(fieldOrBatch, value) {
|
|
187
|
+
if (typeof fieldOrBatch === 'object' && fieldOrBatch !== null) {
|
|
188
|
+
for (const [k, v] of Object.entries(fieldOrBatch)) {
|
|
189
|
+
this.#raw[k] = v
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
this.#raw[fieldOrBatch] = value
|
|
193
|
+
}
|
|
194
|
+
this.#version++
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Field-mapped accessors ───────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
get label() {
|
|
200
|
+
return this.#item[this.#fields.label] ?? ''
|
|
201
|
+
}
|
|
202
|
+
get value() {
|
|
203
|
+
return this.#item[this.#fields.value] ?? this.#raw
|
|
204
|
+
}
|
|
205
|
+
// All other fields via get('icon'), get('href'), get('snippet'), etc.
|
|
206
|
+
|
|
207
|
+
// ─── Computed props ───────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
get disabled() {
|
|
210
|
+
return this.#item[this.#fields.disabled] === true
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** True only for object items with a non-empty children array. */
|
|
214
|
+
get hasChildren() {
|
|
215
|
+
return (
|
|
216
|
+
this.#raw !== null &&
|
|
217
|
+
typeof this.#raw === 'object' &&
|
|
218
|
+
Array.isArray(this.#item[this.#fields.children]) &&
|
|
219
|
+
this.#item[this.#fields.children].length > 0
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Returns wrapped ProxyItem children (empty array for primitives and leaf items). */
|
|
224
|
+
get children() {
|
|
225
|
+
return this.#children
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
get type() {
|
|
229
|
+
const t = this.#item[this.#fields.type]
|
|
230
|
+
if (t === 'separator' || t === 'spacer') return t
|
|
231
|
+
return this.hasChildren ? 'group' : 'item'
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Control state (expanded / selected) ─────────────────────────────────
|
|
235
|
+
|
|
236
|
+
get expanded() {
|
|
237
|
+
return this.#expanded
|
|
238
|
+
}
|
|
239
|
+
set expanded(v) {
|
|
240
|
+
this.#expanded = v
|
|
241
|
+
const ef = this.#fields.expanded
|
|
242
|
+
if (ef in this.#item) this.#item[ef] = v
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
get selected() {
|
|
246
|
+
return this.#selected
|
|
247
|
+
}
|
|
248
|
+
set selected(v) {
|
|
249
|
+
this.#selected = v
|
|
250
|
+
const sf = this.#fields.selected
|
|
251
|
+
if (sf in this.#item) this.#item[sf] = v
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── LazyProxyItem ───────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* LazyProxyItem
|
|
259
|
+
*
|
|
260
|
+
* Extends ProxyItem with lazy-loading support. When a lazyLoad function
|
|
261
|
+
* is provided, children are fetched on demand via fetch().
|
|
262
|
+
*
|
|
263
|
+
* #lazyLoad — async function (value, raw) => children[] — null when not lazy
|
|
264
|
+
* #loaded — true when: lazyLoad is null, or node already has children array,
|
|
265
|
+
* or after successful fetch(). false only for sentinel nodes
|
|
266
|
+
* (children === true) that need fetching.
|
|
267
|
+
* #loading — true during async fetch(), false otherwise. Used for spinner UI.
|
|
268
|
+
*
|
|
269
|
+
* After fetch(), uses set('children', result) to update the underlying item
|
|
270
|
+
* and trigger #children recomputation via the version counter.
|
|
271
|
+
*
|
|
272
|
+
* The lazyLoad function is propagated to all children automatically via
|
|
273
|
+
* _createChild override.
|
|
274
|
+
*/
|
|
275
|
+
export class LazyProxyItem extends ProxyItem {
|
|
276
|
+
#lazyLoad
|
|
277
|
+
#loaded = $state(true)
|
|
278
|
+
#loading = $state(false)
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {*} raw
|
|
282
|
+
* @param {Partial<typeof BASE_FIELDS>} [fields]
|
|
283
|
+
* @param {string} [key]
|
|
284
|
+
* @param {number} [level]
|
|
285
|
+
* @param {((value: unknown, raw: unknown) => Promise<unknown[]>) | null} [lazyLoad]
|
|
286
|
+
*/
|
|
287
|
+
constructor(raw, fields = {}, key = '', level = 0, lazyLoad = null) {
|
|
288
|
+
super(raw, fields, key, level)
|
|
289
|
+
this.#lazyLoad = lazyLoad
|
|
290
|
+
// Loaded if: no lazyLoad function, children already exist as an array, or no children field (leaf)
|
|
291
|
+
// Only sentinel nodes (children: true) are considered unloaded
|
|
292
|
+
this.#loaded = lazyLoad === null || this.get('children') !== true
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
get loaded() { return this.#loaded }
|
|
296
|
+
get loading() { return this.#loading }
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Fetch children via the lazyLoad function.
|
|
300
|
+
* No-op if lazyLoad is null, already loaded, or currently loading.
|
|
301
|
+
* After fetching, writes children to the underlying item via set().
|
|
302
|
+
*/
|
|
303
|
+
async fetch() {
|
|
304
|
+
if (!this.#lazyLoad || this.#loaded || this.#loading) return
|
|
305
|
+
this.#loading = true
|
|
306
|
+
try {
|
|
307
|
+
const children = await this.#lazyLoad(this.value, this.original)
|
|
308
|
+
this.set('children', children)
|
|
309
|
+
this.#loaded = true
|
|
310
|
+
} finally {
|
|
311
|
+
this.#loading = false
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** @override — propagate lazyLoad to children */
|
|
316
|
+
_createChild(raw, fields, key, level) {
|
|
317
|
+
return new LazyProxyItem(raw, fields, key, level, this.#lazyLoad)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProxyTree
|
|
3
|
+
*
|
|
4
|
+
* Reactive data layer that manages a tree of ProxyItem instances.
|
|
5
|
+
* Derives flatView (for rendering) and lookup (for O(1) access) reactively
|
|
6
|
+
* from the root proxies and their children.
|
|
7
|
+
*
|
|
8
|
+
* Used by both Wrapper and LazyWrapper as the shared data model.
|
|
9
|
+
*
|
|
10
|
+
* Key design:
|
|
11
|
+
* #rootProxies is $state([]) — reassigned (not mutated) so $derived re-computes.
|
|
12
|
+
* flatView and lookup are $derived from #rootProxies, reading proxy.children
|
|
13
|
+
* and proxy.expanded recursively, so they automatically re-derive on any
|
|
14
|
+
* expansion or children change anywhere in the tree.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { BASE_FIELDS, normalizeFields } from '@rokkit/core'
|
|
18
|
+
import { ProxyItem } from './proxy-item.svelte.js'
|
|
19
|
+
|
|
20
|
+
// ─── Tree line type computation ────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
// Maps a parent's line type to the continuation type shown at the same column
|
|
23
|
+
// in child rows below it. 'child'→'sibling' (line continues), 'last'→'empty' (branch ended).
|
|
24
|
+
const NEXT_LINE = { child: 'sibling', last: 'empty', sibling: 'sibling', empty: 'empty', icon: 'empty' }
|
|
25
|
+
|
|
26
|
+
// ─── Reactive tree traversal utilities ─────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build flat view by walking proxy.children ($derived) recursively.
|
|
30
|
+
* Reads proxy.expanded ($state) and proxy.children ($derived), so any
|
|
31
|
+
* $derived wrapping this function re-computes on expansion or children changes.
|
|
32
|
+
*
|
|
33
|
+
* Computes lineTypes per node during the walk — no second pass needed.
|
|
34
|
+
* lineTypes is an array of connector types for rendering tree lines:
|
|
35
|
+
* 'child' — branch connector
|
|
36
|
+
* 'last' — last branch connector
|
|
37
|
+
* 'sibling' — vertical continuation line
|
|
38
|
+
* 'empty' — (blank space)
|
|
39
|
+
* 'icon' — expand/collapse toggle slot
|
|
40
|
+
*
|
|
41
|
+
* @param {ProxyItem[]} proxies
|
|
42
|
+
* @param {string[]} [parentLineTypes] Line types of the parent node (for computing inherited connectors)
|
|
43
|
+
* @returns {{ key: string, proxy: ProxyItem, level: number, hasChildren: boolean, isExpandable: boolean, type: string, lineTypes: string[] }[]}
|
|
44
|
+
*/
|
|
45
|
+
function buildReactiveFlatView(proxies, parentLineTypes = []) {
|
|
46
|
+
const result = []
|
|
47
|
+
for (let i = 0; i < proxies.length; i++) {
|
|
48
|
+
const proxy = proxies[i]
|
|
49
|
+
const children = proxy.children // reads $derived — registers dependency
|
|
50
|
+
const hasChildren = children.length > 0
|
|
51
|
+
const isExpandable = hasChildren || proxy.get('children') === true // sentinel: lazy-loadable
|
|
52
|
+
const isLast = i === proxies.length - 1
|
|
53
|
+
const position = isLast ? 'last' : 'child'
|
|
54
|
+
|
|
55
|
+
// Compute line types: inherit parent's continuations + current position + icon/empty
|
|
56
|
+
const inherited = parentLineTypes.slice(0, -1).map((t) => NEXT_LINE[t] ?? 'empty')
|
|
57
|
+
if (parentLineTypes.length > 0) inherited.push(position)
|
|
58
|
+
const lineTypes = isExpandable ? [...inherited, 'icon'] : inherited
|
|
59
|
+
|
|
60
|
+
result.push({
|
|
61
|
+
key: proxy.key,
|
|
62
|
+
proxy,
|
|
63
|
+
level: proxy.level,
|
|
64
|
+
hasChildren,
|
|
65
|
+
isExpandable,
|
|
66
|
+
type: proxy.type,
|
|
67
|
+
lineTypes
|
|
68
|
+
})
|
|
69
|
+
if (hasChildren && proxy.expanded) {
|
|
70
|
+
result.push(...buildReactiveFlatView(children, lineTypes))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build lookup Map by walking proxy.children ($derived) recursively.
|
|
78
|
+
* Traverses ALL children (not just expanded) so keys are available
|
|
79
|
+
* for selection and navigation even before a group is opened.
|
|
80
|
+
*
|
|
81
|
+
* @param {ProxyItem[]} proxies
|
|
82
|
+
* @param {Map<string, ProxyItem>} [map]
|
|
83
|
+
* @returns {Map<string, ProxyItem>}
|
|
84
|
+
*/
|
|
85
|
+
function buildReactiveLookup(proxies, map = new Map()) {
|
|
86
|
+
for (const proxy of proxies) {
|
|
87
|
+
map.set(proxy.key, proxy)
|
|
88
|
+
const children = proxy.children
|
|
89
|
+
if (children.length > 0) {
|
|
90
|
+
buildReactiveLookup(children, map)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return map
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── ProxyTree ─────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export class ProxyTree {
|
|
99
|
+
#fields
|
|
100
|
+
#factory
|
|
101
|
+
|
|
102
|
+
// Root proxies — $state so reassignment triggers $derived recomputation.
|
|
103
|
+
#rootProxies = $state([])
|
|
104
|
+
|
|
105
|
+
// Reactive flatView: re-derives when proxy.expanded OR proxy.children changes.
|
|
106
|
+
flatView = $derived(buildReactiveFlatView(this.#rootProxies))
|
|
107
|
+
|
|
108
|
+
// Reactive lookup: re-derives when proxy.children changes anywhere in the tree.
|
|
109
|
+
#lookup = $derived(buildReactiveLookup(this.#rootProxies))
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {unknown[]} [items]
|
|
113
|
+
* @param {Partial<typeof BASE_FIELDS>} [fields]
|
|
114
|
+
* @param {{ createProxy?: (raw: *, fields: object, key: string, level: number) => ProxyItem }} [options]
|
|
115
|
+
*/
|
|
116
|
+
constructor(items = [], fields = {}, options = {}) {
|
|
117
|
+
this.#fields = { ...BASE_FIELDS, ...normalizeFields(fields) }
|
|
118
|
+
this.#factory = options.createProxy ?? ((raw, f, key, level) => new ProxyItem(raw, f, key, level))
|
|
119
|
+
this.#rootProxies = (items ?? []).map((raw, i) => this.#factory(raw, this.#fields, String(i), 1))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Read accessors ──────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/** @returns {ProxyItem[]} Root proxy array */
|
|
125
|
+
get roots() { return this.#rootProxies }
|
|
126
|
+
|
|
127
|
+
/** @returns {Map<string, ProxyItem>} Lookup map of all proxies by key */
|
|
128
|
+
get lookup() { return this.#lookup }
|
|
129
|
+
|
|
130
|
+
// ─── Mutation methods ────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Append new root items. Creates proxies with keys continuing from
|
|
134
|
+
* the current length. Reassigns #rootProxies to trigger $derived.
|
|
135
|
+
*
|
|
136
|
+
* @param {unknown[]} items Raw items to append as roots
|
|
137
|
+
*/
|
|
138
|
+
append(items) {
|
|
139
|
+
const start = this.#rootProxies.length
|
|
140
|
+
const newProxies = items.map((raw, i) =>
|
|
141
|
+
this.#factory(raw, this.#fields, String(start + i), 1)
|
|
142
|
+
)
|
|
143
|
+
this.#rootProxies = [...this.#rootProxies, ...newProxies]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Add children to an existing proxy node.
|
|
148
|
+
* Uses proxy.set('children', rawItems) so ProxyItem's version counter
|
|
149
|
+
* triggers #buildChildren() recomputation. The flatView and lookup
|
|
150
|
+
* derive from proxy.children reactively.
|
|
151
|
+
*
|
|
152
|
+
* @param {ProxyItem} proxy The proxy to add children to
|
|
153
|
+
* @param {unknown[]} items Raw child items
|
|
154
|
+
*/
|
|
155
|
+
addChildren(proxy, items) {
|
|
156
|
+
proxy.set('children', items)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
* 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
|
|
45
|
+
*/
|
|
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 = nextDirection === 'none' ? [] : [{ column: columnName, direction: nextDirection }]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Update column sorted flags
|
|
75
|
+
this.columns = this.columns.map((c) => {
|
|
76
|
+
const sort = this.sortState.find((s) => s.column === c.name)
|
|
77
|
+
return { ...c, sorted: sort ? sort.direction : 'none' }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Apply sort and update list
|
|
81
|
+
this.#applySortAndUpdate()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Clear all sort state and restore original data order.
|
|
86
|
+
*/
|
|
87
|
+
clearSort() {
|
|
88
|
+
this.sortState = []
|
|
89
|
+
this.columns = this.columns.map((c) => ({ ...c, sorted: 'none' }))
|
|
90
|
+
this.#list.update(this.#rawData)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Apply current sortState to rawData and feed sorted data to list controller.
|
|
95
|
+
* @private
|
|
96
|
+
*/
|
|
97
|
+
#applySortAndUpdate() {
|
|
98
|
+
if (this.sortState.length === 0) {
|
|
99
|
+
this.#list.update(this.#rawData)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const sorted = [...this.#rawData].sort((a, b) => {
|
|
104
|
+
for (const { column, direction } of this.sortState) {
|
|
105
|
+
const comparator = direction === 'ascending' ? ascending : descending
|
|
106
|
+
const result = comparator(a[column], b[column])
|
|
107
|
+
if (result !== 0) return result
|
|
108
|
+
}
|
|
109
|
+
return 0
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
this.#list.update(sorted)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// =========================================================================
|
|
116
|
+
// Data access (delegated to ListController)
|
|
117
|
+
// =========================================================================
|
|
118
|
+
|
|
119
|
+
get data() {
|
|
120
|
+
return this.#list.data
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get lookup() {
|
|
124
|
+
return this.#list.lookup
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get focusedKey() {
|
|
128
|
+
return this.#list.focusedKey
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
set focusedKey(v) {
|
|
132
|
+
this.#list.focusedKey = v
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get focused() {
|
|
136
|
+
return this.#list.focused
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get selected() {
|
|
140
|
+
return this.#list.selected
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get selectedKeys() {
|
|
144
|
+
return this.#list.selectedKeys
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// =========================================================================
|
|
148
|
+
// Navigation (delegated to ListController)
|
|
149
|
+
// =========================================================================
|
|
150
|
+
|
|
151
|
+
moveFirst() {
|
|
152
|
+
return this.#list.moveFirst()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
moveLast() {
|
|
156
|
+
return this.#list.moveLast()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
moveNext() {
|
|
160
|
+
return this.#list.moveNext()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
movePrev() {
|
|
164
|
+
return this.#list.movePrev()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
moveTo(path) {
|
|
168
|
+
return this.#list.moveTo(path)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
moveToIndex(index) {
|
|
172
|
+
return this.#list.moveToIndex(index)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// =========================================================================
|
|
176
|
+
// Selection (delegated to ListController)
|
|
177
|
+
// =========================================================================
|
|
178
|
+
|
|
179
|
+
select(key) {
|
|
180
|
+
return this.#list.select(key)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
extendSelection(key) {
|
|
184
|
+
return this.#list.extendSelection(key)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// =========================================================================
|
|
188
|
+
// Update
|
|
189
|
+
// =========================================================================
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Update the data source. Re-applies current sort if active.
|
|
193
|
+
* @param {Array<Record<string, unknown>>} data
|
|
194
|
+
*/
|
|
195
|
+
update(data) {
|
|
196
|
+
this.#rawData = data
|
|
197
|
+
this.#applySortAndUpdate()
|
|
198
|
+
}
|
|
199
|
+
}
|