@rokkit/states 1.0.0-next.127 → 1.0.0-next.128
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 +3 -3
- package/src/derive.svelte.js +22 -13
- package/src/index.js +5 -2
- package/src/lazy-wrapper.svelte.js +119 -0
- package/src/list-controller.svelte.js +5 -5
- package/src/media.svelte.js +24 -0
- package/src/messages.svelte.js +28 -2
- package/src/proxy-item.svelte.js +320 -0
- package/src/proxy-tree.svelte.js +158 -0
- package/src/vibe.svelte.js +3 -3
- package/src/wrapper.svelte.js +231 -0
- package/src/nested-controller.svelte.js +0 -94
- package/src/proxy.svelte.js +0 -120
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.128",
|
|
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,8 +30,8 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@rokkit/core": "1.0.0-next.
|
|
34
|
-
"@rokkit/data": "1.0.0-next.
|
|
33
|
+
"@rokkit/core": "1.0.0-next.128",
|
|
34
|
+
"@rokkit/data": "1.0.0-next.128",
|
|
35
35
|
"d3-array": "^3.2.4",
|
|
36
36
|
"ramda": "^0.32.0",
|
|
37
37
|
"svelte": "^5.53.5"
|
package/src/derive.svelte.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
import { getKeyFromPath,
|
|
2
|
-
import { Proxy } from './proxy.svelte'
|
|
1
|
+
import { getKeyFromPath, DEFAULT_FIELDS, getNestedFields } from '@rokkit/core'
|
|
3
2
|
/**
|
|
4
3
|
*
|
|
5
4
|
* @param {Array<*>} items
|
|
6
5
|
* @param {import('@rokkit/core').FieldMapping} fields
|
|
7
6
|
* @param {Array<number>} path
|
|
8
7
|
* @param {Set<string>|null} expandedKeys - When provided, expansion is determined by key membership; falls back to item field
|
|
9
|
-
* @returns {Array<{ key: string, value: any }>}
|
|
8
|
+
* @returns {Array<{ key: string, value: any, level: number, hasChildren: boolean }>}
|
|
10
9
|
*/
|
|
11
|
-
export function flatVisibleNodes(items, fields =
|
|
10
|
+
export function flatVisibleNodes(items, fields = DEFAULT_FIELDS, path = [], expandedKeys = null) {
|
|
12
11
|
const data = []
|
|
13
12
|
if (!items || !Array.isArray(items)) return data
|
|
14
13
|
|
|
14
|
+
const level = path.length
|
|
15
|
+
|
|
15
16
|
items.forEach((item, index) => {
|
|
16
17
|
const itemPath = [...path, index]
|
|
17
18
|
const key = getKeyFromPath(itemPath)
|
|
@@ -19,7 +20,7 @@ export function flatVisibleNodes(items, fields = defaultFields, path = [], expan
|
|
|
19
20
|
Array.isArray(item[fields.children]) && item[fields.children].length > 0
|
|
20
21
|
const expanded = hasChildren && (expandedKeys ? expandedKeys.has(key) : item[fields.expanded])
|
|
21
22
|
|
|
22
|
-
data.push({ key, value: item })
|
|
23
|
+
data.push({ key, value: item, level, hasChildren })
|
|
23
24
|
|
|
24
25
|
if (expanded) {
|
|
25
26
|
const childFields = getNestedFields(fields)
|
|
@@ -30,26 +31,34 @@ export function flatVisibleNodes(items, fields = defaultFields, path = [], expan
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
* Derives a flat lookup table for the given items, using index paths as keys
|
|
34
|
-
* Each value is a
|
|
34
|
+
* Derives a flat lookup table for the given items, using index paths as keys.
|
|
35
|
+
* Each value is a lightweight wrapper exposing the original item as both
|
|
36
|
+
* `.value` and `.original` for backward compatibility, plus a `.get(field)`
|
|
37
|
+
* method that reads from the item via field mapping.
|
|
35
38
|
*
|
|
36
39
|
* @param {Array<*>} items - Source items array
|
|
37
40
|
* @param {import('@rokkit/core').FieldMapping} fields - Field mappings configuration
|
|
38
41
|
* @param {Array<number>} path - Current path in the tree
|
|
39
|
-
* @returns {Map<string,
|
|
42
|
+
* @returns {Map<string, { value: *, original: *, label: string, get: (f: string) => * }>}
|
|
40
43
|
*/
|
|
41
|
-
export function deriveLookupWithProxy(items, fields =
|
|
44
|
+
export function deriveLookupWithProxy(items, fields = DEFAULT_FIELDS, path = []) {
|
|
42
45
|
const lookup = new Map()
|
|
43
46
|
if (!items || !Array.isArray(items)) return lookup
|
|
44
47
|
|
|
45
48
|
items.forEach((item, index) => {
|
|
46
49
|
const itemPath = [...path, index]
|
|
47
50
|
const key = getKeyFromPath(itemPath)
|
|
48
|
-
const
|
|
51
|
+
const norm = typeof item === 'object' && item !== null ? item : { [fields.text]: item }
|
|
52
|
+
const entry = {
|
|
53
|
+
value: item,
|
|
54
|
+
original: item,
|
|
55
|
+
label: String(norm[fields.text] ?? ''),
|
|
56
|
+
get: (fieldName) => norm[fields[fieldName] ?? fieldName]
|
|
57
|
+
}
|
|
49
58
|
|
|
50
|
-
lookup.set(key,
|
|
51
|
-
const children =
|
|
52
|
-
if (children.length > 0) {
|
|
59
|
+
lookup.set(key, entry)
|
|
60
|
+
const children = norm[fields.children] ?? []
|
|
61
|
+
if (Array.isArray(children) && children.length > 0) {
|
|
53
62
|
const childFields = getNestedFields(fields)
|
|
54
63
|
const childLookup = deriveLookupWithProxy(children, childFields, itemPath)
|
|
55
64
|
for (const [childKey, childValue] of childLookup.entries()) {
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export { TableController } from './table-controller.svelte.js'
|
|
2
|
-
export { Proxy } from './proxy.svelte.js'
|
|
3
2
|
export { vibe } from './vibe.svelte.js'
|
|
4
3
|
export { ListController } from './list-controller.svelte.js'
|
|
5
|
-
export { NestedController } from './nested-controller.svelte.js'
|
|
6
4
|
export { messages } from './messages.svelte.js'
|
|
5
|
+
export { ProxyItem, LazyProxyItem, BASE_FIELDS } from './proxy-item.svelte.js'
|
|
6
|
+
export { ProxyTree } from './proxy-tree.svelte.js'
|
|
7
|
+
export { Wrapper } from './wrapper.svelte.js'
|
|
8
|
+
export { LazyWrapper } from './lazy-wrapper.svelte.js'
|
|
9
|
+
export { watchMedia, defaultBreakpoints } from './media.svelte.js'
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LazyWrapper
|
|
3
|
+
*
|
|
4
|
+
* Extends Wrapper with lazy-loading support for tree nodes that use
|
|
5
|
+
* LazyProxyItem. Overrides expand(), select(), and toggle() to detect
|
|
6
|
+
* unloaded sentinel nodes (proxy.loaded === false) and trigger fetch()
|
|
7
|
+
* before delegating to the base Wrapper behavior.
|
|
8
|
+
*
|
|
9
|
+
* Also provides loadMore() for root-level pagination via onlazyload callback.
|
|
10
|
+
*
|
|
11
|
+
* All navigation logic (next, prev, first, last, collapse, moveTo,
|
|
12
|
+
* moveToValue, findByText, cancel, blur, extend, range) is inherited
|
|
13
|
+
* from Wrapper — no duplication.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Wrapper } from './wrapper.svelte.js'
|
|
17
|
+
|
|
18
|
+
// ─── LazyWrapper ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export class LazyWrapper extends Wrapper {
|
|
21
|
+
#onlazyload
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {import('./proxy-tree.svelte.js').ProxyTree} proxyTree
|
|
25
|
+
* @param {{ onselect?: Function, onchange?: Function, onlazyload?: Function }} [options]
|
|
26
|
+
*/
|
|
27
|
+
constructor(proxyTree, options = {}) {
|
|
28
|
+
super(proxyTree, options)
|
|
29
|
+
this.#onlazyload = options.onlazyload
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Root-level pagination ──────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Load more root-level items via the onlazyload callback.
|
|
36
|
+
* Appends results to the proxy tree.
|
|
37
|
+
*/
|
|
38
|
+
async loadMore() {
|
|
39
|
+
if (!this.#onlazyload) return
|
|
40
|
+
const result = await this.#onlazyload()
|
|
41
|
+
if (Array.isArray(result) && result.length > 0) {
|
|
42
|
+
this.proxyTree.append(result)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Overrides: lazy sentinel detection ─────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Expand focused group. If the node is an unloaded lazy sentinel
|
|
50
|
+
* (proxy.loaded === false), fetch children first then expand.
|
|
51
|
+
* Otherwise delegates to Wrapper's expand().
|
|
52
|
+
*/
|
|
53
|
+
expand(_path) {
|
|
54
|
+
const key = this.focusedKey
|
|
55
|
+
if (!key) return
|
|
56
|
+
const node = this.flatView.find((n) => n.key === key)
|
|
57
|
+
if (!node) return
|
|
58
|
+
|
|
59
|
+
// Lazy unloaded node: fetch children, then expand
|
|
60
|
+
if (!node.hasChildren && node.proxy.loaded === false) {
|
|
61
|
+
node.proxy.fetch().then(() => {
|
|
62
|
+
node.proxy.expanded = true
|
|
63
|
+
})
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
super.expand(_path)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Select item at path (or focusedKey). If the target is a group/expandable
|
|
72
|
+
* node with proxy.loaded === false, fetch children first then expand.
|
|
73
|
+
* Otherwise delegates to Wrapper's select().
|
|
74
|
+
*/
|
|
75
|
+
select(path) {
|
|
76
|
+
const key = path ?? this.focusedKey
|
|
77
|
+
if (!key) return
|
|
78
|
+
const proxy = this.lookup.get(key)
|
|
79
|
+
if (!proxy) return
|
|
80
|
+
|
|
81
|
+
// Group with children: delegate to super (toggle expansion)
|
|
82
|
+
if (proxy.hasChildren) {
|
|
83
|
+
super.select(path)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Lazy sentinel: fetch children, then expand
|
|
88
|
+
if (proxy.loaded === false) {
|
|
89
|
+
proxy.fetch().then(() => { proxy.expanded = true })
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
super.select(path)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Toggle expansion of group at path. If the node is an unloaded lazy
|
|
98
|
+
* sentinel, fetch children first then expand.
|
|
99
|
+
* Otherwise delegates to Wrapper's toggle().
|
|
100
|
+
*/
|
|
101
|
+
toggle(path) {
|
|
102
|
+
const key = path ?? this.focusedKey
|
|
103
|
+
if (!key) return
|
|
104
|
+
const proxy = this.lookup.get(key)
|
|
105
|
+
if (!proxy) return
|
|
106
|
+
|
|
107
|
+
// Group with children: normal toggle
|
|
108
|
+
if (proxy.hasChildren) {
|
|
109
|
+
super.toggle(path)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Lazy sentinel: fetch children, then expand
|
|
114
|
+
if (proxy.loaded === false) {
|
|
115
|
+
proxy.fetch().then(() => { proxy.expanded = true })
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { FieldMapper,
|
|
1
|
+
import { FieldMapper, DEFAULT_FIELDS, 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'
|
|
5
5
|
|
|
6
6
|
export class ListController {
|
|
7
7
|
items = $state(null)
|
|
8
|
-
fields =
|
|
8
|
+
fields = DEFAULT_FIELDS
|
|
9
9
|
mappers = []
|
|
10
10
|
#options = $state({})
|
|
11
11
|
// lookup = new Map()
|
|
@@ -22,7 +22,7 @@ export class ListController {
|
|
|
22
22
|
|
|
23
23
|
constructor(items, value, fields, options) {
|
|
24
24
|
this.items = items
|
|
25
|
-
this.fields = { ...
|
|
25
|
+
this.fields = { ...DEFAULT_FIELDS, ...fields }
|
|
26
26
|
this.mappers.push(new FieldMapper(fields))
|
|
27
27
|
this.#options = { multiselect: false, ...options }
|
|
28
28
|
this.#initExpandedKeys(items, this.fields)
|
|
@@ -282,8 +282,8 @@ export class ListController {
|
|
|
282
282
|
for (let i = 0; i < this.data.length; i++) {
|
|
283
283
|
const idx = (startIndex + i) % this.data.length
|
|
284
284
|
if (this.#isDisabled(idx)) continue
|
|
285
|
-
const
|
|
286
|
-
const text =
|
|
285
|
+
const entry = this.lookup.get(this.data[idx].key)
|
|
286
|
+
const text = entry?.label ?? ''
|
|
287
287
|
if (String(text).toLowerCase().startsWith(q)) {
|
|
288
288
|
return this.data[idx].key
|
|
289
289
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { MediaQuery } from 'svelte/reactivity'
|
|
2
|
+
|
|
3
|
+
/** @type {Record<string, string>} */
|
|
4
|
+
export const defaultBreakpoints = {
|
|
5
|
+
small: '(max-width: 767px)',
|
|
6
|
+
medium: '(min-width: 768px) and (max-width: 1023px)',
|
|
7
|
+
large: '(min-width: 1024px)',
|
|
8
|
+
extraLarge: '(min-width: 1280px)',
|
|
9
|
+
short: '(max-height: 399px)',
|
|
10
|
+
landscape: '(orientation: landscape)',
|
|
11
|
+
tiny: '(orientation: portrait) and (max-height: 599px)',
|
|
12
|
+
dark: '(prefers-color-scheme: dark)',
|
|
13
|
+
noanimations: '(prefers-reduced-motion: reduce)'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** @param {Record<string, string>} breakpoints */
|
|
17
|
+
export function watchMedia(breakpoints = defaultBreakpoints) {
|
|
18
|
+
/** @type {Record<string, MediaQuery>} */
|
|
19
|
+
const current = {}
|
|
20
|
+
for (const [key, query] of Object.entries(breakpoints)) {
|
|
21
|
+
current[key] = new MediaQuery(query)
|
|
22
|
+
}
|
|
23
|
+
return current
|
|
24
|
+
}
|
package/src/messages.svelte.js
CHANGED
|
@@ -8,7 +8,25 @@ const defaultMessages = {
|
|
|
8
8
|
loading: 'Loading...',
|
|
9
9
|
noResults: 'No results found',
|
|
10
10
|
select: 'Select an option',
|
|
11
|
-
search: 'Search...'
|
|
11
|
+
search: 'Search...',
|
|
12
|
+
list: { label: 'List' },
|
|
13
|
+
tree: { label: 'Tree', expand: 'Expand', collapse: 'Collapse', loading: 'Loading', loadMore: 'Load More' },
|
|
14
|
+
toolbar: { label: 'Toolbar' },
|
|
15
|
+
menu: { label: 'Menu' },
|
|
16
|
+
toggle: { label: 'Selection' },
|
|
17
|
+
rating: { label: 'Rating' },
|
|
18
|
+
stepper: { label: 'Progress' },
|
|
19
|
+
breadcrumbs: { label: 'Breadcrumb' },
|
|
20
|
+
carousel: { label: 'Carousel', prev: 'Previous slide', next: 'Next slide', slides: 'Slide navigation' },
|
|
21
|
+
tabs: { add: 'Add tab', remove: 'Remove tab' },
|
|
22
|
+
code: { copy: 'Copy code', copied: 'Copied!' },
|
|
23
|
+
range: { lower: 'Lower bound', upper: 'Upper bound', value: 'Value' },
|
|
24
|
+
search_: { clear: 'Clear search' },
|
|
25
|
+
filter: { remove: 'Remove filter' },
|
|
26
|
+
grid: { label: 'Grid' },
|
|
27
|
+
uploadProgress: { label: 'Upload progress', clear: 'Clear all', cancel: 'Cancel', retry: 'Retry', remove: 'Remove' },
|
|
28
|
+
floatingNav: { label: 'Page navigation', pin: 'Pin navigation', unpin: 'Unpin navigation' },
|
|
29
|
+
mode: { system: 'System', light: 'Light', dark: 'Dark' }
|
|
12
30
|
}
|
|
13
31
|
|
|
14
32
|
/**
|
|
@@ -31,7 +49,15 @@ class MessagesStore {
|
|
|
31
49
|
* @param {Partial<import('./types').Messages>} custom
|
|
32
50
|
*/
|
|
33
51
|
set(custom) {
|
|
34
|
-
|
|
52
|
+
const merged = { ...defaultMessages }
|
|
53
|
+
for (const key of Object.keys(custom)) {
|
|
54
|
+
if (typeof custom[key] === 'object' && custom[key] !== null && typeof merged[key] === 'object' && merged[key] !== null) {
|
|
55
|
+
merged[key] = { ...merged[key], ...custom[key] }
|
|
56
|
+
} else {
|
|
57
|
+
merged[key] = custom[key]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
this.#messages = merged
|
|
35
61
|
}
|
|
36
62
|
|
|
37
63
|
/**
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProxyItem
|
|
3
|
+
*
|
|
4
|
+
* Wraps a raw item (object or primitive) and provides uniform field access.
|
|
5
|
+
*
|
|
6
|
+
* #raw — always the original input, never mutated.
|
|
7
|
+
* #item — the object used for all field accesses:
|
|
8
|
+
* for objects: same reference as #raw
|
|
9
|
+
* for primitives: { [fields.text]: raw, [fields.value]: raw }
|
|
10
|
+
* This normalisation means get() and all getters work through the
|
|
11
|
+
* same field-mapping path with no special-casing.
|
|
12
|
+
* #key — path-based identifier ('0', '0-1', '0-1-2', …) assigned by
|
|
13
|
+
* ProxyTree and propagated into children automatically.
|
|
14
|
+
* #level — nesting depth: always equals key.split('-').length.
|
|
15
|
+
* (1 = root, 2 = first-level children, 3 = grandchildren, …)
|
|
16
|
+
*
|
|
17
|
+
* get(fieldName) — maps semantic name → raw key → #item value.
|
|
18
|
+
* For field-mapped attributes only (text, value, icon, …).
|
|
19
|
+
* Structural props (key, level) and control state
|
|
20
|
+
* (expanded, selected) are accessed directly as properties.
|
|
21
|
+
*
|
|
22
|
+
* Direct getters: label, value, id — primary access.
|
|
23
|
+
* All other fields via get(fieldName).
|
|
24
|
+
*
|
|
25
|
+
* children — auto-wrapped as ProxyItem instances via $derived, with their
|
|
26
|
+
* keys and levels already set. Stable references so
|
|
27
|
+
* $derived correctly tracks nested expanded state.
|
|
28
|
+
*
|
|
29
|
+
* Control state (expanded / selected) — two modes:
|
|
30
|
+
* external: item has the field → proxy reads/writes through to #item
|
|
31
|
+
* internal: item lacks the field → proxy owns it as $state
|
|
32
|
+
* Primitive items always use internal mode (their #item has no state fields).
|
|
33
|
+
*
|
|
34
|
+
* ProxyItems are created once and never recreated — this keeps $state
|
|
35
|
+
* signals stable so $derived computations track them correctly.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { BASE_FIELDS, normalizeFields } from '@rokkit/core'
|
|
39
|
+
export { BASE_FIELDS }
|
|
40
|
+
|
|
41
|
+
// Auto-increment counter for generating stable unique IDs.
|
|
42
|
+
let _nextId = 1
|
|
43
|
+
|
|
44
|
+
// ─── ProxyItem ────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export class ProxyItem {
|
|
47
|
+
#raw // original input — never touched after construction
|
|
48
|
+
#item // normalised object used for all field accesses
|
|
49
|
+
#fields
|
|
50
|
+
#id // stable unique identifier — from item field or auto-generated
|
|
51
|
+
#key // path-based key e.g. '0', '0-1', '0-1-2'
|
|
52
|
+
#level // nesting depth: 1 = root
|
|
53
|
+
|
|
54
|
+
// Control state — always read from here so $derived tracks changes.
|
|
55
|
+
// Initialised from #item field when present (external mode).
|
|
56
|
+
#expanded = $state(false)
|
|
57
|
+
#selected = $state(false)
|
|
58
|
+
|
|
59
|
+
// Version counter — incremented by set() to trigger #children recomputation.
|
|
60
|
+
// $derived reads this so it re-derives when set('children', ...) is called.
|
|
61
|
+
#version = $state(0)
|
|
62
|
+
|
|
63
|
+
// Children auto-wrapped as ProxyItem instances with keys + levels assigned.
|
|
64
|
+
// $derived ensures stable references: same ProxyItem instances returned on
|
|
65
|
+
// every access, so $derived can track their expanded state.
|
|
66
|
+
#children = $derived(this.#buildChildren())
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {*} raw Raw item — object or primitive (string, number, …)
|
|
70
|
+
* @param {Partial<typeof BASE_FIELDS>} [fields]
|
|
71
|
+
* @param {string} [key] Path-based key assigned by ProxyTree
|
|
72
|
+
* @param {number} [level] Nesting depth (1 = root)
|
|
73
|
+
*/
|
|
74
|
+
constructor(raw, fields = {}, key = '', level = 0) {
|
|
75
|
+
this.#fields = { ...BASE_FIELDS, ...normalizeFields(fields) }
|
|
76
|
+
this.#raw = raw
|
|
77
|
+
this.#key = key
|
|
78
|
+
this.#level = level
|
|
79
|
+
|
|
80
|
+
// Normalise primitives: #item is always an object.
|
|
81
|
+
// Both text and value fields point to the primitive so all accessors work uniformly.
|
|
82
|
+
this.#item =
|
|
83
|
+
raw !== null && typeof raw === 'object'
|
|
84
|
+
? raw
|
|
85
|
+
: { [this.#fields.label]: raw, [this.#fields.value]: raw }
|
|
86
|
+
|
|
87
|
+
// Stable unique id: read from item field, or auto-generate
|
|
88
|
+
this.#id = this.#item[this.#fields.id] ?? `proxy-${_nextId++}`
|
|
89
|
+
|
|
90
|
+
// Sync initial control state from #item fields when present
|
|
91
|
+
const ef = this.#fields.expanded
|
|
92
|
+
const sf = this.#fields.selected
|
|
93
|
+
if (ef in this.#item) this.#expanded = Boolean(this.#item[ef])
|
|
94
|
+
if (sf in this.#item) this.#selected = Boolean(this.#item[sf])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Internal: build wrapped children ────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
#buildChildren() {
|
|
100
|
+
void this.#version // reactive dependency — triggers recompute after set()
|
|
101
|
+
const raw = this.#item[this.#fields.children]
|
|
102
|
+
if (!Array.isArray(raw) || raw.length === 0) return []
|
|
103
|
+
return raw.map(
|
|
104
|
+
(child, i) =>
|
|
105
|
+
this._createChild(
|
|
106
|
+
child,
|
|
107
|
+
this.#fields,
|
|
108
|
+
this.#key ? `${this.#key}-${i}` : String(i),
|
|
109
|
+
this.#level + 1
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Factory method for creating child proxies. Override in subclasses
|
|
116
|
+
* to produce specialised children (e.g. LazyProxyItem).
|
|
117
|
+
* @param {*} raw
|
|
118
|
+
* @param {Partial<typeof BASE_FIELDS>} fields
|
|
119
|
+
* @param {string} key
|
|
120
|
+
* @param {number} level
|
|
121
|
+
* @returns {ProxyItem}
|
|
122
|
+
*/
|
|
123
|
+
_createChild(raw, fields, key, level) {
|
|
124
|
+
return new ProxyItem(raw, fields, key, level)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Structural props ─────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
get key() {
|
|
130
|
+
return this.#key
|
|
131
|
+
}
|
|
132
|
+
get level() {
|
|
133
|
+
return this.#level
|
|
134
|
+
}
|
|
135
|
+
/** Stable unique identifier — from item's id field, or auto-generated. */
|
|
136
|
+
get id() {
|
|
137
|
+
return this.#id
|
|
138
|
+
}
|
|
139
|
+
/** The original input passed to the constructor — never mutated. */
|
|
140
|
+
get original() {
|
|
141
|
+
return this.#raw
|
|
142
|
+
}
|
|
143
|
+
/** The merged field-mapping configuration. */
|
|
144
|
+
get fields() {
|
|
145
|
+
return this.#fields
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Generic field accessor ───────────────────────────────────────────────
|
|
149
|
+
//
|
|
150
|
+
// Maps a semantic field name to the #item value via the fields config.
|
|
151
|
+
// For field-mapped attributes only: text, value, icon, href, description, …
|
|
152
|
+
// Falls back to using fieldName directly as a raw key when not in config.
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {string} fieldName Semantic name, e.g. 'icon', 'href', 'description'
|
|
156
|
+
* @returns {*}
|
|
157
|
+
*/
|
|
158
|
+
get(fieldName) {
|
|
159
|
+
const rawKey = this.#fields[fieldName] ?? fieldName
|
|
160
|
+
return this.#item[rawKey]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Write a value back to the underlying item through the field mapping.
|
|
165
|
+
* For object items, this modifies the original raw item (since #item === #raw).
|
|
166
|
+
* Increments the version counter so $derived(#buildChildren()) re-computes.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} fieldName Semantic name, e.g. 'children', 'text'
|
|
169
|
+
* @param {*} value
|
|
170
|
+
*/
|
|
171
|
+
set(fieldName, value) {
|
|
172
|
+
const rawKey = this.#fields[fieldName] ?? fieldName
|
|
173
|
+
this.#item[rawKey] = value
|
|
174
|
+
this.#version++
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Write directly to the original raw item, bypassing field mapping.
|
|
179
|
+
* Advanced operation for when the caller needs to update the source data.
|
|
180
|
+
* Accepts either (field, value) or an object for batch updates.
|
|
181
|
+
* Increments version so $derived(#buildChildren()) re-computes.
|
|
182
|
+
*
|
|
183
|
+
* @param {string|object} fieldOrBatch Raw key name, or { key: value, … }
|
|
184
|
+
* @param {*} [value]
|
|
185
|
+
*/
|
|
186
|
+
mutate(fieldOrBatch, value) {
|
|
187
|
+
if (typeof fieldOrBatch === 'object' && fieldOrBatch !== null) {
|
|
188
|
+
for (const [k, v] of Object.entries(fieldOrBatch)) {
|
|
189
|
+
this.#raw[k] = v
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
this.#raw[fieldOrBatch] = value
|
|
193
|
+
}
|
|
194
|
+
this.#version++
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Field-mapped accessors ───────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
get label() {
|
|
200
|
+
return this.#item[this.#fields.label] ?? ''
|
|
201
|
+
}
|
|
202
|
+
get value() {
|
|
203
|
+
return this.#item[this.#fields.value] ?? this.#raw
|
|
204
|
+
}
|
|
205
|
+
// All other fields via get('icon'), get('href'), get('snippet'), etc.
|
|
206
|
+
|
|
207
|
+
// ─── Computed props ───────────────────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
get disabled() {
|
|
210
|
+
return this.#item[this.#fields.disabled] === true
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** True only for object items with a non-empty children array. */
|
|
214
|
+
get hasChildren() {
|
|
215
|
+
return (
|
|
216
|
+
this.#raw !== null &&
|
|
217
|
+
typeof this.#raw === 'object' &&
|
|
218
|
+
Array.isArray(this.#item[this.#fields.children]) &&
|
|
219
|
+
this.#item[this.#fields.children].length > 0
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Returns wrapped ProxyItem children (empty array for primitives and leaf items). */
|
|
224
|
+
get children() {
|
|
225
|
+
return this.#children
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
get type() {
|
|
229
|
+
const t = this.#item[this.#fields.type]
|
|
230
|
+
if (t === 'separator' || t === 'spacer') return t
|
|
231
|
+
return this.hasChildren ? 'group' : 'item'
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Control state (expanded / selected) ─────────────────────────────────
|
|
235
|
+
|
|
236
|
+
get expanded() {
|
|
237
|
+
return this.#expanded
|
|
238
|
+
}
|
|
239
|
+
set expanded(v) {
|
|
240
|
+
this.#expanded = v
|
|
241
|
+
const ef = this.#fields.expanded
|
|
242
|
+
if (ef in this.#item) this.#item[ef] = v
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
get selected() {
|
|
246
|
+
return this.#selected
|
|
247
|
+
}
|
|
248
|
+
set selected(v) {
|
|
249
|
+
this.#selected = v
|
|
250
|
+
const sf = this.#fields.selected
|
|
251
|
+
if (sf in this.#item) this.#item[sf] = v
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── LazyProxyItem ───────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* LazyProxyItem
|
|
259
|
+
*
|
|
260
|
+
* Extends ProxyItem with lazy-loading support. When a lazyLoad function
|
|
261
|
+
* is provided, children are fetched on demand via fetch().
|
|
262
|
+
*
|
|
263
|
+
* #lazyLoad — async function (value, raw) => children[] — null when not lazy
|
|
264
|
+
* #loaded — true when: lazyLoad is null, or node already has children array,
|
|
265
|
+
* or after successful fetch(). false only for sentinel nodes
|
|
266
|
+
* (children === true) that need fetching.
|
|
267
|
+
* #loading — true during async fetch(), false otherwise. Used for spinner UI.
|
|
268
|
+
*
|
|
269
|
+
* After fetch(), uses set('children', result) to update the underlying item
|
|
270
|
+
* and trigger #children recomputation via the version counter.
|
|
271
|
+
*
|
|
272
|
+
* The lazyLoad function is propagated to all children automatically via
|
|
273
|
+
* _createChild override.
|
|
274
|
+
*/
|
|
275
|
+
export class LazyProxyItem extends ProxyItem {
|
|
276
|
+
#lazyLoad
|
|
277
|
+
#loaded = $state(true)
|
|
278
|
+
#loading = $state(false)
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* @param {*} raw
|
|
282
|
+
* @param {Partial<typeof BASE_FIELDS>} [fields]
|
|
283
|
+
* @param {string} [key]
|
|
284
|
+
* @param {number} [level]
|
|
285
|
+
* @param {((value: unknown, raw: unknown) => Promise<unknown[]>) | null} [lazyLoad]
|
|
286
|
+
*/
|
|
287
|
+
constructor(raw, fields = {}, key = '', level = 0, lazyLoad = null) {
|
|
288
|
+
super(raw, fields, key, level)
|
|
289
|
+
this.#lazyLoad = lazyLoad
|
|
290
|
+
// Loaded if: no lazyLoad function, children already exist as an array, or no children field (leaf)
|
|
291
|
+
// Only sentinel nodes (children: true) are considered unloaded
|
|
292
|
+
this.#loaded = lazyLoad === null || this.get('children') !== true
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
get loaded() { return this.#loaded }
|
|
296
|
+
get loading() { return this.#loading }
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Fetch children via the lazyLoad function.
|
|
300
|
+
* No-op if lazyLoad is null, already loaded, or currently loading.
|
|
301
|
+
* After fetching, writes children to the underlying item via set().
|
|
302
|
+
*/
|
|
303
|
+
async fetch() {
|
|
304
|
+
if (!this.#lazyLoad || this.#loaded || this.#loading) return
|
|
305
|
+
this.#loading = true
|
|
306
|
+
try {
|
|
307
|
+
const children = await this.#lazyLoad(this.value, this.original)
|
|
308
|
+
this.set('children', children)
|
|
309
|
+
this.#loaded = true
|
|
310
|
+
} finally {
|
|
311
|
+
this.#loading = false
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** @override — propagate lazyLoad to children */
|
|
316
|
+
_createChild(raw, fields, key, level) {
|
|
317
|
+
return new LazyProxyItem(raw, fields, key, level, this.#lazyLoad)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProxyTree
|
|
3
|
+
*
|
|
4
|
+
* Reactive data layer that manages a tree of ProxyItem instances.
|
|
5
|
+
* Derives flatView (for rendering) and lookup (for O(1) access) reactively
|
|
6
|
+
* from the root proxies and their children.
|
|
7
|
+
*
|
|
8
|
+
* Used by both Wrapper and LazyWrapper as the shared data model.
|
|
9
|
+
*
|
|
10
|
+
* Key design:
|
|
11
|
+
* #rootProxies is $state([]) — reassigned (not mutated) so $derived re-computes.
|
|
12
|
+
* flatView and lookup are $derived from #rootProxies, reading proxy.children
|
|
13
|
+
* and proxy.expanded recursively, so they automatically re-derive on any
|
|
14
|
+
* expansion or children change anywhere in the tree.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { BASE_FIELDS, normalizeFields } from '@rokkit/core'
|
|
18
|
+
import { ProxyItem } from './proxy-item.svelte.js'
|
|
19
|
+
|
|
20
|
+
// ─── Tree line type computation ────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
// Maps a parent's line type to the continuation type shown at the same column
|
|
23
|
+
// in child rows below it. 'child'→'sibling' (line continues), 'last'→'empty' (branch ended).
|
|
24
|
+
const NEXT_LINE = { child: 'sibling', last: 'empty', sibling: 'sibling', empty: 'empty', icon: 'empty' }
|
|
25
|
+
|
|
26
|
+
// ─── Reactive tree traversal utilities ─────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build flat view by walking proxy.children ($derived) recursively.
|
|
30
|
+
* Reads proxy.expanded ($state) and proxy.children ($derived), so any
|
|
31
|
+
* $derived wrapping this function re-computes on expansion or children changes.
|
|
32
|
+
*
|
|
33
|
+
* Computes lineTypes per node during the walk — no second pass needed.
|
|
34
|
+
* lineTypes is an array of connector types for rendering tree lines:
|
|
35
|
+
* 'child' — branch connector
|
|
36
|
+
* 'last' — last branch connector
|
|
37
|
+
* 'sibling' — vertical continuation line
|
|
38
|
+
* 'empty' — (blank space)
|
|
39
|
+
* 'icon' — expand/collapse toggle slot
|
|
40
|
+
*
|
|
41
|
+
* @param {ProxyItem[]} proxies
|
|
42
|
+
* @param {string[]} [parentLineTypes] Line types of the parent node (for computing inherited connectors)
|
|
43
|
+
* @returns {{ key: string, proxy: ProxyItem, level: number, hasChildren: boolean, isExpandable: boolean, type: string, lineTypes: string[] }[]}
|
|
44
|
+
*/
|
|
45
|
+
function buildReactiveFlatView(proxies, parentLineTypes = []) {
|
|
46
|
+
const result = []
|
|
47
|
+
for (let i = 0; i < proxies.length; i++) {
|
|
48
|
+
const proxy = proxies[i]
|
|
49
|
+
const children = proxy.children // reads $derived — registers dependency
|
|
50
|
+
const hasChildren = children.length > 0
|
|
51
|
+
const isExpandable = hasChildren || proxy.get('children') === true // sentinel: lazy-loadable
|
|
52
|
+
const isLast = i === proxies.length - 1
|
|
53
|
+
const position = isLast ? 'last' : 'child'
|
|
54
|
+
|
|
55
|
+
// Compute line types: inherit parent's continuations + current position + icon/empty
|
|
56
|
+
const inherited = parentLineTypes.slice(0, -1).map((t) => NEXT_LINE[t] ?? 'empty')
|
|
57
|
+
if (parentLineTypes.length > 0) inherited.push(position)
|
|
58
|
+
const lineTypes = isExpandable ? [...inherited, 'icon'] : inherited
|
|
59
|
+
|
|
60
|
+
result.push({
|
|
61
|
+
key: proxy.key,
|
|
62
|
+
proxy,
|
|
63
|
+
level: proxy.level,
|
|
64
|
+
hasChildren,
|
|
65
|
+
isExpandable,
|
|
66
|
+
type: proxy.type,
|
|
67
|
+
lineTypes
|
|
68
|
+
})
|
|
69
|
+
if (hasChildren && proxy.expanded) {
|
|
70
|
+
result.push(...buildReactiveFlatView(children, lineTypes))
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return result
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build lookup Map by walking proxy.children ($derived) recursively.
|
|
78
|
+
* Traverses ALL children (not just expanded) so keys are available
|
|
79
|
+
* for selection and navigation even before a group is opened.
|
|
80
|
+
*
|
|
81
|
+
* @param {ProxyItem[]} proxies
|
|
82
|
+
* @param {Map<string, ProxyItem>} [map]
|
|
83
|
+
* @returns {Map<string, ProxyItem>}
|
|
84
|
+
*/
|
|
85
|
+
function buildReactiveLookup(proxies, map = new Map()) {
|
|
86
|
+
for (const proxy of proxies) {
|
|
87
|
+
map.set(proxy.key, proxy)
|
|
88
|
+
const children = proxy.children
|
|
89
|
+
if (children.length > 0) {
|
|
90
|
+
buildReactiveLookup(children, map)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return map
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── ProxyTree ─────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
export class ProxyTree {
|
|
99
|
+
#fields
|
|
100
|
+
#factory
|
|
101
|
+
|
|
102
|
+
// Root proxies — $state so reassignment triggers $derived recomputation.
|
|
103
|
+
#rootProxies = $state([])
|
|
104
|
+
|
|
105
|
+
// Reactive flatView: re-derives when proxy.expanded OR proxy.children changes.
|
|
106
|
+
flatView = $derived(buildReactiveFlatView(this.#rootProxies))
|
|
107
|
+
|
|
108
|
+
// Reactive lookup: re-derives when proxy.children changes anywhere in the tree.
|
|
109
|
+
#lookup = $derived(buildReactiveLookup(this.#rootProxies))
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {unknown[]} [items]
|
|
113
|
+
* @param {Partial<typeof BASE_FIELDS>} [fields]
|
|
114
|
+
* @param {{ createProxy?: (raw: *, fields: object, key: string, level: number) => ProxyItem }} [options]
|
|
115
|
+
*/
|
|
116
|
+
constructor(items = [], fields = {}, options = {}) {
|
|
117
|
+
this.#fields = { ...BASE_FIELDS, ...normalizeFields(fields) }
|
|
118
|
+
this.#factory = options.createProxy ?? ((raw, f, key, level) => new ProxyItem(raw, f, key, level))
|
|
119
|
+
this.#rootProxies = (items ?? []).map((raw, i) => this.#factory(raw, this.#fields, String(i), 1))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Read accessors ──────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/** @returns {ProxyItem[]} Root proxy array */
|
|
125
|
+
get roots() { return this.#rootProxies }
|
|
126
|
+
|
|
127
|
+
/** @returns {Map<string, ProxyItem>} Lookup map of all proxies by key */
|
|
128
|
+
get lookup() { return this.#lookup }
|
|
129
|
+
|
|
130
|
+
// ─── Mutation methods ────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Append new root items. Creates proxies with keys continuing from
|
|
134
|
+
* the current length. Reassigns #rootProxies to trigger $derived.
|
|
135
|
+
*
|
|
136
|
+
* @param {unknown[]} items Raw items to append as roots
|
|
137
|
+
*/
|
|
138
|
+
append(items) {
|
|
139
|
+
const start = this.#rootProxies.length
|
|
140
|
+
const newProxies = items.map((raw, i) =>
|
|
141
|
+
this.#factory(raw, this.#fields, String(start + i), 1)
|
|
142
|
+
)
|
|
143
|
+
this.#rootProxies = [...this.#rootProxies, ...newProxies]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Add children to an existing proxy node.
|
|
148
|
+
* Uses proxy.set('children', rawItems) so ProxyItem's version counter
|
|
149
|
+
* triggers #buildChildren() recomputation. The flatView and lookup
|
|
150
|
+
* derive from proxy.children reactively.
|
|
151
|
+
*
|
|
152
|
+
* @param {ProxyItem} proxy The proxy to add children to
|
|
153
|
+
* @param {unknown[]} items Raw child items
|
|
154
|
+
*/
|
|
155
|
+
addChildren(proxy, items) {
|
|
156
|
+
proxy.set('children', items)
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/vibe.svelte.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/** @typedef {'cozy' | 'compact' | 'comfortable'} Density */
|
|
3
3
|
/** @typedef {'ltr' | 'rtl'} Direction */
|
|
4
4
|
|
|
5
|
-
import { defaultColors,
|
|
5
|
+
import { defaultColors, DEFAULT_THEME_MAPPING, themeRules, detectDirection } from '@rokkit/core'
|
|
6
6
|
import { DEFAULT_STYLES, VALID_DENSITIES, VALID_MODES, VALID_DIRECTIONS } from './constants'
|
|
7
7
|
import { has } from 'ramda'
|
|
8
8
|
|
|
@@ -27,7 +27,7 @@ class Vibe {
|
|
|
27
27
|
#colors = $state(defaultColors)
|
|
28
28
|
#density = $state('comfortable')
|
|
29
29
|
#direction = $state(detectDirection())
|
|
30
|
-
#colorMap = $state(
|
|
30
|
+
#colorMap = $state(DEFAULT_THEME_MAPPING)
|
|
31
31
|
#palette = $derived.by(() => themeRules(this.#colorMap, this.#colors))
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -63,7 +63,7 @@ class Vibe {
|
|
|
63
63
|
if (missing.length > 0) {
|
|
64
64
|
throw new Error(`Did you forget to define "${missing.join(', ')}"?`)
|
|
65
65
|
}
|
|
66
|
-
this.#colorMap = { ...
|
|
66
|
+
this.#colorMap = { ...DEFAULT_THEME_MAPPING, ...value }
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrapper
|
|
3
|
+
*
|
|
4
|
+
* Navigation controller for persistent list/tree/sidebar components.
|
|
5
|
+
* Accepts a ProxyTree instance for reactive data (flatView, lookup),
|
|
6
|
+
* and provides full navigation, expansion, selection, and typeahead logic.
|
|
7
|
+
*
|
|
8
|
+
* ProxyTree owns the data layer: items -> proxies -> flatView + lookup.
|
|
9
|
+
* Wrapper owns the navigation layer: focusedKey, movement, selection callbacks.
|
|
10
|
+
*
|
|
11
|
+
* Designed for any persistent (always-visible) component:
|
|
12
|
+
* - Sidebar navigation (links, collapsible groups)
|
|
13
|
+
* - List / Tree components
|
|
14
|
+
* - Any option list rendered inline
|
|
15
|
+
*
|
|
16
|
+
* Dropdown variants (Select, Menu) extend this class and override cancel() / blur()
|
|
17
|
+
* to close the dropdown and return focus to the trigger.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export class Wrapper {
|
|
21
|
+
// ─── Data ──────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
#proxyTree
|
|
24
|
+
|
|
25
|
+
// flatView: re-derives from proxyTree's flatView, which itself re-derives
|
|
26
|
+
// when any proxy.expanded or proxy.children changes.
|
|
27
|
+
flatView = $derived(this.#proxyTree.flatView)
|
|
28
|
+
|
|
29
|
+
// Navigable items: exclude separators, spacers, and disabled items.
|
|
30
|
+
// This is the subset that keyboard navigation moves through.
|
|
31
|
+
#navigable = $derived(
|
|
32
|
+
this.flatView.filter(
|
|
33
|
+
(n) => n.type !== 'separator' && n.type !== 'spacer' && !n.proxy.disabled
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
// ─── State ──────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
#focusedKey = $state(null)
|
|
40
|
+
|
|
41
|
+
// ─── Callbacks ──────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
#onselect
|
|
44
|
+
#onchange
|
|
45
|
+
#selectedValue = $state(undefined)
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {import('./proxy-tree.svelte.js').ProxyTree} proxyTree
|
|
49
|
+
* @param {{ onselect?: Function, onchange?: Function }} [options]
|
|
50
|
+
*/
|
|
51
|
+
constructor(proxyTree, options = {}) {
|
|
52
|
+
this.#proxyTree = proxyTree
|
|
53
|
+
this.#onselect = options.onselect
|
|
54
|
+
this.#onchange = options.onchange
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── IWrapper: state read by Navigator ─────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
get focusedKey() { return this.#focusedKey }
|
|
60
|
+
|
|
61
|
+
// ─── IWrapper: movement (path passed through but ignored) ──────────────────
|
|
62
|
+
|
|
63
|
+
/** Move focus to the next navigable item; clamp at end. */
|
|
64
|
+
next(_path) {
|
|
65
|
+
const nav = this.#navigable
|
|
66
|
+
if (!nav.length) return
|
|
67
|
+
const idx = nav.findIndex((n) => n.key === this.#focusedKey)
|
|
68
|
+
if (idx < nav.length - 1) this.#focusedKey = nav[idx + 1].key
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Move focus to the previous navigable item; clamp at start. */
|
|
72
|
+
prev(_path) {
|
|
73
|
+
const nav = this.#navigable
|
|
74
|
+
if (!nav.length) return
|
|
75
|
+
const idx = nav.findIndex((n) => n.key === this.#focusedKey)
|
|
76
|
+
if (idx > 0) this.#focusedKey = nav[idx - 1].key
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Move focus to the first navigable item. */
|
|
80
|
+
first(_path) {
|
|
81
|
+
const nav = this.#navigable
|
|
82
|
+
if (nav.length) this.#focusedKey = nav[0].key
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Move focus to the last navigable item. */
|
|
86
|
+
last(_path) {
|
|
87
|
+
const nav = this.#navigable
|
|
88
|
+
if (nav.length) this.#focusedKey = nav[nav.length - 1].key
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Expand focused group, or move focus into it if already open.
|
|
93
|
+
* No-op on leaf items.
|
|
94
|
+
*/
|
|
95
|
+
expand(_path) {
|
|
96
|
+
if (!this.#focusedKey) return
|
|
97
|
+
const node = this.flatView.find((n) => n.key === this.#focusedKey)
|
|
98
|
+
if (!node || !node.hasChildren) return
|
|
99
|
+
if (!node.proxy.expanded) {
|
|
100
|
+
node.proxy.expanded = true
|
|
101
|
+
} else {
|
|
102
|
+
// Already open — advance focus to first visible child
|
|
103
|
+
this.next(null)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Collapse focused group, or move focus to parent if already collapsed / leaf.
|
|
109
|
+
* At root level with no parent: no-op.
|
|
110
|
+
*/
|
|
111
|
+
collapse(_path) {
|
|
112
|
+
if (!this.#focusedKey) return
|
|
113
|
+
const node = this.flatView.find((n) => n.key === this.#focusedKey)
|
|
114
|
+
if (!node) return
|
|
115
|
+
if (node.hasChildren && node.proxy.expanded) {
|
|
116
|
+
node.proxy.expanded = false
|
|
117
|
+
} else {
|
|
118
|
+
// Move to parent: strip the last segment from the key
|
|
119
|
+
const parts = this.#focusedKey.split('-')
|
|
120
|
+
if (parts.length > 1) {
|
|
121
|
+
parts.pop()
|
|
122
|
+
this.#focusedKey = parts.join('-')
|
|
123
|
+
}
|
|
124
|
+
// At root level (no '-'): no-op — already at root
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── IWrapper: selection actions ───────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Select item at path (or focusedKey when path is null).
|
|
132
|
+
* Groups toggle expanded. Leaves fire onchange (value differs) and onselect callbacks.
|
|
133
|
+
*/
|
|
134
|
+
select(path) {
|
|
135
|
+
const key = path ?? this.#focusedKey
|
|
136
|
+
if (!key) return
|
|
137
|
+
this.#focusedKey = key
|
|
138
|
+
const proxy = this.#proxyTree.lookup.get(key)
|
|
139
|
+
if (!proxy) return
|
|
140
|
+
if (proxy.hasChildren) {
|
|
141
|
+
proxy.expanded = !proxy.expanded
|
|
142
|
+
} else {
|
|
143
|
+
if (proxy.value !== this.#selectedValue) {
|
|
144
|
+
this.#selectedValue = proxy.value
|
|
145
|
+
this.#onchange?.(proxy.value, proxy)
|
|
146
|
+
}
|
|
147
|
+
this.#onselect?.(proxy.value, proxy)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Toggle expansion of group at path — called by Navigator for accordion-trigger clicks.
|
|
153
|
+
* Unlike select(), this only applies to groups and never fires onselect.
|
|
154
|
+
*/
|
|
155
|
+
toggle(path) {
|
|
156
|
+
const key = path ?? this.#focusedKey
|
|
157
|
+
if (!key) return
|
|
158
|
+
const proxy = this.#proxyTree.lookup.get(key)
|
|
159
|
+
if (proxy?.hasChildren) proxy.expanded = !proxy.expanded
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Sync focused state to path — called by Navigator on focusin and typeahead match.
|
|
164
|
+
*/
|
|
165
|
+
moveTo(path) {
|
|
166
|
+
if (path !== null) this.#focusedKey = path
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Sync focused key to the item matching this semantic value.
|
|
171
|
+
* Used by controlled components (Toggle, Select) to keep navigation
|
|
172
|
+
* in sync when the bound value changes externally.
|
|
173
|
+
*
|
|
174
|
+
* @param {unknown} v
|
|
175
|
+
*/
|
|
176
|
+
moveToValue(v) {
|
|
177
|
+
if (v === undefined || v === null) return
|
|
178
|
+
for (const [key, proxy] of this.#proxyTree.lookup) {
|
|
179
|
+
if (proxy.value === v) {
|
|
180
|
+
this.#focusedKey = key
|
|
181
|
+
this.#selectedValue = v
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Persistent list: no dropdown to close. Override in dropdown wrappers. */
|
|
188
|
+
cancel(_path) {}
|
|
189
|
+
|
|
190
|
+
/** Persistent list: no-op. Override in dropdown wrappers to close + restore trigger focus. */
|
|
191
|
+
blur() {}
|
|
192
|
+
|
|
193
|
+
/** Multiselect toggle — not yet implemented. */
|
|
194
|
+
extend(_path) {}
|
|
195
|
+
|
|
196
|
+
/** Multiselect range — not yet implemented. */
|
|
197
|
+
range(_path) {}
|
|
198
|
+
|
|
199
|
+
// ─── IWrapper: typeahead ───────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Return the key of the first navigable item whose text starts with query
|
|
203
|
+
* (case-insensitive). Wraps around. startAfterKey enables cycling.
|
|
204
|
+
* Returns null if no match.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} query
|
|
207
|
+
* @param {string|null} [startAfterKey]
|
|
208
|
+
* @returns {string|null}
|
|
209
|
+
*/
|
|
210
|
+
findByText(query, startAfterKey = null) {
|
|
211
|
+
const nav = this.#navigable
|
|
212
|
+
if (!nav.length) return null
|
|
213
|
+
const q = query.toLowerCase()
|
|
214
|
+
const startIdx = startAfterKey
|
|
215
|
+
? nav.findIndex((n) => n.key === startAfterKey) + 1
|
|
216
|
+
: 0
|
|
217
|
+
for (let i = 0; i < nav.length; i++) {
|
|
218
|
+
const node = nav[(startIdx + i) % nav.length]
|
|
219
|
+
if (node.proxy.label.toLowerCase().startsWith(q)) return node.key
|
|
220
|
+
}
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── Helpers for the component ─────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
/** @returns {Map<string, import('./proxy-item.svelte.js').ProxyItem>} */
|
|
227
|
+
get lookup() { return this.#proxyTree.lookup }
|
|
228
|
+
|
|
229
|
+
/** @returns {import('./proxy-tree.svelte.js').ProxyTree} */
|
|
230
|
+
get proxyTree() { return this.#proxyTree }
|
|
231
|
+
}
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { getKeyFromPath, getPathFromKey } 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
|
-
if (value) {
|
|
12
|
-
this.ensureVisible(value)
|
|
13
|
-
this.moveToValue(value)
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Mark parents as expanded so that item is visible
|
|
19
|
-
* @param {*} value
|
|
20
|
-
* @returns
|
|
21
|
-
*/
|
|
22
|
-
ensureVisible(value) {
|
|
23
|
-
const result = this.lookup.entries().find((entry) => equals(entry[1].value, value))
|
|
24
|
-
if (!result) return false
|
|
25
|
-
const path = getPathFromKey(result[0])
|
|
26
|
-
|
|
27
|
-
for (let i = 1; i < path.length; i++) {
|
|
28
|
-
const nodeKey = getKeyFromPath(path.slice(0, i))
|
|
29
|
-
this.expandedKeys.add(nodeKey)
|
|
30
|
-
}
|
|
31
|
-
return true
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Toggle expansion of item
|
|
36
|
-
* @param {string} key
|
|
37
|
-
* @returns {boolean}
|
|
38
|
-
*/
|
|
39
|
-
toggleExpansion(key) {
|
|
40
|
-
if (!this.lookup.has(key)) return false
|
|
41
|
-
if (this.expandedKeys.has(key)) {
|
|
42
|
-
this.expandedKeys.delete(key)
|
|
43
|
-
} else {
|
|
44
|
-
this.expandedKeys.add(key)
|
|
45
|
-
}
|
|
46
|
-
return true
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Expand item. If already expanded, move focus to first child.
|
|
51
|
-
* @param {string} [key]
|
|
52
|
-
* @returns {boolean}
|
|
53
|
-
*/
|
|
54
|
-
expand(key) {
|
|
55
|
-
const actualKey = key ?? this.focusedKey
|
|
56
|
-
if (!this.lookup.has(actualKey)) return false
|
|
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)
|
|
69
|
-
return true
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Collapse item. If not expandable (leaf or already collapsed), move focus to parent.
|
|
74
|
-
* @param {string} [key]
|
|
75
|
-
* @returns {boolean}
|
|
76
|
-
*/
|
|
77
|
-
collapse(key) {
|
|
78
|
-
const actualKey = key ?? this.focusedKey
|
|
79
|
-
if (!this.lookup.has(actualKey)) return false
|
|
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
|
|
93
|
-
}
|
|
94
|
-
}
|
package/src/proxy.svelte.js
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { defaultFields, id, toString, getNestedFields } from '@rokkit/core'
|
|
2
|
-
import { isNil, has } from 'ramda'
|
|
3
|
-
|
|
4
|
-
export class Proxy {
|
|
5
|
-
#original = null
|
|
6
|
-
#value = $state(null)
|
|
7
|
-
#fields = defaultFields
|
|
8
|
-
#id = null
|
|
9
|
-
|
|
10
|
-
#children = $derived(this.#processChildren())
|
|
11
|
-
|
|
12
|
-
constructor(value, fields) {
|
|
13
|
-
this.fields = fields
|
|
14
|
-
this.#original = value
|
|
15
|
-
this.#value = typeof value === 'object' ? value : { [this.fields.text]: value }
|
|
16
|
-
this.id = typeof value === 'object' ? (this.get('id') ?? id()) : value
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
#processChildren() {
|
|
20
|
-
if (isNil(this.#value)) return []
|
|
21
|
-
|
|
22
|
-
const children = this.#value[this.fields.children] ?? []
|
|
23
|
-
if (Array.isArray(children)) {
|
|
24
|
-
const fields = getNestedFields(this.fields)
|
|
25
|
-
return children.map((child) => new Proxy(child, fields))
|
|
26
|
-
}
|
|
27
|
-
return []
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
get id() {
|
|
31
|
-
return this.#id
|
|
32
|
-
}
|
|
33
|
-
set id(new_id) {
|
|
34
|
-
this.#id = typeof new_id === 'string' ? new_id : toString(new_id)
|
|
35
|
-
}
|
|
36
|
-
get children() {
|
|
37
|
-
return this.#children
|
|
38
|
-
}
|
|
39
|
-
get fields() {
|
|
40
|
-
return this.#fields
|
|
41
|
-
}
|
|
42
|
-
set fields(value) {
|
|
43
|
-
this.#fields = { ...defaultFields, ...value }
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
get value() {
|
|
47
|
-
return typeof this.#original === 'object' ? this.#value : this.#original
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
set value(value) {
|
|
51
|
-
if (typeof value === 'object') {
|
|
52
|
-
const removedKeys = Object.keys(this.#value).filter(
|
|
53
|
-
(key) => !Object.keys(value).includes(key)
|
|
54
|
-
)
|
|
55
|
-
Object.entries(value).forEach(([k, v]) => {
|
|
56
|
-
this.#value[k] = v
|
|
57
|
-
})
|
|
58
|
-
removedKeys.forEach((key) => {
|
|
59
|
-
delete this.#value[key]
|
|
60
|
-
})
|
|
61
|
-
} else {
|
|
62
|
-
this.#value = { [this.fields.text]: value }
|
|
63
|
-
this.#original = value
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Gets a mapped attribute from the original item
|
|
69
|
-
*
|
|
70
|
-
* @param {string} fieldName - Name of the field to get
|
|
71
|
-
* @param {any} [defaultValue] - Default value to return if not found
|
|
72
|
-
* @returns {any|undefined} - The attribute value or null if not found
|
|
73
|
-
*/
|
|
74
|
-
get(fieldName, defaultValue) {
|
|
75
|
-
return this.has(fieldName) ? this.#value[this.fields[fieldName]] : defaultValue
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Checks if a mapped attribute exists in the original item
|
|
80
|
-
* @param {string} fieldName - Name of the field to check
|
|
81
|
-
* @returns boolean
|
|
82
|
-
*/
|
|
83
|
-
has(fieldName) {
|
|
84
|
-
const mappedField = this.fields[fieldName]
|
|
85
|
-
return !isNil(mappedField) && has(mappedField, this.#value)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Gets the appropriate snippet for rendering this item:
|
|
90
|
-
* - Uses the 'snippet' field from the current item to find the snippet key
|
|
91
|
-
* - Finds a matching snippet in the provided collection using this key
|
|
92
|
-
* - Falls back to the defaultSnippet if:
|
|
93
|
-
* - No snippet key is configured for this item
|
|
94
|
-
* - The configured snippet key doesn't exist in the snippets collection
|
|
95
|
-
* @param {Object} snippets
|
|
96
|
-
* @param {import('svelte').Snippet|undefined} [defaultSnippet]
|
|
97
|
-
* @returns {import('svelte').Snippet|undefined}
|
|
98
|
-
*/
|
|
99
|
-
getSnippet(snippets, defaultSnippet) {
|
|
100
|
-
const snippetKey = this.get('snippet')
|
|
101
|
-
const snippet = has(snippetKey, snippets) ? snippets[snippetKey] : undefined
|
|
102
|
-
return snippet ?? defaultSnippet
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Identifies if the item has children
|
|
107
|
-
*/
|
|
108
|
-
get hasChildren() {
|
|
109
|
-
return (
|
|
110
|
-
typeof this.#original === 'object' &&
|
|
111
|
-
has(this.fields.children, this.#value) &&
|
|
112
|
-
Array.isArray(this.#value[this.fields.children]) &&
|
|
113
|
-
this.#value[this.fields.children].length > 0
|
|
114
|
-
)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
get expanded() {
|
|
118
|
-
return this.has('expanded') ? this.#value[this.fields.expanded] : false
|
|
119
|
-
}
|
|
120
|
-
}
|