@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.
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.108",
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
+ }
@@ -0,0 +1,58 @@
1
+ import { getKeyFromPath, defaultFields, getNestedFields } from '@rokkit/core'
2
+
3
+ /**
4
+ *
5
+ * @param {Array<*>} items
6
+ * @param {import('@rokkit/core').FieldMapping} fields
7
+ * @param {Array<number>} path
8
+ * @returns {Array<{ key: string, value: any }>}
9
+ */
10
+ export function flatVisibleNodes(items, fields = defaultFields, path = []) {
11
+ const data = []
12
+ items.forEach((item, index) => {
13
+ const itemPath = [...path, index]
14
+ const key = getKeyFromPath(itemPath)
15
+ const expanded =
16
+ Array.isArray(item[fields.children]) &&
17
+ item[fields.children].length > 0 &&
18
+ item[fields.expanded]
19
+
20
+ data.push({ key, value: item })
21
+
22
+ if (expanded) {
23
+ const childFields = getNestedFields(fields)
24
+ data.push(...flatVisibleNodes(item[fields.children], childFields, itemPath))
25
+ }
26
+ })
27
+ return data
28
+ }
29
+
30
+ /**
31
+ * Derives a flat lookup table for the given items, using the index path as key
32
+ *
33
+ * @param {Array<*>} items
34
+ * @param {import('@rokkit/core').FieldMapping} fields
35
+ * @param {Array<number>} path
36
+ * @returns {Record<string, { depth: number, item: any }>}
37
+ */
38
+ export function deriveLookup(items, fields = defaultFields, path = []) {
39
+ const lookup = {}
40
+
41
+ items.forEach((item, index) => {
42
+ const itemPath = [...path, index]
43
+ const key = getKeyFromPath(itemPath)
44
+
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) {
52
+ const childFields = getNestedFields(fields)
53
+ const result = deriveLookup(item[fields.children], childFields, itemPath)
54
+ Object.assign(lookup, result)
55
+ }
56
+ })
57
+ return lookup
58
+ }
package/src/index.js CHANGED
@@ -1,3 +1,8 @@
1
- export { DataWrapper } from './nested.svelte'
2
1
  export { TableWrapper } from './tabular.svelte'
2
+ // export { NodeProxy } from './node-proxy.svelte'
3
+ // export { ListProxy } from './list-proxy.svelte'
4
+ // export { NestedProxy } from './nested-proxy.svelte'
5
+ export { Proxy } from './proxy.svelte'
3
6
  export { vibe } from './vibe.svelte'
7
+ export { ListController } from './list-controller.svelte'
8
+ export { NestedController } from './nested-controller.svelte'
@@ -0,0 +1,155 @@
1
+ import { SvelteMap } from 'svelte/reactivity'
2
+ import { FieldMapper } from '@rokkit/core'
3
+ import { isNil, equals } from 'ramda'
4
+ import { getKeyFromPath } from '@rokkit/core'
5
+
6
+ export class ListController {
7
+ items = $state(null)
8
+ selected = new SvelteMap()
9
+ activeItem = $state(null)
10
+ activeIndex = -1
11
+ options = $state({})
12
+
13
+ mappers = $state([])
14
+ data = $state(null)
15
+ lookup = new SvelteMap()
16
+
17
+ constructor(items, value, fields, options) {
18
+ this.items = items
19
+ this.mappers.push(new FieldMapper(fields))
20
+ this.options = { multiSelect: false, ...options }
21
+ this.init(items, value)
22
+ }
23
+
24
+ get isNested() {
25
+ return this.mappers.length > 0
26
+ }
27
+
28
+ /**
29
+ * @protected
30
+ */
31
+ init(items, value) {
32
+ console.log('init', value)
33
+ this.data = items
34
+ this.data.forEach((item, index) => {
35
+ this.lookup.set(getKeyFromPath(index), item)
36
+ })
37
+ this.moveToValue(value)
38
+ }
39
+
40
+ /**
41
+ * @private
42
+ * @param {number|number[]} path
43
+ */
44
+ getIndexFromPath(path) {
45
+ return !isNil(path) && !Array.isArray(path) ? path : path[0]
46
+ }
47
+
48
+ moveToValue(value) {
49
+ if (!value) return false
50
+
51
+ const index = this.data.findIndex((item) => equals(item, value))
52
+ return this.select(index)
53
+ }
54
+ /**
55
+ * @private
56
+ * @param {numeric} index
57
+ */
58
+ setActiveItem(index) {
59
+ this.activeItem = this.data[index]
60
+ }
61
+ /**
62
+ * @private
63
+ * @param {numeric} index
64
+ */
65
+ getItemAtIndex(index) {
66
+ return this.data[index]
67
+ }
68
+
69
+ moveTo(path) {
70
+ const index = this.getIndexFromPath(path)
71
+ console.log('moveTo', path, index)
72
+ if (index >= 0 && index < this.data.length && index !== this.activeIndex) {
73
+ this.activeIndex = index
74
+ this.setActiveItem(index)
75
+ return true
76
+ }
77
+ return false
78
+ }
79
+
80
+ movePrev() {
81
+ if (this.activeIndex < 0) {
82
+ return this.moveTo(0)
83
+ } else if (this.activeIndex > 0) {
84
+ return this.moveTo(this.activeIndex - 1)
85
+ }
86
+ return false
87
+ }
88
+
89
+ moveNext() {
90
+ if (this.activeIndex < this.data.length - 1) {
91
+ return this.moveTo(this.activeIndex + 1)
92
+ }
93
+ return false
94
+ }
95
+
96
+ moveFirst() {
97
+ this.moveTo(0)
98
+ }
99
+
100
+ moveLast() {
101
+ this.moveTo(this.data.length - 1)
102
+ }
103
+
104
+ /**
105
+ * @private
106
+ * @param {number} index
107
+ */
108
+ toggleSelection(path) {
109
+ const key = getKeyFromPath(path)
110
+ const index = this.getIndexFromPath(path)
111
+
112
+ if (this.selected.has(key)) {
113
+ this.selected.delete(key)
114
+ } else {
115
+ this.selected.add(key, this.getItemAtIndex(index))
116
+ }
117
+ return true
118
+ }
119
+
120
+ /**
121
+ *
122
+ * @param {number|number[]} path
123
+ * @returns
124
+ */
125
+ extendSelection(path) {
126
+ const index = isNil(path) ? this.activeIndex : this.getIndexFromPath(path)
127
+ if (index >= 0 && index < this.data.length) return false
128
+
129
+ if (this.options.multiselect) {
130
+ return this.toggleSelection(path)
131
+ } else {
132
+ return this.select(path)
133
+ }
134
+ }
135
+
136
+ /**
137
+ *
138
+ * @param {number|number[]} path
139
+ * @returns
140
+ */
141
+ select(path) {
142
+ const index = isNil(path) ? this.activeIndex : this.getIndexFromPath(path)
143
+ if (index > -1 && index !== this.activeIndex) {
144
+ this.selected.clear()
145
+ this.selected.set(getKeyFromPath(path), this.getItemAtIndex(index))
146
+ }
147
+
148
+ this.moveTo(path)
149
+ return true
150
+ }
151
+
152
+ toggleExpansion() {
153
+ return false
154
+ }
155
+ }
@@ -0,0 +1,176 @@
1
+ import { FieldMapper, defaultFields } from '@rokkit/core'
2
+ import { equals } from 'ramda'
3
+ import { SvelteSet } from 'svelte/reactivity'
4
+ import { flatVisibleNodes } from './derive.svelte'
5
+
6
+ export class ListController {
7
+ items = $state(null)
8
+ fields = defaultFields
9
+ mappers = []
10
+ #options = $state({})
11
+ lookup = new Map()
12
+ selectedKeys = new SvelteSet()
13
+ focusedKey = $state(null)
14
+ #currentIndex = -1
15
+
16
+ selected = $derived(Array.from(this.selectedKeys).map((key) => this.lookup.get(key)))
17
+ focused = $derived(this.lookup.get(this.focusedKey))
18
+ data = $derived(flatVisibleNodes(this.items, this.fields))
19
+
20
+ constructor(items, value, fields, options) {
21
+ this.items = items
22
+ this.fields = { ...defaultFields, ...fields }
23
+ this.mappers.push(new FieldMapper(fields))
24
+ this.#options = { multiselect: false, ...options }
25
+ this.init(items, value)
26
+ }
27
+
28
+ /**
29
+ * @private
30
+ * @param {Array<*>} items
31
+ * @param {*} value
32
+ */
33
+ init(items, value) {
34
+ items.forEach((item, index) => this.lookup.set(String(index), item))
35
+ this.moveToValue(value)
36
+ }
37
+
38
+ get isNested() {
39
+ return this.mappers.length > 1
40
+ }
41
+
42
+ get currentKey() {
43
+ return this.focusedKey
44
+ }
45
+
46
+ /**
47
+ * @private
48
+ * @param {*} value
49
+ * @returns
50
+ */
51
+ findByValue(value) {
52
+ const index = this.data.findIndex((row) => equals(row.value, value))
53
+ return index < 0 ? { index } : { index, ...this.data[index] }
54
+ }
55
+
56
+ /**
57
+ * @private
58
+ * @param {*} value
59
+ * @returns
60
+ */
61
+ moveToValue(value = null) {
62
+ const { index, key } = this.findByValue(value)
63
+
64
+ this.selectedKeys.clear()
65
+ if (index >= 0) {
66
+ this.moveToIndex(index)
67
+ this.selectedKeys.add(key)
68
+ } else {
69
+ this.focusedKey = null
70
+ this.#currentIndex = -1
71
+ }
72
+ return true
73
+ }
74
+
75
+ /**
76
+ *
77
+ * @param {string} path
78
+ * @returns
79
+ */
80
+ moveTo(path) {
81
+ const index = Number(path)
82
+ return this.moveToIndex(index)
83
+ }
84
+
85
+ /**
86
+ * @private
87
+ * @param {number} index
88
+ */
89
+ moveToIndex(index) {
90
+ if (index >= 0 && index < this.data.length && this.#currentIndex !== index) {
91
+ this.#currentIndex = index
92
+ this.focusedKey = this.data[index].key
93
+ return true
94
+ }
95
+ return false
96
+ }
97
+
98
+ movePrev() {
99
+ if (this.#currentIndex > 0) {
100
+ return this.moveToIndex(this.#currentIndex - 1)
101
+ } else if (this.#currentIndex < 0) {
102
+ return this.moveLast()
103
+ }
104
+ return false
105
+ }
106
+
107
+ moveNext() {
108
+ if (this.#currentIndex < this.data.length - 1) {
109
+ return this.moveToIndex(this.#currentIndex + 1)
110
+ }
111
+ return false
112
+ }
113
+
114
+ moveFirst() {
115
+ return this.moveToIndex(0)
116
+ }
117
+
118
+ moveLast() {
119
+ return this.moveToIndex(this.data.length - 1)
120
+ }
121
+
122
+ /**
123
+ * Toggles the selection.
124
+ * @private
125
+ * @param {string} key
126
+ */
127
+ toggleSelection(key) {
128
+ if (this.selectedKeys.has(key)) {
129
+ this.selectedKeys.delete(key)
130
+ } else {
131
+ this.selectedKeys.add(key)
132
+ }
133
+
134
+ return true
135
+ }
136
+
137
+ /**
138
+ *
139
+ * @param {string} selectedKey
140
+ * @returns
141
+ */
142
+ select(selectedKey) {
143
+ const key = selectedKey ?? this.focusedKey
144
+
145
+ if (!this.lookup.has(key)) return false
146
+
147
+ if (this.focusedKey !== key) {
148
+ const { index } = this.findByValue(this.lookup.get(key))
149
+ this.moveToIndex(index)
150
+ }
151
+
152
+ if (!this.selectedKeys.has(key)) {
153
+ this.selectedKeys.clear()
154
+ this.selectedKeys.add(key)
155
+ }
156
+
157
+ return true
158
+ }
159
+
160
+ /**
161
+ *
162
+ * @param {string} selectedKey
163
+ * @returns
164
+ */
165
+ extendSelection(selectedKey) {
166
+ const key = selectedKey ?? this.focusedKey
167
+
168
+ if (!this.lookup.has(key)) return false
169
+
170
+ if (this.#options.multiselect) {
171
+ return this.toggleSelection(key)
172
+ } else {
173
+ return this.select(key)
174
+ }
175
+ }
176
+ }