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