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

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.108",
3
+ "version": "1.0.0-next.109",
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.106",
34
+ "@rokkit/core": "1.0.0-next.109",
35
35
  "d3-array": "^3.2.4",
36
36
  "d3-collection": "^1.0.7",
37
37
  "ramda": "^0.30.1",
@@ -1,5 +1,5 @@
1
1
  import { getKeyFromPath, defaultFields, getNestedFields } from '@rokkit/core'
2
-
2
+ import { Proxy } from './proxy.svelte'
3
3
  /**
4
4
  *
5
5
  * @param {Array<*>} items
@@ -28,30 +28,30 @@ export function flatVisibleNodes(items, fields = defaultFields, path = []) {
28
28
  }
29
29
 
30
30
  /**
31
- * Derives a flat lookup table for the given items, using the index path as key
31
+ * Derives a flat lookup table for the given items, using index paths as keys
32
+ * Each value is a Proxy instance for convenient manipulation
32
33
  *
33
- * @param {Array<*>} items
34
- * @param {import('@rokkit/core').FieldMapping} fields
35
- * @param {Array<number>} path
36
- * @returns {Record<string, { depth: number, item: any }>}
34
+ * @param {Array<*>} items - Source items array
35
+ * @param {import('@rokkit/core').FieldMapping} fields - Field mappings configuration
36
+ * @param {Array<number>} path - Current path in the tree
37
+ * @returns {Map<string, Proxy>} - Map of path keys to Proxy instances
37
38
  */
38
- export function deriveLookup(items, fields = defaultFields, path = []) {
39
- const lookup = {}
39
+ export function deriveLookupWithProxy(items, fields = defaultFields, path = []) {
40
+ const lookup = new Map()
40
41
 
41
42
  items.forEach((item, index) => {
42
43
  const itemPath = [...path, index]
43
44
  const key = getKeyFromPath(itemPath)
45
+ const proxy = new Proxy(item, fields)
44
46
 
45
- lookup[key] = { depth: itemPath.length - 1, value: item }
46
- const hasChildren =
47
- typeof item === 'object' &&
48
- Array.isArray(item[fields.children]) &&
49
- item[fields.children].length > 0
50
-
51
- if (hasChildren) {
47
+ lookup.set(key, proxy)
48
+ // console.log(key, proxy.value)
49
+ if (proxy.hasChildren) {
52
50
  const childFields = getNestedFields(fields)
53
- const result = deriveLookup(item[fields.children], childFields, itemPath)
54
- Object.assign(lookup, result)
51
+ const childLookup = deriveLookupWithProxy(proxy.get('children'), childFields, itemPath)
52
+ for (const [childKey, childValue] of childLookup.entries()) {
53
+ lookup.set(childKey, childValue)
54
+ }
55
55
  }
56
56
  })
57
57
  return lookup
@@ -1,28 +1,29 @@
1
1
  import { FieldMapper, defaultFields } from '@rokkit/core'
2
2
  import { equals } from 'ramda'
3
3
  import { SvelteSet } from 'svelte/reactivity'
4
- import { flatVisibleNodes } from './derive.svelte'
4
+ import { deriveLookupWithProxy, flatVisibleNodes } from './derive.svelte'
5
5
 
6
6
  export class ListController {
7
7
  items = $state(null)
8
8
  fields = defaultFields
9
9
  mappers = []
10
10
  #options = $state({})
11
- lookup = new Map()
11
+ // lookup = new Map()
12
12
  selectedKeys = new SvelteSet()
13
13
  focusedKey = $state(null)
14
14
  #currentIndex = -1
15
15
 
16
- selected = $derived(Array.from(this.selectedKeys).map((key) => this.lookup.get(key)))
17
- focused = $derived(this.lookup.get(this.focusedKey))
16
+ selected = $derived(Array.from(this.selectedKeys).map((key) => this.lookup.get(key).value))
17
+ focused = $derived(this.lookup.get(this.focusedKey)?.value)
18
18
  data = $derived(flatVisibleNodes(this.items, this.fields))
19
+ lookup = $derived(deriveLookupWithProxy(this.items, this.fields))
19
20
 
20
21
  constructor(items, value, fields, options) {
21
22
  this.items = items
22
23
  this.fields = { ...defaultFields, ...fields }
23
24
  this.mappers.push(new FieldMapper(fields))
24
25
  this.#options = { multiselect: false, ...options }
25
- this.init(items, value)
26
+ this.init(value)
26
27
  }
27
28
 
28
29
  /**
@@ -30,8 +31,8 @@ export class ListController {
30
31
  * @param {Array<*>} items
31
32
  * @param {*} value
32
33
  */
33
- init(items, value) {
34
- items.forEach((item, index) => this.lookup.set(String(index), item))
34
+ init(value) {
35
+ // items.forEach((item, index) => this.lookup.set(String(index), item))
35
36
  this.moveToValue(value)
36
37
  }
37
38
 
@@ -145,7 +146,7 @@ export class ListController {
145
146
  if (!this.lookup.has(key)) return false
146
147
 
147
148
  if (this.focusedKey !== key) {
148
- const { index } = this.findByValue(this.lookup.get(key))
149
+ const { index } = this.findByValue(this.lookup.get(key).value)
149
150
  this.moveToIndex(index)
150
151
  }
151
152
 
@@ -3,16 +3,12 @@ import { equals } from 'ramda'
3
3
  import { ListController } from './list-controller.svelte'
4
4
 
5
5
  export class NestedController extends ListController {
6
- constructor(items, value, fields, options) {
7
- super(items, value, fields, options)
8
- }
9
-
10
6
  /**
11
7
  * @protected
12
8
  * @param {Object} [value]
13
9
  */
14
- init(items, value) {
15
- this.createLookup(items)
10
+ init(value) {
11
+ // this.createLookup(items)
16
12
  if (value) {
17
13
  this.ensureVisible(value)
18
14
  this.moveToValue(value)
@@ -45,9 +41,14 @@ export class NestedController extends ListController {
45
41
  })
46
42
  }
47
43
 
44
+ /**
45
+ * Mark parents as expanded so that item is visible
46
+ * @param {*} value
47
+ * @returns
48
+ */
48
49
  ensureVisible(value) {
49
- const result = this.lookup.entries().find((entry) => equals(entry[1], value))
50
- if (!Array.isArray(result)) return false
50
+ const result = this.lookup.entries().find((entry) => equals(entry[1].value, value))
51
+ // console.log(result)
51
52
  const path = getPathFromKey(result[0])
52
53
 
53
54
  for (let i = 1; i < path.length; i++) {
@@ -57,30 +58,51 @@ export class NestedController extends ListController {
57
58
  return true
58
59
  }
59
60
 
61
+ /**
62
+ * Toggle expansion of item
63
+ * @param {*} value
64
+ * @returns
65
+ */
60
66
  toggleExpansion(key) {
61
67
  if (!this.lookup.has(key)) return false
62
- const item = this.lookup.get(key)
63
- const fields = this.fieldsFor(key)
64
- item[fields.expanded] = !item[this.fields.expanded]
68
+ const proxy = this.lookup.get(key)
69
+ proxy.expanded = !proxy.expanded
70
+ // const item = this.lookup.get(key)
71
+ // const fields = this.fieldsFor(key)
72
+ // item[fields.expanded] = !item[this.fields.expanded]
65
73
  return true
66
74
  }
67
75
 
76
+ /**
77
+ * Expand item
78
+ * @param {*} value
79
+ * @returns
80
+ */
68
81
  expand(key) {
69
82
  const actualKey = key ?? this.focusedKey
70
83
  if (!this.lookup.has(actualKey)) return false
71
- const item = this.lookup.get(actualKey)
72
- const fields = this.fieldsFor(actualKey)
73
- item[fields.expanded] = true
84
+ const proxy = this.lookup.get(actualKey)
85
+ proxy.expanded = true
86
+ // const item = this.lookup.get(actualKey)
87
+ // const fields = this.fieldsFor(actualKey)
88
+ // item[fields.expanded] = true
74
89
 
75
90
  return true
76
91
  }
77
92
 
93
+ /**
94
+ * Collapse item
95
+ * @param {*} value
96
+ * @returns
97
+ */
78
98
  collapse(key) {
79
99
  const actualKey = key ?? this.focusedKey
80
100
  if (!this.lookup.has(actualKey)) return false
81
- const item = this.lookup.get(actualKey)
82
- const fields = this.fieldsFor(actualKey)
83
- item[fields.expanded] = false
101
+ // const item = this.lookup.get(actualKey)
102
+ // const fields = this.fieldsFor(actualKey)
103
+ // item[fields.expanded] = false
104
+ const proxy = this.lookup.get(actualKey)
105
+ proxy.expanded = false
84
106
  return true
85
107
  }
86
108
 
@@ -2,12 +2,14 @@ import { defaultFields } from '@rokkit/core'
2
2
  import { isNil, has } from 'ramda'
3
3
 
4
4
  export class Proxy {
5
- #value = $state({})
6
- #fields = $state(defaultFields)
5
+ #original = null
6
+ #value = null
7
+ #fields = null
7
8
 
8
9
  constructor(value, fields) {
9
- this.#value = typeof value === 'object' ? value : { text: value }
10
10
  this.fields = fields
11
+ this.#original = value
12
+ this.#value = typeof value === 'object' ? value : { [this.fields.text]: value }
11
13
  }
12
14
 
13
15
  get fields() {
@@ -18,7 +20,7 @@ export class Proxy {
18
20
  }
19
21
 
20
22
  get value() {
21
- return this.#value
23
+ return typeof this.#original === 'object' ? this.#value : this.#original
22
24
  }
23
25
 
24
26
  set value(value) {
@@ -34,6 +36,7 @@ export class Proxy {
34
36
  })
35
37
  } else {
36
38
  this.#value.text = value
39
+ this.#original = value
37
40
  }
38
41
  }
39
42
 
@@ -41,10 +44,11 @@ export class Proxy {
41
44
  * Gets a mapped attribute from the original item
42
45
  *
43
46
  * @param {string} fieldName - Name of the field to get
47
+ * @param {any} defaultValue - Default value to return if not found
44
48
  * @returns {any|null} - The attribute value or null if not found
45
49
  */
46
- get(fieldName) {
47
- return this.has(fieldName) ? this.value[this.fields[fieldName]] : null
50
+ get(fieldName, defaultValue = null) {
51
+ return this.has(fieldName) ? this.#value[this.fields[fieldName]] : defaultValue
48
52
  }
49
53
 
50
54
  /**
@@ -54,6 +58,27 @@ export class Proxy {
54
58
  */
55
59
  has(fieldName) {
56
60
  const mappedField = this.fields[fieldName]
57
- return !isNil(mappedField) && has(mappedField, this.value)
61
+ return !isNil(mappedField) && has(mappedField, this.#value)
62
+ }
63
+
64
+ /**
65
+ * Identifies if the item has children
66
+ */
67
+ get hasChildren() {
68
+ return (
69
+ typeof this.#original === 'object' &&
70
+ Array.isArray(this.#value[this.fields.children]) &&
71
+ this.#value[this.fields.children].length > 0
72
+ )
73
+ }
74
+
75
+ get expanded() {
76
+ return this.has('expanded') ? this.#value[this.fields.expanded] : false
77
+ }
78
+
79
+ set expanded(value) {
80
+ if (typeof this.#original === 'object') {
81
+ this.#value[this.fields.expanded] = Boolean(value)
82
+ }
58
83
  }
59
84
  }
@@ -0,0 +1,137 @@
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
+ }
@@ -1,243 +0,0 @@
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
- }