@rokkit/states 1.0.0-next.106 → 1.0.0-next.107
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 +2 -2
- package/src/base-proxy.svelte.js +243 -0
- package/src/index.js +4 -0
- package/src/list-proxy.svelte.js +127 -0
- package/src/nested-proxy.svelte.js +286 -0
- package/src/nested.svelte.js +1 -1
- package/src/node-proxy.svelte.js +293 -0
- package/src/proxy.svelte.js +59 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rokkit/states",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.107",
|
|
4
4
|
"description": "Contains generic data manipulation functions that can be used in various components.",
|
|
5
5
|
"author": "Jerry Thomas <me@jerrythomas.name>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@lukeed/uuid": "^2.0.1",
|
|
34
|
-
"@rokkit/core": "1.0.0-next.
|
|
34
|
+
"@rokkit/core": "1.0.0-next.106",
|
|
35
35
|
"d3-array": "^3.2.4",
|
|
36
36
|
"d3-collection": "^1.0.7",
|
|
37
37
|
"ramda": "^0.30.1",
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { defaultFields } from '@rokkit/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base class for all proxy models that provides a common interface
|
|
5
|
+
* for navigation and selection operations.
|
|
6
|
+
*
|
|
7
|
+
* @abstract
|
|
8
|
+
*/
|
|
9
|
+
export class BaseProxy {
|
|
10
|
+
/** @type {any[]} Original data array */
|
|
11
|
+
data = $state(null)
|
|
12
|
+
/** @type {NodeProxy|null} Currently focused node */
|
|
13
|
+
nodes = $state([])
|
|
14
|
+
/** @type {NodeProxy[]} Flattened array of all visible nodes */
|
|
15
|
+
visibleNodes = $state([])
|
|
16
|
+
|
|
17
|
+
/** @type {NodeProxy|null} Currently focused node */
|
|
18
|
+
currentNode = $state(null)
|
|
19
|
+
/** @type {Object|null} Currently focused node */
|
|
20
|
+
value = $state(null)
|
|
21
|
+
/** @type {Map<string, NodeProxy>} Map of selected nodes by id */
|
|
22
|
+
selectedNodes = $state(new Map())
|
|
23
|
+
|
|
24
|
+
/** @type {Object} Field mappings */
|
|
25
|
+
fields
|
|
26
|
+
|
|
27
|
+
/** @type {Object} Configuration options */
|
|
28
|
+
options = {
|
|
29
|
+
multiSelect: false,
|
|
30
|
+
keyboardNavigation: true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Creates a new proxy instance
|
|
35
|
+
*
|
|
36
|
+
* @param {any[]} data - Original data
|
|
37
|
+
* @param {Object} fields - Field mappings
|
|
38
|
+
* @param {Object} options - Configuration options
|
|
39
|
+
*/
|
|
40
|
+
constructor(data, value, fields = {}, options = {}) {
|
|
41
|
+
this.fields = { ...defaultFields, ...fields }
|
|
42
|
+
this.options = { ...this.options, ...options }
|
|
43
|
+
this.value = value
|
|
44
|
+
|
|
45
|
+
this.update(data)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Updates the proxy with new data
|
|
50
|
+
*
|
|
51
|
+
* @param {any} data - New data to use
|
|
52
|
+
* @returns {BaseProxy} - This proxy for method chaining
|
|
53
|
+
*/
|
|
54
|
+
update(data) {
|
|
55
|
+
this.data = data
|
|
56
|
+
this.reset()
|
|
57
|
+
return this
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resets selection state
|
|
62
|
+
*
|
|
63
|
+
* @returns {BaseProxy} - This proxy for method chaining
|
|
64
|
+
*/
|
|
65
|
+
reset() {
|
|
66
|
+
this.selectedNodes.clear()
|
|
67
|
+
this.currentNode = null
|
|
68
|
+
return this
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Move to a specific target (index, path, or item)
|
|
73
|
+
*
|
|
74
|
+
* @abstract
|
|
75
|
+
* @param {number|number[]} target - Target to move to
|
|
76
|
+
* @returns {boolean} - True if moved, false otherwise
|
|
77
|
+
*/
|
|
78
|
+
moveTo() {
|
|
79
|
+
throw new Error('moveTo() must be implemented by subclass')
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Move to the next item
|
|
84
|
+
*
|
|
85
|
+
* @abstract
|
|
86
|
+
* @returns {boolean} - True if moved, false otherwise
|
|
87
|
+
*/
|
|
88
|
+
moveNext() {
|
|
89
|
+
throw new Error('moveNext() must be implemented by subclass')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Move to the previous item
|
|
94
|
+
*
|
|
95
|
+
* @abstract
|
|
96
|
+
* @returns {boolean} - True if moved, false otherwise
|
|
97
|
+
*/
|
|
98
|
+
movePrev() {
|
|
99
|
+
throw new Error('movePrev() must be implemented by subclass')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Expand the current node
|
|
104
|
+
*
|
|
105
|
+
* @returns {boolean} - True if expanded, false otherwise
|
|
106
|
+
*/
|
|
107
|
+
expand() {
|
|
108
|
+
// Default implementation for flat lists (no-op)
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Collapse the current node
|
|
114
|
+
*
|
|
115
|
+
* @returns {boolean} - True if collapsed, false otherwise
|
|
116
|
+
*/
|
|
117
|
+
collapse() {
|
|
118
|
+
// Default implementation for flat lists (no-op)
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Toggle expanded/collapsed state
|
|
124
|
+
*
|
|
125
|
+
* @returns {boolean} - True if state changed, false otherwise
|
|
126
|
+
*/
|
|
127
|
+
toggleExpansion() {
|
|
128
|
+
// Default implementation for flat lists (no-op)
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Finds a node by a custom condition
|
|
134
|
+
*
|
|
135
|
+
* @param {Function} condition - Function that returns true for matching nodes
|
|
136
|
+
* @returns {NodeProxy|null} - The found node or null
|
|
137
|
+
*/
|
|
138
|
+
find(condition) {
|
|
139
|
+
let result = null
|
|
140
|
+
for (let i = 0; i < this.nodes.length; i++) {
|
|
141
|
+
result = this.nodes[i].find(condition)
|
|
142
|
+
if (result) return result
|
|
143
|
+
}
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Finds the path index by a custom condition
|
|
149
|
+
*
|
|
150
|
+
* @param {Function} condition - Function that returns true for matching nodes
|
|
151
|
+
* @returns {number[]} - path index of found node or empty array
|
|
152
|
+
*/
|
|
153
|
+
findPathIndex(condition) {
|
|
154
|
+
const result = this.find(condition)
|
|
155
|
+
return result?.path ?? []
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Gets a node by its path
|
|
160
|
+
*
|
|
161
|
+
* @param {number|number[]} path - Path to the node
|
|
162
|
+
* @returns {NodeProxy|null} - The node or null if not found
|
|
163
|
+
*/
|
|
164
|
+
getNodeByPath(path = []) {
|
|
165
|
+
path = Array.isArray(path) ? path : [path]
|
|
166
|
+
|
|
167
|
+
if (!path.length || !this.data) return null
|
|
168
|
+
return path.reduce((currentNodes, index, depth) => {
|
|
169
|
+
// If we've hit a dead end or invalid index, return null
|
|
170
|
+
if (currentNodes === null || index < 0 || index >= currentNodes.length) {
|
|
171
|
+
return null
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Get the node at the current index
|
|
175
|
+
const node = currentNodes[index]
|
|
176
|
+
|
|
177
|
+
// If we've reached the final depth, return the node
|
|
178
|
+
if (depth === path.length - 1) return node
|
|
179
|
+
|
|
180
|
+
// Otherwise, move to the next level (children)
|
|
181
|
+
return node.children
|
|
182
|
+
}, this.nodes)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Selects the current node
|
|
187
|
+
*
|
|
188
|
+
* @param {number|number[]} [path] - The path to the node to toggle selection
|
|
189
|
+
* @returns {boolean} - Whether the selection was successful
|
|
190
|
+
*/
|
|
191
|
+
select(path) {
|
|
192
|
+
const node = path ? this.getNodeByPath(path) : this.currentNode
|
|
193
|
+
if (!node) return false
|
|
194
|
+
|
|
195
|
+
if (!this.options.multiSelect) {
|
|
196
|
+
this.selectedNodes.forEach((node) => {
|
|
197
|
+
node.selected = false
|
|
198
|
+
})
|
|
199
|
+
this.selectedNodes.clear()
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Select the current node
|
|
203
|
+
node.selected = true
|
|
204
|
+
this.selectedNodes.set(node.id, node)
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Toggles selection on the current node (for multi-select)
|
|
210
|
+
*
|
|
211
|
+
* @param {number|number[]} [path] - The path to the node to toggle selection
|
|
212
|
+
* @returns {boolean} - Whether the operation was successful
|
|
213
|
+
*/
|
|
214
|
+
toggleSelection(path) {
|
|
215
|
+
const node = path ? this.getNodeByPath(path) : this.currentNode
|
|
216
|
+
|
|
217
|
+
if (!node) return false
|
|
218
|
+
|
|
219
|
+
node.selected = !node.selected
|
|
220
|
+
const nodeId = node.id
|
|
221
|
+
|
|
222
|
+
if (node.selected) {
|
|
223
|
+
this.selectedNodes.set(nodeId, node)
|
|
224
|
+
} else {
|
|
225
|
+
this.selectedNodes.delete(nodeId)
|
|
226
|
+
}
|
|
227
|
+
return true
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Extends selection on the current node (for multi-select)
|
|
232
|
+
*
|
|
233
|
+
* @param {number|number[]} [path] - The path to the node to extend selection
|
|
234
|
+
* @returns {boolean} - Whether the operation was successful
|
|
235
|
+
*/
|
|
236
|
+
extendSelection(path) {
|
|
237
|
+
if (this.options.multiSelect) {
|
|
238
|
+
return this.toggleSelection(path)
|
|
239
|
+
} else {
|
|
240
|
+
return this.select(path)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
export { DataWrapper } from './nested.svelte'
|
|
2
2
|
export { TableWrapper } from './tabular.svelte'
|
|
3
|
+
export { NodeProxy } from './node-proxy.svelte'
|
|
4
|
+
export { ListProxy } from './list-proxy.svelte'
|
|
5
|
+
export { NestedProxy } from './nested-proxy.svelte'
|
|
6
|
+
export { Proxy } from './proxy.svelte'
|
|
3
7
|
export { vibe } from './vibe.svelte'
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { BaseProxy } from './base-proxy.svelte.js'
|
|
2
|
+
import { NodeProxy } from './node-proxy.svelte.js'
|
|
3
|
+
import { equals } from 'ramda'
|
|
4
|
+
/**
|
|
5
|
+
* Manages a flat list of nodes with selection and focus capabilities
|
|
6
|
+
*/
|
|
7
|
+
export class ListProxy extends BaseProxy {
|
|
8
|
+
/**
|
|
9
|
+
* Creates a new ListProxy
|
|
10
|
+
*
|
|
11
|
+
* @param {Object[]} data - Original data array
|
|
12
|
+
* @param {Object} value - Active value in the list
|
|
13
|
+
* @param {Object} fields - Field mappings
|
|
14
|
+
* @param {Object} options - Configuration options
|
|
15
|
+
*/
|
|
16
|
+
constructor(data, value, fields = {}, options = {}) {
|
|
17
|
+
super(data, value, fields, options)
|
|
18
|
+
this.moveToValue(value)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Updates the proxy with new data
|
|
23
|
+
*
|
|
24
|
+
* @param {any[]} data - New data to use
|
|
25
|
+
* @returns {ListProxy} - This proxy for method chaining
|
|
26
|
+
*/
|
|
27
|
+
update(data) {
|
|
28
|
+
this.data = data || null
|
|
29
|
+
|
|
30
|
+
// Create node proxies for all items
|
|
31
|
+
if (data && Array.isArray(data)) {
|
|
32
|
+
this.nodes = data.map((item, index) => new NodeProxy(item, [index], this.fields))
|
|
33
|
+
} else {
|
|
34
|
+
this.nodes = []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.reset()
|
|
38
|
+
return this
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Moves focus to the specified target (index or item)
|
|
43
|
+
*
|
|
44
|
+
* @param {number|object} target - Index of the node or the node item to focus
|
|
45
|
+
* @returns {boolean} - Whether the move was successful
|
|
46
|
+
*/
|
|
47
|
+
moveTo(target) {
|
|
48
|
+
const index = Array.isArray(target) ? target[0] : target
|
|
49
|
+
|
|
50
|
+
// Validate index
|
|
51
|
+
if (index < 0 || index >= this.nodes.length) {
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Update focus
|
|
56
|
+
if (this.currentNode) this.currentNode.focused = false
|
|
57
|
+
|
|
58
|
+
this.currentNode = this.nodes[index]
|
|
59
|
+
this.currentNode.focused = true
|
|
60
|
+
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Moves focus to the next node
|
|
66
|
+
*
|
|
67
|
+
* @returns {boolean} - Whether the move was successful
|
|
68
|
+
*/
|
|
69
|
+
moveNext() {
|
|
70
|
+
if (!this.nodes.length) return false
|
|
71
|
+
|
|
72
|
+
let nextIndex = 0
|
|
73
|
+
|
|
74
|
+
if (this.currentNode) {
|
|
75
|
+
const currentIndex = this.nodes.indexOf(this.currentNode)
|
|
76
|
+
nextIndex = currentIndex + 1
|
|
77
|
+
|
|
78
|
+
// If at end of list, stay at current position
|
|
79
|
+
if (nextIndex >= this.nodes.length) {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return this.moveTo(nextIndex)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Moves focus to the previous node
|
|
89
|
+
*
|
|
90
|
+
* @returns {boolean} - Whether the move was successful
|
|
91
|
+
*/
|
|
92
|
+
movePrev() {
|
|
93
|
+
if (!this.nodes.length) return false
|
|
94
|
+
|
|
95
|
+
if (!this.currentNode) {
|
|
96
|
+
return this.moveTo(this.nodes.length - 1)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const currentIndex = this.nodes.indexOf(this.currentNode)
|
|
100
|
+
const prevIndex = currentIndex - 1
|
|
101
|
+
|
|
102
|
+
// If at start of list, stay at current position
|
|
103
|
+
if (prevIndex < 0) {
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return this.moveTo(prevIndex)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Finds a node by value and makes it the current & active node
|
|
112
|
+
*
|
|
113
|
+
* @param {any} value
|
|
114
|
+
* @returns
|
|
115
|
+
*/
|
|
116
|
+
moveToValue(value) {
|
|
117
|
+
if (!value || equals(this.currentNode?.value, value)) return false
|
|
118
|
+
|
|
119
|
+
const path = this.findPathIndex((node) => equals(node.value, value))
|
|
120
|
+
if (path.length > 0) {
|
|
121
|
+
this.moveTo(path)
|
|
122
|
+
this.select()
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { BaseProxy } from './base-proxy.svelte.js'
|
|
2
|
+
import { NodeProxy } from './node-proxy.svelte.js'
|
|
3
|
+
import { equals } from 'ramda'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manages a hierarchical tree of nodes with selection, focus and expansion capabilities
|
|
7
|
+
*/
|
|
8
|
+
export class NestedProxy extends BaseProxy {
|
|
9
|
+
/**
|
|
10
|
+
* Creates a new NestedProxy
|
|
11
|
+
*
|
|
12
|
+
* @param {any[]} data - Original hierarchical data array
|
|
13
|
+
* @param {any} value - Initial value for the proxy
|
|
14
|
+
* @param {Object} fields - Field mappings
|
|
15
|
+
* @param {Object} options - Configuration options
|
|
16
|
+
*/
|
|
17
|
+
constructor(data, value, fields = {}, options = {}) {
|
|
18
|
+
// Default options for tree structures
|
|
19
|
+
const defaultTreeOptions = {
|
|
20
|
+
expandedByDefault: false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
super(data, value, fields, { ...defaultTreeOptions, ...options })
|
|
24
|
+
this.moveToValue(value)
|
|
25
|
+
// this._refreshFlatNodes()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Refreshes the flatNodes
|
|
30
|
+
* @private
|
|
31
|
+
*/
|
|
32
|
+
_refreshFlatNodes(nodes = null) {
|
|
33
|
+
if (!nodes) {
|
|
34
|
+
this.visibleNodes = []
|
|
35
|
+
this._refreshFlatNodes(this.nodes)
|
|
36
|
+
} else {
|
|
37
|
+
nodes.forEach((node) => {
|
|
38
|
+
this.visibleNodes.push(node)
|
|
39
|
+
if (node.hasChildren() && node.expanded) {
|
|
40
|
+
this._refreshFlatNodes(node.children)
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Processes hierarchical data into node proxies
|
|
48
|
+
*
|
|
49
|
+
* @private
|
|
50
|
+
* @param {any[]} items - Items to process
|
|
51
|
+
*/
|
|
52
|
+
processNodes(items) {
|
|
53
|
+
if (!items || !Array.isArray(items)) return
|
|
54
|
+
|
|
55
|
+
this.nodes = []
|
|
56
|
+
items.forEach((item, index) => {
|
|
57
|
+
const node = new NodeProxy(item, [index], this.fields)
|
|
58
|
+
this.nodes.push(node)
|
|
59
|
+
})
|
|
60
|
+
this._refreshFlatNodes()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Updates the proxy with new data
|
|
65
|
+
*
|
|
66
|
+
* @param {any[]} data - New hierarchical data to use
|
|
67
|
+
* @returns {NestedProxy} - This proxy for method chaining
|
|
68
|
+
*/
|
|
69
|
+
update(data) {
|
|
70
|
+
this.data = data || null
|
|
71
|
+
this.reset()
|
|
72
|
+
|
|
73
|
+
if (!data) {
|
|
74
|
+
this.visibleNodes = []
|
|
75
|
+
return this
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Create node tree and flatten visible nodes
|
|
79
|
+
this.processNodes(data)
|
|
80
|
+
return this
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Moves focus to the specified target (index, path)
|
|
85
|
+
*
|
|
86
|
+
* @param {number|number[]} target - index or path to move to
|
|
87
|
+
* @returns {boolean} - Whether the move was successful
|
|
88
|
+
*/
|
|
89
|
+
moveTo(target) {
|
|
90
|
+
const targetNode = Array.isArray(target)
|
|
91
|
+
? this.find((node) => equals(node.path, target))
|
|
92
|
+
: this.visibleNodes[target]
|
|
93
|
+
|
|
94
|
+
if (!targetNode) return false
|
|
95
|
+
|
|
96
|
+
// Update focus
|
|
97
|
+
if (this.currentNode) {
|
|
98
|
+
this.currentNode.focused = false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.currentNode = targetNode
|
|
102
|
+
this.currentNode.focused = true
|
|
103
|
+
|
|
104
|
+
return true
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Moves focus to the next visible node
|
|
109
|
+
*
|
|
110
|
+
* @returns {boolean} - Whether the move was successful
|
|
111
|
+
*/
|
|
112
|
+
moveNext() {
|
|
113
|
+
if (!this.visibleNodes.length) return false
|
|
114
|
+
|
|
115
|
+
let nextIndex = 0
|
|
116
|
+
|
|
117
|
+
if (this.currentNode) {
|
|
118
|
+
const currentIndex = this.visibleNodes.indexOf(this.currentNode)
|
|
119
|
+
nextIndex = currentIndex + 1
|
|
120
|
+
|
|
121
|
+
// If at end of list, stay at current position
|
|
122
|
+
if (nextIndex >= this.visibleNodes.length) {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return this.moveTo(nextIndex)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Moves focus to the previous visible node
|
|
132
|
+
*
|
|
133
|
+
* @returns {boolean} - Whether the move was successful
|
|
134
|
+
*/
|
|
135
|
+
movePrev() {
|
|
136
|
+
if (!this.visibleNodes.length) return false
|
|
137
|
+
|
|
138
|
+
if (!this.currentNode) {
|
|
139
|
+
return this.moveTo(this.visibleNodes.length - 1)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const currentIndex = this.visibleNodes.indexOf(this.currentNode)
|
|
143
|
+
const prevIndex = currentIndex - 1
|
|
144
|
+
|
|
145
|
+
// If at start of list, stay at current position
|
|
146
|
+
if (prevIndex < 0) {
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return this.moveTo(prevIndex)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Expand the current node
|
|
155
|
+
*
|
|
156
|
+
* @param {number[]} [path] - The path to the node to expand
|
|
157
|
+
* @returns {boolean} - Whether the node was expanded
|
|
158
|
+
*/
|
|
159
|
+
expand(path) {
|
|
160
|
+
const node = path ? this.getNodeByPath(path) : this.currentNode
|
|
161
|
+
if (!node || !node.hasChildren() || node.expanded) {
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
node.expanded = true
|
|
166
|
+
this._refreshFlatNodes()
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Collapse the current node
|
|
172
|
+
*
|
|
173
|
+
* @param {number[]} [path] - The path to the node to collapse
|
|
174
|
+
* @returns {boolean} - Whether the node was collapsed
|
|
175
|
+
*/
|
|
176
|
+
collapse(path) {
|
|
177
|
+
const node = path ? this.getNodeByPath(path) : this.currentNode
|
|
178
|
+
if (!node || !node.hasChildren() || !node.expanded) return false
|
|
179
|
+
|
|
180
|
+
node.expanded = false
|
|
181
|
+
this._refreshFlatNodes()
|
|
182
|
+
return true
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Toggle expanded/collapsed state of current node
|
|
187
|
+
*
|
|
188
|
+
* @param {number[]} [path] - The path to the node to toggle expansion
|
|
189
|
+
* @returns {boolean} - Whether the state changed
|
|
190
|
+
*/
|
|
191
|
+
toggleExpansion(path) {
|
|
192
|
+
const node = path ? this.getNodeByPath(path) : this.currentNode
|
|
193
|
+
|
|
194
|
+
if (!node || !node.hasChildren()) {
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
node.expanded = !node.expanded
|
|
199
|
+
this._refreshFlatNodes()
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Expands all nodes
|
|
205
|
+
*
|
|
206
|
+
* @returns {NestedProxy} - This proxy for method chaining
|
|
207
|
+
*/
|
|
208
|
+
expandAll() {
|
|
209
|
+
this.visibleNodes.forEach((node) => {
|
|
210
|
+
node.expandAll()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
this._refreshFlatNodes()
|
|
214
|
+
return this
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Collapses all nodes
|
|
219
|
+
*
|
|
220
|
+
* @returns {NestedProxy} - This proxy for method chaining
|
|
221
|
+
*/
|
|
222
|
+
collapseAll() {
|
|
223
|
+
this.visibleNodes.forEach((node) => {
|
|
224
|
+
node.collapseAll()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
this._refreshFlatNodes()
|
|
228
|
+
return this
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Resets selection and expansion state
|
|
233
|
+
*
|
|
234
|
+
* @returns {NestedProxy} - This proxy for method chaining
|
|
235
|
+
*/
|
|
236
|
+
reset() {
|
|
237
|
+
// Clear focus
|
|
238
|
+
if (this.currentNode) {
|
|
239
|
+
this.currentNode.focused = false
|
|
240
|
+
this.currentNode = null
|
|
241
|
+
}
|
|
242
|
+
if (this.options.expandedByDefault) this.expandAll()
|
|
243
|
+
this.nodes.forEach((node) => node.resetStates())
|
|
244
|
+
this.selectedNodes.clear()
|
|
245
|
+
this._refreshFlatNodes()
|
|
246
|
+
|
|
247
|
+
return this
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Ensures a node is visible by expanding its ancestors
|
|
252
|
+
*
|
|
253
|
+
* @param {NodeProxy} node - Node to make visible
|
|
254
|
+
* @returns {boolean} - Whether the node is now visible
|
|
255
|
+
*/
|
|
256
|
+
ensureVisible(node) {
|
|
257
|
+
if (!node || !node.path || node.path.length <= 1) return true
|
|
258
|
+
|
|
259
|
+
for (let i = 1; i < node.path.length; i++) {
|
|
260
|
+
const parentNode = this.getNodeByPath(node.path.slice(0, i))
|
|
261
|
+
parentNode.expanded = true
|
|
262
|
+
}
|
|
263
|
+
this._refreshFlatNodes()
|
|
264
|
+
return true
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Finds a node by value and makes it the current & active node
|
|
269
|
+
*
|
|
270
|
+
* @param {any} value
|
|
271
|
+
* @returns
|
|
272
|
+
*/
|
|
273
|
+
moveToValue(value) {
|
|
274
|
+
if (!value || equals(this.currentNode?.value, value)) return false
|
|
275
|
+
|
|
276
|
+
const targetNode = this.find((node) => equals(node.value, value))
|
|
277
|
+
|
|
278
|
+
if (targetNode) {
|
|
279
|
+
this.ensureVisible(targetNode)
|
|
280
|
+
this.moveTo(targetNode.path)
|
|
281
|
+
this.select()
|
|
282
|
+
return true
|
|
283
|
+
}
|
|
284
|
+
return false
|
|
285
|
+
}
|
|
286
|
+
}
|
package/src/nested.svelte.js
CHANGED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { has, isNil } from 'ramda'
|
|
2
|
+
import { defaultFields } from '@rokkit/core'
|
|
3
|
+
/**
|
|
4
|
+
* Represents an individual node within a data structure
|
|
5
|
+
*/
|
|
6
|
+
export class NodeProxy {
|
|
7
|
+
/** @type {number[]} Path to this node */
|
|
8
|
+
path
|
|
9
|
+
/** @type {number} Depth in the hierarchy */
|
|
10
|
+
depth
|
|
11
|
+
/** @type {NodeProxy|null} Parent node */
|
|
12
|
+
parent = null
|
|
13
|
+
|
|
14
|
+
/** @type {any} Original data item */
|
|
15
|
+
original = $state({})
|
|
16
|
+
/** @type {string} Unique identifier */
|
|
17
|
+
id = $state()
|
|
18
|
+
|
|
19
|
+
/** @type {boolean} Whether this node is expanded */
|
|
20
|
+
expanded = $state(false)
|
|
21
|
+
|
|
22
|
+
/** @type {boolean} Whether this node is selected */
|
|
23
|
+
selected = $state(false)
|
|
24
|
+
|
|
25
|
+
/** @type {boolean} Whether this node has focus */
|
|
26
|
+
focused = $state(false)
|
|
27
|
+
|
|
28
|
+
/** @type {NodeProxy[]} Child nodes */
|
|
29
|
+
children = []
|
|
30
|
+
|
|
31
|
+
#fields = {}
|
|
32
|
+
/**
|
|
33
|
+
* Creates a new NodeProxy
|
|
34
|
+
*
|
|
35
|
+
* @param {any} item - Original data item
|
|
36
|
+
* @param {number[]} path - Path to this node
|
|
37
|
+
* @param {import('./field-mapper.js').FieldMapper} mapper - Field mapper
|
|
38
|
+
* @param {NodeProxy|null} parent - Parent node
|
|
39
|
+
*/
|
|
40
|
+
constructor(item, path, fields, parent = null) {
|
|
41
|
+
this.original = typeof item === 'object' ? item : { text: item }
|
|
42
|
+
this.path = path
|
|
43
|
+
this.depth = path.length - 1
|
|
44
|
+
this.parent = parent
|
|
45
|
+
this.fields = { ...defaultFields, ...fields }
|
|
46
|
+
|
|
47
|
+
this._init()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
_init() {
|
|
51
|
+
this.id = has(this.fields.id, this.original)
|
|
52
|
+
? String(this.original[this.fields.id])
|
|
53
|
+
: this.path.join('-')
|
|
54
|
+
this.expanded =
|
|
55
|
+
has(this.fields.isOpen, this.original) && Boolean(this.original[this.fields.isOpen])
|
|
56
|
+
this.selected =
|
|
57
|
+
has(this.fields.isSelected, this.original) && Boolean(this.original[this.fields.isSelected])
|
|
58
|
+
this._refreshAllChildren(this.path)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
set fields(props) {
|
|
62
|
+
this.#fields = { ...defaultFields, ...props }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get fields() {
|
|
66
|
+
return this.#fields
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Gets a mapped attribute from the original item
|
|
71
|
+
*
|
|
72
|
+
* @param {string} fieldName - Name of the field to get
|
|
73
|
+
* @returns {any|null} - The attribute value or null if not found
|
|
74
|
+
*/
|
|
75
|
+
get(fieldName) {
|
|
76
|
+
const mappedField = this.fields[fieldName]
|
|
77
|
+
if (!mappedField || !has(mappedField, this.original)) {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
return this.original[mappedField]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get the display text for this node
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
get text() {
|
|
88
|
+
return this.get('text')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get the icon for this node
|
|
93
|
+
* @returns {string|null}
|
|
94
|
+
*/
|
|
95
|
+
get icon() {
|
|
96
|
+
return this.get('icon')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Get formatted text using a formatter function
|
|
101
|
+
* @param {Function} formatter - Function to format the text
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
formattedText(formatter) {
|
|
105
|
+
const text = this.get('text')
|
|
106
|
+
if (isNil(text)) return ''
|
|
107
|
+
if (typeof formatter !== 'function') return text.toString()
|
|
108
|
+
return formatter(text, this.get('currency'))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Toggles the expanded state of this node
|
|
113
|
+
*/
|
|
114
|
+
toggle() {
|
|
115
|
+
this.expanded = !this.expanded
|
|
116
|
+
|
|
117
|
+
// Update original data if it has the isOpen field
|
|
118
|
+
if (has(this.fields.isOpen, this.original)) {
|
|
119
|
+
this.original[this.fields.isOpen] = this.expanded
|
|
120
|
+
}
|
|
121
|
+
return this
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Checks if this node has children
|
|
126
|
+
* @returns {boolean}
|
|
127
|
+
*/
|
|
128
|
+
hasChildren() {
|
|
129
|
+
return this.children.length > 0
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Expand all children
|
|
134
|
+
*/
|
|
135
|
+
expandAll() {
|
|
136
|
+
if (this.hasChildren()) {
|
|
137
|
+
this.expanded = true
|
|
138
|
+
this.children.forEach((child) => child.expandAll())
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Collapse all children
|
|
144
|
+
*/
|
|
145
|
+
collapseAll() {
|
|
146
|
+
if (this.hasChildren()) {
|
|
147
|
+
this.children.forEach((child) => child.collapseAll())
|
|
148
|
+
this.expanded = false
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Checks if this node is a leaf node (no children)
|
|
154
|
+
* @returns {boolean}
|
|
155
|
+
*/
|
|
156
|
+
isLeaf() {
|
|
157
|
+
return !this.hasChildren()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Gets the path to this node
|
|
162
|
+
* @returns {number[]}
|
|
163
|
+
*/
|
|
164
|
+
getPath() {
|
|
165
|
+
return [...this.path]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Adds a child node proxy from an existing data item.
|
|
170
|
+
* If index is provided, the child is inserted at that index, otherwise it is appended to the end
|
|
171
|
+
* @param {any} childData - Child data to add (already exists in original data)
|
|
172
|
+
* @returns {NodeProxy} - The newly created child node proxy
|
|
173
|
+
*/
|
|
174
|
+
addChild(childData, index = -1) {
|
|
175
|
+
if (this.children.length === 0) {
|
|
176
|
+
this.original[this.fields.children] = []
|
|
177
|
+
}
|
|
178
|
+
if (index < 0 || index > this.children.length) {
|
|
179
|
+
index = this.children.length
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.original[this.fields.children].splice(index, 0, childData)
|
|
183
|
+
this._refreshAllChildren(this.path)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Removes a child node by index
|
|
188
|
+
* @param {number} index - Index of the child to remove
|
|
189
|
+
* @returns {boolean} - Whether the removal was successful
|
|
190
|
+
*/
|
|
191
|
+
removeChild(index) {
|
|
192
|
+
if (index < 0 || index >= this.children.length) {
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
const child = this.original[this.fields.children][index]
|
|
196
|
+
this.original[this.fields.children].splice(index, 1)
|
|
197
|
+
this._refreshAllChildren(this.path)
|
|
198
|
+
return child
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Removes all children from this node
|
|
203
|
+
* @private
|
|
204
|
+
*/
|
|
205
|
+
_removeAllChildren() {
|
|
206
|
+
this.children.forEach((child) => child.destroy())
|
|
207
|
+
this.children = []
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Checks if the original data has children
|
|
212
|
+
* @private
|
|
213
|
+
*/
|
|
214
|
+
_hasChildren() {
|
|
215
|
+
const childAttr = this.fields.children
|
|
216
|
+
return has(childAttr, this.original) && Array.isArray(this.original[childAttr])
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Removes all children from this node
|
|
221
|
+
* @private
|
|
222
|
+
*/
|
|
223
|
+
_refreshAllChildren(path) {
|
|
224
|
+
if (this.children.length > 0) this._removeAllChildren()
|
|
225
|
+
if (this._hasChildren()) {
|
|
226
|
+
const childFields = this.fields.fields ?? this.fields
|
|
227
|
+
this.original[this.fields.children].forEach((child, index) => {
|
|
228
|
+
const childNode = new NodeProxy(child, [...path, index], childFields, this)
|
|
229
|
+
this.children.push(childNode)
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Destroys this node, cleaning up references
|
|
236
|
+
*/
|
|
237
|
+
destroy() {
|
|
238
|
+
// Clean up all children first
|
|
239
|
+
this.children.forEach((child) => child.destroy())
|
|
240
|
+
|
|
241
|
+
// Clear references
|
|
242
|
+
this.children = []
|
|
243
|
+
this.parent = null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Clear selected, focused states
|
|
248
|
+
*/
|
|
249
|
+
resetStates() {
|
|
250
|
+
this.selected = false
|
|
251
|
+
this.focused = false
|
|
252
|
+
if (this.children.length > 0) {
|
|
253
|
+
this.children.forEach((child) => child.resetStates())
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Get/Set the value for this node
|
|
259
|
+
*/
|
|
260
|
+
get value() {
|
|
261
|
+
return this.original
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
set value(newValue) {
|
|
265
|
+
if (typeof newValue === 'object') {
|
|
266
|
+
const removedKeys = Object.keys(this.original).filter(
|
|
267
|
+
(key) => !Object.keys(newValue).includes(key)
|
|
268
|
+
)
|
|
269
|
+
Object.entries(newValue).forEach(([k, v]) => {
|
|
270
|
+
this.original[k] = v
|
|
271
|
+
})
|
|
272
|
+
removedKeys.forEach((key) => {
|
|
273
|
+
delete this.original[key]
|
|
274
|
+
})
|
|
275
|
+
this._refreshAllChildren(this.path)
|
|
276
|
+
} else {
|
|
277
|
+
this.original.text = newValue
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Find nodes matching a criteria
|
|
283
|
+
* @param {function} condition - The condition to match
|
|
284
|
+
* @returns {NodeProxy|null} - First matching node or null if no matches found
|
|
285
|
+
*/
|
|
286
|
+
find(condition) {
|
|
287
|
+
if (condition(this)) return this
|
|
288
|
+
let result = null
|
|
289
|
+
for (let i = 0; i < this.children.length && !result; i++)
|
|
290
|
+
result = this.children[i].find(condition)
|
|
291
|
+
return result
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { defaultFields } from '@rokkit/core'
|
|
2
|
+
import { isNil, has } from 'ramda'
|
|
3
|
+
|
|
4
|
+
export class Proxy {
|
|
5
|
+
#value = $state({})
|
|
6
|
+
#fields = $state(defaultFields)
|
|
7
|
+
|
|
8
|
+
constructor(value, fields) {
|
|
9
|
+
this.#value = typeof value === 'object' ? value : { text: value }
|
|
10
|
+
this.fields = fields
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get fields() {
|
|
14
|
+
return this.#fields
|
|
15
|
+
}
|
|
16
|
+
set fields(value) {
|
|
17
|
+
this.#fields = { ...defaultFields, ...value }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get value() {
|
|
21
|
+
return this.#value
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
set value(value) {
|
|
25
|
+
if (typeof value === 'object') {
|
|
26
|
+
const removedKeys = Object.keys(this.#value).filter(
|
|
27
|
+
(key) => !Object.keys(value).includes(key)
|
|
28
|
+
)
|
|
29
|
+
Object.entries(value).forEach(([k, v]) => {
|
|
30
|
+
this.#value[k] = v
|
|
31
|
+
})
|
|
32
|
+
removedKeys.forEach((key) => {
|
|
33
|
+
delete this.#value[key]
|
|
34
|
+
})
|
|
35
|
+
} else {
|
|
36
|
+
this.#value.text = value
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Gets a mapped attribute from the original item
|
|
42
|
+
*
|
|
43
|
+
* @param {string} fieldName - Name of the field to get
|
|
44
|
+
* @returns {any|null} - The attribute value or null if not found
|
|
45
|
+
*/
|
|
46
|
+
get(fieldName) {
|
|
47
|
+
return this.has(fieldName) ? this.value[this.fields[fieldName]] : null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Checks if a mapped attribute exists in the original item
|
|
52
|
+
* @param {string} fieldName - Name of the field to check
|
|
53
|
+
* @returns boolean
|
|
54
|
+
*/
|
|
55
|
+
has(fieldName) {
|
|
56
|
+
const mappedField = this.fields[fieldName]
|
|
57
|
+
return !isNil(mappedField) && has(mappedField, this.value)
|
|
58
|
+
}
|
|
59
|
+
}
|