@rokkit/states 1.0.0-next.106 → 1.0.0-next.108

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.
@@ -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.expanded, this.original) && Boolean(this.original[this.fields.expanded])
56
+ this.selected =
57
+ has(this.fields.selected, this.original) && Boolean(this.original[this.fields.selected])
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 expanded field
118
+ if (has(this.fields.expanded, this.original)) {
119
+ this.original[this.fields.expanded] = 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
+ }
@@ -1,267 +0,0 @@
1
- import { has, equals, pick, omit } from 'ramda'
2
- import { SvelteMap } from 'svelte/reactivity'
3
- import { DEFAULT_EVENTS } from './constants'
4
- import { FieldMapper, getKeyFromPath } from '@rokkit/core'
5
-
6
- export class DataWrapper {
7
- /* @type number[] */
8
- #path = []
9
- #events = {}
10
- #init = false
11
- #keys = null
12
- #options = { multiselect: false, autoCloseSiblings: false }
13
-
14
- items = null
15
- data = $state(null)
16
- value = $state(null)
17
- mapping = new FieldMapper()
18
- currentNode = $state(null)
19
- selected = new SvelteMap()
20
-
21
- constructor(items, mapper, value, options = {}) {
22
- this.items = items
23
- this.data = items
24
- if (mapper) this.mapping = mapper
25
-
26
- this.#events = { ...DEFAULT_EVENTS, ...options.events }
27
- this.#options = { ...options, ...pick(['multiselect', 'autoCloseSiblings'], options) }
28
- this.#keys = options.keys || null
29
- this.moveToValue(value)
30
- }
31
-
32
- moveToValue(value) {
33
- this.#init = true
34
- this.value = value
35
- this.moveTo(this.findPathToItem(value))
36
- this.#expandPath(this.#path)
37
-
38
- this.#init = false
39
- }
40
-
41
- #expandPath(path) {
42
- if (!path.length) return
43
- for (let i = 0; i < path.length; i++) {
44
- const item = this.mapping.getItemByPath(this.data, path.slice(0, i + 1))
45
- if (!this.mapping.isExpanded(item)) {
46
- this.mapping.toggleExpansion(item)
47
- }
48
- }
49
- }
50
- #matchItems(a, b) {
51
- if (this.#keys) {
52
- return equals(pick(this.#keys, a), pick(this.#keys, b))
53
- } else {
54
- return equals(a, b)
55
- }
56
- }
57
- /**
58
- * Finds an item in a tree structure and returns the path as array of indices
59
- * @param {*} value - The value to find
60
- * @param {number[]} parent - The current path being explored
61
- * @returns {number[]|null} - Array of indices representing path to item, or null if not found
62
- */
63
- findPathToItem(value, parent = []) {
64
- const children = this.mapping.getChildrenByPath(this.data, parent)
65
- // Direct child check
66
- const directIndex = children.findIndex((item) => this.#matchItems(item, value))
67
- if (directIndex !== -1) {
68
- return [...parent, directIndex]
69
- }
70
-
71
- // Recursive search in children
72
- return children.reduce((path, _, index) => {
73
- if (path.length > 0) return path
74
- if (!this.mapping.hasChildren(children[index])) return []
75
-
76
- return this.findPathToItem(value, [...parent, index])
77
- }, [])
78
- }
79
-
80
- #getLastVisibleDescendant(node, nodePath) {
81
- if (!this.mapping.hasChildren(node) || !this.mapping.isExpanded(node)) {
82
- return { node, path: nodePath }
83
- }
84
-
85
- const children = this.mapping.getChildren(node)
86
- if (children.length === 0) {
87
- return { node, path: nodePath }
88
- }
89
-
90
- const lastChildIndex = children.length - 1
91
- const lastChild = children[lastChildIndex]
92
- return this.#getLastVisibleDescendant(lastChild, [...nodePath, lastChildIndex])
93
- }
94
-
95
- #getPreviousSiblingPath() {
96
- const currentIndex = this.#path[this.#path.length - 1]
97
- const prevSiblingPath = [...this.#path.slice(0, -1), currentIndex - 1]
98
- const prevSibling = this.mapping.getItemByPath(this.data, prevSiblingPath)
99
-
100
- if (this.mapping.isExpanded(prevSibling)) {
101
- const { path } = this.#getLastVisibleDescendant(prevSibling, prevSiblingPath)
102
- return path
103
- } else {
104
- return prevSiblingPath
105
- }
106
- }
107
-
108
- #getNextSiblingPath(inputPath) {
109
- const parentPath = inputPath.slice(0, -1)
110
- const currentIndex = Number(inputPath[inputPath.length - 1])
111
-
112
- const siblings = this.mapping.getChildrenByPath(this.data, parentPath)
113
- if (currentIndex < siblings.length - 1) {
114
- return [...parentPath, currentIndex + 1]
115
- } else if (parentPath.length > 0) {
116
- return this.#getNextSiblingPath(parentPath)
117
- }
118
- return null
119
- }
120
-
121
- emit(type, data) {
122
- if (!this.#init && has(type, this.#events)) this.#events[type](data)
123
- }
124
-
125
- moveTo(path) {
126
- if (!path) return
127
- const currentPath = Array.isArray(path) ? path : [path]
128
-
129
- if (equals(currentPath, this.#path)) return
130
-
131
- this.#path = currentPath
132
- if (currentPath.length === 0) {
133
- this.currentNode = null
134
- } else {
135
- this.currentNode = this.mapping.getItemByPath(this.data, currentPath)
136
- this.emit('move', { path: this.#path, node: this.currentNode })
137
- }
138
- }
139
- movePrev() {
140
- let currentPath = [0]
141
- if (this.#path.length === 0) this.moveTo([0])
142
-
143
- // Return false if at root level first item
144
- if (this.#path.length === 1 && this.#path[0] === 0) {
145
- return
146
- }
147
-
148
- // Get previous sibling index
149
- const currentIndex = this.#path[this.#path.length - 1]
150
- if (currentIndex > 0) {
151
- // Has previous sibling
152
- currentPath = this.#getPreviousSiblingPath()
153
- } else {
154
- // Move to parent
155
- currentPath = this.#path.slice(0, -1)
156
- }
157
- this.moveTo(currentPath)
158
- }
159
-
160
- moveNext() {
161
- if (this.#path.length === 0) {
162
- this.moveTo([0])
163
- return
164
- }
165
-
166
- const currentNode = this.currentNode
167
-
168
- // If current node is expanded and has children, move to first child
169
- if (this.mapping.isExpanded(currentNode) && this.mapping.hasChildren(currentNode)) {
170
- this.moveTo([...this.#path, 0])
171
- return
172
- }
173
-
174
- // Try to move to next sibling
175
- const nextSiblingPath = this.#getNextSiblingPath(this.#path)
176
- if (nextSiblingPath) {
177
- this.moveTo(nextSiblingPath)
178
- return
179
- }
180
- }
181
-
182
- #collapseParent() {
183
- this.moveTo(this.#path.slice(0, -1))
184
- if (this.mapping.isExpanded(this.currentNode)) {
185
- this.toggleExpansion()
186
- }
187
- }
188
-
189
- collapse() {
190
- // if not expanded child move to parent?
191
- if (this.mapping.isExpanded(this.currentNode)) {
192
- this.toggleExpansion()
193
- } else if (this.#path.length > 1) {
194
- this.#collapseParent()
195
- }
196
- }
197
-
198
- expand() {
199
- if (this.mapping.isExpanded(this.currentNode)) return
200
- this.toggleExpansion()
201
- }
202
-
203
- collapseSiblings() {
204
- if (!this.#options.autoCloseSiblings || !this.mapping.isExpanded(this.currentNode)) return
205
-
206
- const parentPath = this.#path.slice(0, -1)
207
- const siblings = this.mapping.getChildrenByPath(this.data, parentPath)
208
- const currentIndex = this.#path[this.#path.length - 1]
209
-
210
- siblings.forEach((sibling, index) => {
211
- if (currentIndex !== index && this.mapping.isExpanded(sibling)) {
212
- this.mapping.toggleExpansion(sibling)
213
- }
214
- })
215
- }
216
-
217
- toggleExpansion() {
218
- if (!this.currentNode || !this.mapping.hasChildren(this.currentNode)) return
219
-
220
- const eventType = this.mapping.isExpanded(this.currentNode) ? 'collapse' : 'expand'
221
- this.mapping.toggleExpansion(this.currentNode)
222
- this.collapseSiblings()
223
- this.emit(eventType, { path: this.#path, node: this.currentNode })
224
- }
225
-
226
- select(path = null) {
227
- this.moveTo(path)
228
-
229
- if (this.currentNode) {
230
- this.value = this.mapping.getItemByPath(this.data, this.#path)
231
- this.selected.clear()
232
- this.selected.set(getKeyFromPath(this.#path), this.currentNode)
233
- this.emit('select', {
234
- path: this.#path,
235
- node: this.currentNode,
236
- selected: this.selected
237
- })
238
- }
239
- }
240
-
241
- #toggleSelection() {
242
- if (!this.currentNode) return
243
-
244
- const isSelected = this.selected.has(getKeyFromPath(this.#path))
245
-
246
- if (isSelected) {
247
- this.selected.delete(getKeyFromPath(this.#path))
248
- } else {
249
- this.selected.set(getKeyFromPath(this.#path), this.currentNode)
250
- }
251
-
252
- this.emit('select', {
253
- path: this.#path,
254
- node: this.currentNode,
255
- selected: this.selected
256
- })
257
- }
258
-
259
- extendSelection(path = null) {
260
- this.moveTo(path)
261
- if (this.#options.multiselect) {
262
- this.#toggleSelection()
263
- } else {
264
- this.select()
265
- }
266
- }
267
- }