@rokkit/states 1.0.0-next.127 → 1.0.0-next.129

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.127",
3
+ "version": "1.0.0-next.129",
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.127",
34
- "@rokkit/data": "1.0.0-next.127",
33
+ "@rokkit/core": "1.0.0-next.129",
34
+ "@rokkit/data": "1.0.0-next.129",
35
35
  "d3-array": "^3.2.4",
36
36
  "ramda": "^0.32.0",
37
37
  "svelte": "^5.53.5"
@@ -1,17 +1,18 @@
1
- import { getKeyFromPath, defaultFields, getNestedFields } from '@rokkit/core'
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 = defaultFields, path = [], expandedKeys = null) {
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 Proxy instance for convenient manipulation
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, Proxy>} - Map of path keys to Proxy instances
42
+ * @returns {Map<string, { value: *, original: *, label: string, get: (f: string) => * }>}
40
43
  */
41
- export function deriveLookupWithProxy(items, fields = defaultFields, path = []) {
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 proxy = new Proxy(item, fields)
51
+ const norm = typeof item === 'object' && item !== null ? item : { [fields.label]: item }
52
+ const entry = {
53
+ value: item,
54
+ original: item,
55
+ label: String(norm[fields.label] ?? ''),
56
+ get: (fieldName) => norm[fields[fieldName] ?? fieldName]
57
+ }
49
58
 
50
- lookup.set(key, proxy)
51
- const children = proxy.value[proxy.fields.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, defaultFields, getKeyFromPath, getNestedFields } from '@rokkit/core'
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 = defaultFields
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 = { ...defaultFields, ...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 proxy = this.lookup.get(this.data[idx].key)
286
- const text = proxy?.get('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
+ }
@@ -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
- this.#messages = { ...defaultMessages, ...custom }
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.label]: 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 (label, 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
+ }
@@ -2,7 +2,7 @@
2
2
  /** @typedef {'cozy' | 'compact' | 'comfortable'} Density */
3
3
  /** @typedef {'ltr' | 'rtl'} Direction */
4
4
 
5
- import { defaultColors, defaultThemeMapping, themeRules, detectDirection } from '@rokkit/core'
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(defaultThemeMapping)
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 = { ...defaultThemeMapping, ...value }
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
- }
@@ -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
- }