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