@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/states",
3
- "version": "1.0.0-next.106",
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.105",
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
+ }
@@ -1,4 +1,4 @@
1
- import { has, equals, pick, omit } from 'ramda'
1
+ import { has, equals, pick } from 'ramda'
2
2
  import { SvelteMap } from 'svelte/reactivity'
3
3
  import { DEFAULT_EVENTS } from './constants'
4
4
  import { FieldMapper, getKeyFromPath } from '@rokkit/core'
@@ -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
+ }