@rokkit/states 1.0.0-next.125 → 1.0.0-next.127
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 +5 -6
- package/src/constants.js +1 -0
- package/src/derive.svelte.js +9 -6
- package/src/index.js +2 -1
- package/src/list-controller.svelte.js +126 -14
- package/src/messages.svelte.js +45 -0
- package/src/nested-controller.svelte.js +39 -16
- package/src/proxy.svelte.js +0 -6
- package/src/table-controller.svelte.js +199 -0
- package/src/vibe.svelte.js +34 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rokkit/states",
|
|
3
|
-
"version": "1.0.0-next.
|
|
3
|
+
"version": "1.0.0-next.127",
|
|
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",
|
|
@@ -30,11 +30,10 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@
|
|
34
|
-
"@rokkit/
|
|
33
|
+
"@rokkit/core": "1.0.0-next.127",
|
|
34
|
+
"@rokkit/data": "1.0.0-next.127",
|
|
35
35
|
"d3-array": "^3.2.4",
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"svelte": "^5.39.2"
|
|
36
|
+
"ramda": "^0.32.0",
|
|
37
|
+
"svelte": "^5.53.5"
|
|
39
38
|
}
|
|
40
39
|
}
|
package/src/constants.js
CHANGED
|
@@ -9,6 +9,7 @@ export const DEFAULT_EVENTS = {
|
|
|
9
9
|
|
|
10
10
|
export const VALID_DENSITIES = ['compact', 'comfortable', 'cozy']
|
|
11
11
|
export const VALID_MODES = ['light', 'dark']
|
|
12
|
+
export const VALID_DIRECTIONS = ['ltr', 'rtl']
|
|
12
13
|
/** @type {string[]} */
|
|
13
14
|
export const DEFAULT_STYLES = ['rokkit', 'minimal', 'material']
|
|
14
15
|
|
package/src/derive.svelte.js
CHANGED
|
@@ -5,23 +5,25 @@ import { Proxy } from './proxy.svelte'
|
|
|
5
5
|
* @param {Array<*>} items
|
|
6
6
|
* @param {import('@rokkit/core').FieldMapping} fields
|
|
7
7
|
* @param {Array<number>} path
|
|
8
|
+
* @param {Set<string>|null} expandedKeys - When provided, expansion is determined by key membership; falls back to item field
|
|
8
9
|
* @returns {Array<{ key: string, value: any }>}
|
|
9
10
|
*/
|
|
10
|
-
export function flatVisibleNodes(items, fields = defaultFields, path = []) {
|
|
11
|
+
export function flatVisibleNodes(items, fields = defaultFields, path = [], expandedKeys = null) {
|
|
11
12
|
const data = []
|
|
13
|
+
if (!items || !Array.isArray(items)) return data
|
|
14
|
+
|
|
12
15
|
items.forEach((item, index) => {
|
|
13
16
|
const itemPath = [...path, index]
|
|
14
17
|
const key = getKeyFromPath(itemPath)
|
|
15
|
-
const
|
|
16
|
-
Array.isArray(item[fields.children]) &&
|
|
17
|
-
|
|
18
|
-
item[fields.expanded]
|
|
18
|
+
const hasChildren =
|
|
19
|
+
Array.isArray(item[fields.children]) && item[fields.children].length > 0
|
|
20
|
+
const expanded = hasChildren && (expandedKeys ? expandedKeys.has(key) : item[fields.expanded])
|
|
19
21
|
|
|
20
22
|
data.push({ key, value: item })
|
|
21
23
|
|
|
22
24
|
if (expanded) {
|
|
23
25
|
const childFields = getNestedFields(fields)
|
|
24
|
-
data.push(...flatVisibleNodes(item[fields.children], childFields, itemPath))
|
|
26
|
+
data.push(...flatVisibleNodes(item[fields.children], childFields, itemPath, expandedKeys))
|
|
25
27
|
}
|
|
26
28
|
})
|
|
27
29
|
return data
|
|
@@ -38,6 +40,7 @@ export function flatVisibleNodes(items, fields = defaultFields, path = []) {
|
|
|
38
40
|
*/
|
|
39
41
|
export function deriveLookupWithProxy(items, fields = defaultFields, path = []) {
|
|
40
42
|
const lookup = new Map()
|
|
43
|
+
if (!items || !Array.isArray(items)) return lookup
|
|
41
44
|
|
|
42
45
|
items.forEach((item, index) => {
|
|
43
46
|
const itemPath = [...path, index]
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { TableController } from './table-controller.svelte.js'
|
|
2
2
|
export { Proxy } from './proxy.svelte.js'
|
|
3
3
|
export { vibe } from './vibe.svelte.js'
|
|
4
4
|
export { ListController } from './list-controller.svelte.js'
|
|
5
5
|
export { NestedController } from './nested-controller.svelte.js'
|
|
6
|
+
export { messages } from './messages.svelte.js'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FieldMapper, defaultFields } from '@rokkit/core'
|
|
1
|
+
import { FieldMapper, defaultFields, getKeyFromPath, getNestedFields } from '@rokkit/core'
|
|
2
2
|
import { equals } from 'ramda'
|
|
3
3
|
import { SvelteSet } from 'svelte/reactivity'
|
|
4
4
|
import { deriveLookupWithProxy, flatVisibleNodes } from './derive.svelte'
|
|
@@ -10,12 +10,14 @@ export class ListController {
|
|
|
10
10
|
#options = $state({})
|
|
11
11
|
// lookup = new Map()
|
|
12
12
|
selectedKeys = new SvelteSet()
|
|
13
|
+
expandedKeys = new SvelteSet()
|
|
13
14
|
focusedKey = $state(null)
|
|
14
15
|
#currentIndex = -1
|
|
16
|
+
#anchorKey = null
|
|
15
17
|
|
|
16
18
|
selected = $derived(Array.from(this.selectedKeys).map((key) => this.lookup.get(key).value))
|
|
17
19
|
focused = $derived(this.lookup.get(this.focusedKey)?.value)
|
|
18
|
-
data = $derived(flatVisibleNodes(this.items, this.fields))
|
|
20
|
+
data = $derived(flatVisibleNodes(this.items, this.fields, [], this.expandedKeys))
|
|
19
21
|
lookup = $derived(deriveLookupWithProxy(this.items, this.fields))
|
|
20
22
|
|
|
21
23
|
constructor(items, value, fields, options) {
|
|
@@ -23,9 +25,29 @@ export class ListController {
|
|
|
23
25
|
this.fields = { ...defaultFields, ...fields }
|
|
24
26
|
this.mappers.push(new FieldMapper(fields))
|
|
25
27
|
this.#options = { multiselect: false, ...options }
|
|
28
|
+
this.#initExpandedKeys(items, this.fields)
|
|
26
29
|
this.init(value)
|
|
27
30
|
}
|
|
28
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Scan items for pre-existing expanded flags and populate expandedKeys
|
|
34
|
+
* @private
|
|
35
|
+
*/
|
|
36
|
+
#initExpandedKeys(items, fields, path = []) {
|
|
37
|
+
if (!items || !Array.isArray(items)) return
|
|
38
|
+
items.forEach((item, index) => {
|
|
39
|
+
if (item === null || item === undefined || typeof item !== 'object') return
|
|
40
|
+
const itemPath = [...path, index]
|
|
41
|
+
const children = item[fields.children]
|
|
42
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
43
|
+
if (item[fields.expanded]) {
|
|
44
|
+
this.expandedKeys.add(getKeyFromPath(itemPath))
|
|
45
|
+
}
|
|
46
|
+
this.#initExpandedKeys(children, getNestedFields(fields), itemPath)
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
29
51
|
/**
|
|
30
52
|
* @private
|
|
31
53
|
* @param {Array<*>} items
|
|
@@ -54,7 +76,17 @@ export class ListController {
|
|
|
54
76
|
* @returns
|
|
55
77
|
*/
|
|
56
78
|
findByValue(value) {
|
|
57
|
-
|
|
79
|
+
// Try exact match first (full object comparison)
|
|
80
|
+
let index = this.data.findIndex((row) => equals(row.value, value))
|
|
81
|
+
|
|
82
|
+
// Fallback: match by extracted value field (e.g. primitive 'a' against { text: 'A', value: 'a' })
|
|
83
|
+
if (index < 0) {
|
|
84
|
+
const valueField = this.fields.value
|
|
85
|
+
index = this.data.findIndex(
|
|
86
|
+
(row) => typeof row.value === 'object' && row.value !== null && equals(row.value[valueField], value)
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
58
90
|
return index < 0 ? { index } : { index, ...this.data[index] }
|
|
59
91
|
}
|
|
60
92
|
|
|
@@ -79,12 +111,13 @@ export class ListController {
|
|
|
79
111
|
|
|
80
112
|
/**
|
|
81
113
|
*
|
|
82
|
-
* @param {string} path
|
|
114
|
+
* @param {string|number} path - path key string (e.g. "0", "1-0", "2-1-3")
|
|
83
115
|
* @returns
|
|
84
116
|
*/
|
|
85
117
|
moveTo(path) {
|
|
86
|
-
const
|
|
87
|
-
|
|
118
|
+
const key = String(path)
|
|
119
|
+
const index = this.data.findIndex((row) => row.key === key)
|
|
120
|
+
return index >= 0 ? this.moveToIndex(index) : false
|
|
88
121
|
}
|
|
89
122
|
|
|
90
123
|
/**
|
|
@@ -100,28 +133,44 @@ export class ListController {
|
|
|
100
133
|
return false
|
|
101
134
|
}
|
|
102
135
|
|
|
136
|
+
/**
|
|
137
|
+
* @private
|
|
138
|
+
* @param {number} index
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
#isDisabled(index) {
|
|
142
|
+
const item = this.data[index]?.value
|
|
143
|
+
if (item === null || item === undefined || typeof item !== 'object') return false
|
|
144
|
+
return item[this.fields.disabled] === true
|
|
145
|
+
}
|
|
146
|
+
|
|
103
147
|
movePrev() {
|
|
104
|
-
if (this.#currentIndex
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return this.moveLast()
|
|
148
|
+
if (this.#currentIndex < 0) return this.moveLast()
|
|
149
|
+
for (let i = this.#currentIndex - 1; i >= 0; i--) {
|
|
150
|
+
if (!this.#isDisabled(i)) return this.moveToIndex(i)
|
|
108
151
|
}
|
|
109
152
|
return false
|
|
110
153
|
}
|
|
111
154
|
|
|
112
155
|
moveNext() {
|
|
113
|
-
|
|
114
|
-
return this.moveToIndex(
|
|
156
|
+
for (let i = this.#currentIndex + 1; i < this.data.length; i++) {
|
|
157
|
+
if (!this.#isDisabled(i)) return this.moveToIndex(i)
|
|
115
158
|
}
|
|
116
159
|
return false
|
|
117
160
|
}
|
|
118
161
|
|
|
119
162
|
moveFirst() {
|
|
120
|
-
|
|
163
|
+
for (let i = 0; i < this.data.length; i++) {
|
|
164
|
+
if (!this.#isDisabled(i)) return this.moveToIndex(i)
|
|
165
|
+
}
|
|
166
|
+
return false
|
|
121
167
|
}
|
|
122
168
|
|
|
123
169
|
moveLast() {
|
|
124
|
-
|
|
170
|
+
for (let i = this.data.length - 1; i >= 0; i--) {
|
|
171
|
+
if (!this.#isDisabled(i)) return this.moveToIndex(i)
|
|
172
|
+
}
|
|
173
|
+
return false
|
|
125
174
|
}
|
|
126
175
|
|
|
127
176
|
/**
|
|
@@ -159,6 +208,7 @@ export class ListController {
|
|
|
159
208
|
this.selectedKeys.add(key)
|
|
160
209
|
}
|
|
161
210
|
|
|
211
|
+
this.#anchorKey = key
|
|
162
212
|
return true
|
|
163
213
|
}
|
|
164
214
|
|
|
@@ -173,12 +223,74 @@ export class ListController {
|
|
|
173
223
|
if (!this.lookup.has(key)) return false
|
|
174
224
|
|
|
175
225
|
if (this.#options.multiselect) {
|
|
226
|
+
this.#anchorKey = key
|
|
176
227
|
return this.toggleSelection(key)
|
|
177
228
|
} else {
|
|
178
229
|
return this.select(key)
|
|
179
230
|
}
|
|
180
231
|
}
|
|
181
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Select all non-disabled items between the anchor and the given key (inclusive).
|
|
235
|
+
* Used for Shift+click range selection in multiselect mode.
|
|
236
|
+
* @param {string} selectedKey
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*/
|
|
239
|
+
selectRange(selectedKey) {
|
|
240
|
+
const key = selectedKey ?? this.focusedKey
|
|
241
|
+
if (!this.lookup.has(key)) return false
|
|
242
|
+
|
|
243
|
+
if (!this.#options.multiselect) return this.select(key)
|
|
244
|
+
|
|
245
|
+
const anchorKey = this.#anchorKey ?? this.focusedKey
|
|
246
|
+
if (!anchorKey) return this.select(key)
|
|
247
|
+
|
|
248
|
+
const anchorIndex = this.data.findIndex((row) => row.key === anchorKey)
|
|
249
|
+
const targetIndex = this.data.findIndex((row) => row.key === key)
|
|
250
|
+
if (anchorIndex < 0 || targetIndex < 0) return false
|
|
251
|
+
|
|
252
|
+
const start = Math.min(anchorIndex, targetIndex)
|
|
253
|
+
const end = Math.max(anchorIndex, targetIndex)
|
|
254
|
+
|
|
255
|
+
this.selectedKeys.clear()
|
|
256
|
+
for (let i = start; i <= end; i++) {
|
|
257
|
+
if (!this.#isDisabled(i)) {
|
|
258
|
+
this.selectedKeys.add(this.data[i].key)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Move focus but don't change anchor (anchor stays for subsequent Shift+clicks)
|
|
263
|
+
this.moveToIndex(targetIndex)
|
|
264
|
+
return true
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Find the first visible, non-disabled item whose text starts with `query`.
|
|
269
|
+
* Search wraps around and starts after `startAfterKey` for cycling.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} query - Prefix to match (case-insensitive)
|
|
272
|
+
* @param {string|null} [startAfterKey] - Key to start searching after (for cycling)
|
|
273
|
+
* @returns {string|null} The matching item's key, or null
|
|
274
|
+
*/
|
|
275
|
+
findByText(query, startAfterKey = null) {
|
|
276
|
+
const q = query.toLowerCase()
|
|
277
|
+
let startIndex = 0
|
|
278
|
+
if (startAfterKey !== null) {
|
|
279
|
+
const idx = this.data.findIndex((row) => row.key === startAfterKey)
|
|
280
|
+
if (idx >= 0) startIndex = idx + 1
|
|
281
|
+
}
|
|
282
|
+
for (let i = 0; i < this.data.length; i++) {
|
|
283
|
+
const idx = (startIndex + i) % this.data.length
|
|
284
|
+
if (this.#isDisabled(idx)) continue
|
|
285
|
+
const proxy = this.lookup.get(this.data[idx].key)
|
|
286
|
+
const text = proxy?.get('text') ?? ''
|
|
287
|
+
if (String(text).toLowerCase().startsWith(q)) {
|
|
288
|
+
return this.data[idx].key
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return null
|
|
292
|
+
}
|
|
293
|
+
|
|
182
294
|
update(items) {
|
|
183
295
|
this.items = items
|
|
184
296
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default messages for UI components
|
|
3
|
+
* @type {import('./types').Messages}
|
|
4
|
+
*/
|
|
5
|
+
const defaultMessages = {
|
|
6
|
+
emptyList: 'No items found',
|
|
7
|
+
emptyTree: 'No data available',
|
|
8
|
+
loading: 'Loading...',
|
|
9
|
+
noResults: 'No results found',
|
|
10
|
+
select: 'Select an option',
|
|
11
|
+
search: 'Search...'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Messages store for localized UI strings
|
|
16
|
+
*/
|
|
17
|
+
class MessagesStore {
|
|
18
|
+
/** @type {import('./types').Messages} */
|
|
19
|
+
#messages = $state({ ...defaultMessages })
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Get the current messages
|
|
23
|
+
* @returns {import('./types').Messages}
|
|
24
|
+
*/
|
|
25
|
+
get current() {
|
|
26
|
+
return this.#messages
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Set custom messages (merges with defaults)
|
|
31
|
+
* @param {Partial<import('./types').Messages>} custom
|
|
32
|
+
*/
|
|
33
|
+
set(custom) {
|
|
34
|
+
this.#messages = { ...defaultMessages, ...custom }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Reset to default messages
|
|
39
|
+
*/
|
|
40
|
+
reset() {
|
|
41
|
+
this.#messages = { ...defaultMessages }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const messages = new MessagesStore()
|
|
@@ -21,51 +21,74 @@ export class NestedController extends ListController {
|
|
|
21
21
|
*/
|
|
22
22
|
ensureVisible(value) {
|
|
23
23
|
const result = this.lookup.entries().find((entry) => equals(entry[1].value, value))
|
|
24
|
+
if (!result) return false
|
|
24
25
|
const path = getPathFromKey(result[0])
|
|
25
26
|
|
|
26
27
|
for (let i = 1; i < path.length; i++) {
|
|
27
28
|
const nodeKey = getKeyFromPath(path.slice(0, i))
|
|
28
|
-
this.
|
|
29
|
+
this.expandedKeys.add(nodeKey)
|
|
29
30
|
}
|
|
30
31
|
return true
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Toggle expansion of item
|
|
35
|
-
* @param {
|
|
36
|
-
* @returns
|
|
36
|
+
* @param {string} key
|
|
37
|
+
* @returns {boolean}
|
|
37
38
|
*/
|
|
38
39
|
toggleExpansion(key) {
|
|
39
40
|
if (!this.lookup.has(key)) return false
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
if (this.expandedKeys.has(key)) {
|
|
42
|
+
this.expandedKeys.delete(key)
|
|
43
|
+
} else {
|
|
44
|
+
this.expandedKeys.add(key)
|
|
45
|
+
}
|
|
42
46
|
return true
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
/**
|
|
46
|
-
* Expand item
|
|
47
|
-
* @param {
|
|
48
|
-
* @returns
|
|
50
|
+
* Expand item. If already expanded, move focus to first child.
|
|
51
|
+
* @param {string} [key]
|
|
52
|
+
* @returns {boolean}
|
|
49
53
|
*/
|
|
50
54
|
expand(key) {
|
|
51
55
|
const actualKey = key ?? this.focusedKey
|
|
52
56
|
if (!this.lookup.has(actualKey)) return false
|
|
53
|
-
const proxy = this.lookup.get(actualKey)
|
|
54
|
-
proxy.expanded = true
|
|
55
57
|
|
|
58
|
+
const firstChildKey = `${actualKey}-0`
|
|
59
|
+
const hasChildren = this.lookup.has(firstChildKey)
|
|
60
|
+
|
|
61
|
+
if (!hasChildren) return false
|
|
62
|
+
|
|
63
|
+
if (this.expandedKeys.has(actualKey)) {
|
|
64
|
+
// Already expanded → move to first child
|
|
65
|
+
return this.moveTo(firstChildKey)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
this.expandedKeys.add(actualKey)
|
|
56
69
|
return true
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
/**
|
|
60
|
-
* Collapse item
|
|
61
|
-
* @param {
|
|
62
|
-
* @returns
|
|
73
|
+
* Collapse item. If not expandable (leaf or already collapsed), move focus to parent.
|
|
74
|
+
* @param {string} [key]
|
|
75
|
+
* @returns {boolean}
|
|
63
76
|
*/
|
|
64
77
|
collapse(key) {
|
|
65
78
|
const actualKey = key ?? this.focusedKey
|
|
66
79
|
if (!this.lookup.has(actualKey)) return false
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
|
|
81
|
+
if (this.expandedKeys.has(actualKey)) {
|
|
82
|
+
this.expandedKeys.delete(actualKey)
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Leaf or collapsed group → move to parent
|
|
87
|
+
const path = getPathFromKey(actualKey)
|
|
88
|
+
if (path.length > 1) {
|
|
89
|
+
const parentKey = getKeyFromPath(path.slice(0, -1))
|
|
90
|
+
return this.lookup.has(parentKey) ? this.moveTo(parentKey) : false
|
|
91
|
+
}
|
|
92
|
+
return false
|
|
70
93
|
}
|
|
71
94
|
}
|
package/src/proxy.svelte.js
CHANGED
|
@@ -117,10 +117,4 @@ export class Proxy {
|
|
|
117
117
|
get expanded() {
|
|
118
118
|
return this.has('expanded') ? this.#value[this.fields.expanded] : false
|
|
119
119
|
}
|
|
120
|
-
|
|
121
|
-
set expanded(value) {
|
|
122
|
-
if (typeof this.#original === 'object') {
|
|
123
|
-
this.#value[this.fields.expanded] = Boolean(value)
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
120
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { ascending, descending } from 'd3-array'
|
|
2
|
+
import { deriveColumns } from '@rokkit/data'
|
|
3
|
+
import { ListController } from './list-controller.svelte.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TableController — manages table state via composition over ListController.
|
|
7
|
+
*
|
|
8
|
+
* Handles column metadata, sorting (single and multi-column), and delegates
|
|
9
|
+
* row focus/selection/navigation to an internal ListController.
|
|
10
|
+
*/
|
|
11
|
+
export class TableController {
|
|
12
|
+
columns = $state([])
|
|
13
|
+
sortState = $state([])
|
|
14
|
+
|
|
15
|
+
#list
|
|
16
|
+
#rawData
|
|
17
|
+
#fields
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {Array<Record<string, unknown>>} data - Row data
|
|
21
|
+
* @param {Object} [options]
|
|
22
|
+
* @param {Array} [options.columns] - Column definitions (auto-derived if empty)
|
|
23
|
+
* @param {Object} [options.fields] - Row-level field mapping
|
|
24
|
+
* @param {*} [options.value] - Initial selected value
|
|
25
|
+
* @param {boolean} [options.multiselect] - Enable multi-row selection
|
|
26
|
+
*/
|
|
27
|
+
constructor(data = [], options = {}) {
|
|
28
|
+
const { columns, fields, value, multiselect } = options
|
|
29
|
+
this.#rawData = data
|
|
30
|
+
this.#fields = fields
|
|
31
|
+
this.columns = columns?.length
|
|
32
|
+
? columns.map((c) => ({ sortable: true, sorted: 'none', ...c }))
|
|
33
|
+
: deriveColumns(data)
|
|
34
|
+
this.#list = new ListController(data, value, fields, { multiselect })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// =========================================================================
|
|
38
|
+
// Sort
|
|
39
|
+
// =========================================================================
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Toggle sort on a column. Cycles: none → ascending → descending → none.
|
|
43
|
+
* @param {string} columnName - Column to sort by
|
|
44
|
+
* @param {boolean} [extend=false] - If true (Shift+click), add to sort stack
|
|
45
|
+
*/
|
|
46
|
+
sortBy(columnName, extend = false) {
|
|
47
|
+
const col = this.columns.find((c) => c.name === columnName)
|
|
48
|
+
if (!col || col.sortable === false) return
|
|
49
|
+
|
|
50
|
+
// Determine next direction
|
|
51
|
+
const cycle = { none: 'ascending', ascending: 'descending', descending: 'none' }
|
|
52
|
+
const nextDirection = cycle[col.sorted ?? 'none']
|
|
53
|
+
|
|
54
|
+
if (extend) {
|
|
55
|
+
// Multi-column sort: add/update/remove this column in the sort stack
|
|
56
|
+
const existing = this.sortState.findIndex((s) => s.column === columnName)
|
|
57
|
+
if (nextDirection === 'none') {
|
|
58
|
+
// Remove from stack
|
|
59
|
+
this.sortState = this.sortState.filter((s) => s.column !== columnName)
|
|
60
|
+
} else if (existing >= 0) {
|
|
61
|
+
// Update direction in place
|
|
62
|
+
this.sortState = this.sortState.map((s) =>
|
|
63
|
+
s.column === columnName ? { ...s, direction: nextDirection } : s
|
|
64
|
+
)
|
|
65
|
+
} else {
|
|
66
|
+
// Add to stack
|
|
67
|
+
this.sortState = [...this.sortState, { column: columnName, direction: nextDirection }]
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
// Single column sort: replace entire sort state
|
|
71
|
+
this.sortState = nextDirection === 'none' ? [] : [{ column: columnName, direction: nextDirection }]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Update column sorted flags
|
|
75
|
+
this.columns = this.columns.map((c) => {
|
|
76
|
+
const sort = this.sortState.find((s) => s.column === c.name)
|
|
77
|
+
return { ...c, sorted: sort ? sort.direction : 'none' }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Apply sort and update list
|
|
81
|
+
this.#applySortAndUpdate()
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Clear all sort state and restore original data order.
|
|
86
|
+
*/
|
|
87
|
+
clearSort() {
|
|
88
|
+
this.sortState = []
|
|
89
|
+
this.columns = this.columns.map((c) => ({ ...c, sorted: 'none' }))
|
|
90
|
+
this.#list.update(this.#rawData)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Apply current sortState to rawData and feed sorted data to list controller.
|
|
95
|
+
* @private
|
|
96
|
+
*/
|
|
97
|
+
#applySortAndUpdate() {
|
|
98
|
+
if (this.sortState.length === 0) {
|
|
99
|
+
this.#list.update(this.#rawData)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const sorted = [...this.#rawData].sort((a, b) => {
|
|
104
|
+
for (const { column, direction } of this.sortState) {
|
|
105
|
+
const comparator = direction === 'ascending' ? ascending : descending
|
|
106
|
+
const result = comparator(a[column], b[column])
|
|
107
|
+
if (result !== 0) return result
|
|
108
|
+
}
|
|
109
|
+
return 0
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
this.#list.update(sorted)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// =========================================================================
|
|
116
|
+
// Data access (delegated to ListController)
|
|
117
|
+
// =========================================================================
|
|
118
|
+
|
|
119
|
+
get data() {
|
|
120
|
+
return this.#list.data
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
get lookup() {
|
|
124
|
+
return this.#list.lookup
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get focusedKey() {
|
|
128
|
+
return this.#list.focusedKey
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
set focusedKey(v) {
|
|
132
|
+
this.#list.focusedKey = v
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get focused() {
|
|
136
|
+
return this.#list.focused
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get selected() {
|
|
140
|
+
return this.#list.selected
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get selectedKeys() {
|
|
144
|
+
return this.#list.selectedKeys
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// =========================================================================
|
|
148
|
+
// Navigation (delegated to ListController)
|
|
149
|
+
// =========================================================================
|
|
150
|
+
|
|
151
|
+
moveFirst() {
|
|
152
|
+
return this.#list.moveFirst()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
moveLast() {
|
|
156
|
+
return this.#list.moveLast()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
moveNext() {
|
|
160
|
+
return this.#list.moveNext()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
movePrev() {
|
|
164
|
+
return this.#list.movePrev()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
moveTo(path) {
|
|
168
|
+
return this.#list.moveTo(path)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
moveToIndex(index) {
|
|
172
|
+
return this.#list.moveToIndex(index)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// =========================================================================
|
|
176
|
+
// Selection (delegated to ListController)
|
|
177
|
+
// =========================================================================
|
|
178
|
+
|
|
179
|
+
select(key) {
|
|
180
|
+
return this.#list.select(key)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
extendSelection(key) {
|
|
184
|
+
return this.#list.extendSelection(key)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// =========================================================================
|
|
188
|
+
// Update
|
|
189
|
+
// =========================================================================
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Update the data source. Re-applies current sort if active.
|
|
193
|
+
* @param {Array<Record<string, unknown>>} data
|
|
194
|
+
*/
|
|
195
|
+
update(data) {
|
|
196
|
+
this.#rawData = data
|
|
197
|
+
this.#applySortAndUpdate()
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/vibe.svelte.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/** @typedef {'light' | 'dark'} ThemeMode */
|
|
2
2
|
/** @typedef {'cozy' | 'compact' | 'comfortable'} Density */
|
|
3
|
+
/** @typedef {'ltr' | 'rtl'} Direction */
|
|
3
4
|
|
|
4
|
-
import { defaultColors, defaultThemeMapping, themeRules } from '@rokkit/core'
|
|
5
|
-
import { DEFAULT_STYLES, VALID_DENSITIES, VALID_MODES } from './constants'
|
|
5
|
+
import { defaultColors, defaultThemeMapping, themeRules, detectDirection } from '@rokkit/core'
|
|
6
|
+
import { DEFAULT_STYLES, VALID_DENSITIES, VALID_MODES, VALID_DIRECTIONS } from './constants'
|
|
6
7
|
import { has } from 'ramda'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -25,6 +26,7 @@ class Vibe {
|
|
|
25
26
|
#style = $state('rokkit')
|
|
26
27
|
#colors = $state(defaultColors)
|
|
27
28
|
#density = $state('comfortable')
|
|
29
|
+
#direction = $state(detectDirection())
|
|
28
30
|
#colorMap = $state(defaultThemeMapping)
|
|
29
31
|
#palette = $derived.by(() => themeRules(this.#colorMap, this.#colors))
|
|
30
32
|
|
|
@@ -105,6 +107,21 @@ class Vibe {
|
|
|
105
107
|
}
|
|
106
108
|
}
|
|
107
109
|
|
|
110
|
+
get direction() {
|
|
111
|
+
return this.#direction
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
set direction(value) {
|
|
115
|
+
if (isAllowedValue(value, VALID_DIRECTIONS, this.#direction)) {
|
|
116
|
+
this.#direction = value
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @returns {boolean} */
|
|
121
|
+
get isRTL() {
|
|
122
|
+
return this.#direction === 'rtl'
|
|
123
|
+
}
|
|
124
|
+
|
|
108
125
|
get palette() {
|
|
109
126
|
return this.#palette
|
|
110
127
|
}
|
|
@@ -133,7 +150,12 @@ class Vibe {
|
|
|
133
150
|
if (!key) throw new Error('Key is required')
|
|
134
151
|
|
|
135
152
|
try {
|
|
136
|
-
const config = {
|
|
153
|
+
const config = {
|
|
154
|
+
style: this.#style,
|
|
155
|
+
mode: this.#mode,
|
|
156
|
+
density: this.#density,
|
|
157
|
+
direction: this.#direction
|
|
158
|
+
}
|
|
137
159
|
localStorage.setItem(key, JSON.stringify(config))
|
|
138
160
|
} catch (e) {
|
|
139
161
|
// eslint-disable-next-line no-console
|
|
@@ -149,6 +171,15 @@ class Vibe {
|
|
|
149
171
|
this.style = value.style
|
|
150
172
|
this.mode = value.mode
|
|
151
173
|
this.density = value.density
|
|
174
|
+
this.direction = value.direction
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Re-detect direction from document
|
|
179
|
+
* Useful when lang attribute changes dynamically
|
|
180
|
+
*/
|
|
181
|
+
detectDirection() {
|
|
182
|
+
this.#direction = detectDirection()
|
|
152
183
|
}
|
|
153
184
|
}
|
|
154
185
|
|