@rokkit/states 1.0.0-next.125 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/states",
3
- "version": "1.0.0-next.125",
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,11 +30,10 @@
30
30
  }
31
31
  },
32
32
  "dependencies": {
33
- "@lukeed/uuid": "^2.0.1",
34
- "@rokkit/core": "1.0.0-next.125",
33
+ "@rokkit/core": "1.0.0-next.128",
34
+ "@rokkit/data": "1.0.0-next.128",
35
35
  "d3-array": "^3.2.4",
36
- "d3-collection": "^1.0.7",
37
- "ramda": "^0.31.3",
38
- "svelte": "^5.39.2"
36
+ "ramda": "^0.32.0",
37
+ "svelte": "^5.53.5"
39
38
  }
40
39
  }
package/src/constants.js CHANGED
@@ -9,6 +9,7 @@ export const DEFAULT_EVENTS = {
9
9
 
10
10
  export const VALID_DENSITIES = ['compact', 'comfortable', 'cozy']
11
11
  export const VALID_MODES = ['light', 'dark']
12
+ export const VALID_DIRECTIONS = ['ltr', 'rtl']
12
13
  /** @type {string[]} */
13
14
  export const DEFAULT_STYLES = ['rokkit', 'minimal', 'material']
14
15
 
@@ -1,52 +1,64 @@
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
- * @returns {Array<{ key: string, value: any }>}
7
+ * @param {Set<string>|null} expandedKeys - When provided, expansion is determined by key membership; falls back to item field
8
+ * @returns {Array<{ key: string, value: any, level: number, hasChildren: boolean }>}
9
9
  */
10
- export function flatVisibleNodes(items, fields = defaultFields, path = []) {
10
+ export function flatVisibleNodes(items, fields = DEFAULT_FIELDS, path = [], expandedKeys = null) {
11
11
  const data = []
12
+ if (!items || !Array.isArray(items)) return data
13
+
14
+ const level = path.length
15
+
12
16
  items.forEach((item, index) => {
13
17
  const itemPath = [...path, index]
14
18
  const key = getKeyFromPath(itemPath)
15
- const expanded =
16
- Array.isArray(item[fields.children]) &&
17
- item[fields.children].length > 0 &&
18
- item[fields.expanded]
19
+ const hasChildren =
20
+ Array.isArray(item[fields.children]) && item[fields.children].length > 0
21
+ const expanded = hasChildren && (expandedKeys ? expandedKeys.has(key) : item[fields.expanded])
19
22
 
20
- data.push({ key, value: item })
23
+ data.push({ key, value: item, level, hasChildren })
21
24
 
22
25
  if (expanded) {
23
26
  const childFields = getNestedFields(fields)
24
- data.push(...flatVisibleNodes(item[fields.children], childFields, itemPath))
27
+ data.push(...flatVisibleNodes(item[fields.children], childFields, itemPath, expandedKeys))
25
28
  }
26
29
  })
27
30
  return data
28
31
  }
29
32
 
30
33
  /**
31
- * Derives a flat lookup table for the given items, using index paths as keys
32
- * Each value is a Proxy instance for convenient manipulation
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.
33
38
  *
34
39
  * @param {Array<*>} items - Source items array
35
40
  * @param {import('@rokkit/core').FieldMapping} fields - Field mappings configuration
36
41
  * @param {Array<number>} path - Current path in the tree
37
- * @returns {Map<string, Proxy>} - Map of path keys to Proxy instances
42
+ * @returns {Map<string, { value: *, original: *, label: string, get: (f: string) => * }>}
38
43
  */
39
- export function deriveLookupWithProxy(items, fields = defaultFields, path = []) {
44
+ export function deriveLookupWithProxy(items, fields = DEFAULT_FIELDS, path = []) {
40
45
  const lookup = new Map()
46
+ if (!items || !Array.isArray(items)) return lookup
41
47
 
42
48
  items.forEach((item, index) => {
43
49
  const itemPath = [...path, index]
44
50
  const key = getKeyFromPath(itemPath)
45
- const proxy = new Proxy(item, fields)
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
+ }
46
58
 
47
- lookup.set(key, proxy)
48
- const children = proxy.value[proxy.fields.children] ?? []
49
- if (children.length > 0) {
59
+ lookup.set(key, entry)
60
+ const children = norm[fields.children] ?? []
61
+ if (Array.isArray(children) && children.length > 0) {
50
62
  const childFields = getNestedFields(fields)
51
63
  const childLookup = deriveLookupWithProxy(children, childFields, itemPath)
52
64
  for (const [childKey, childValue] of childLookup.entries()) {
package/src/index.js CHANGED
@@ -1,5 +1,9 @@
1
- export { TableWrapper } from './tabular.svelte.js'
2
- export { Proxy } from './proxy.svelte.js'
1
+ export { TableController } from './table-controller.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'
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,31 +1,53 @@
1
- import { FieldMapper, defaultFields } 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()
12
12
  selectedKeys = new SvelteSet()
13
+ expandedKeys = new SvelteSet()
13
14
  focusedKey = $state(null)
14
15
  #currentIndex = -1
16
+ #anchorKey = null
15
17
 
16
18
  selected = $derived(Array.from(this.selectedKeys).map((key) => this.lookup.get(key).value))
17
19
  focused = $derived(this.lookup.get(this.focusedKey)?.value)
18
- data = $derived(flatVisibleNodes(this.items, this.fields))
20
+ data = $derived(flatVisibleNodes(this.items, this.fields, [], this.expandedKeys))
19
21
  lookup = $derived(deriveLookupWithProxy(this.items, this.fields))
20
22
 
21
23
  constructor(items, value, fields, options) {
22
24
  this.items = items
23
- this.fields = { ...defaultFields, ...fields }
25
+ this.fields = { ...DEFAULT_FIELDS, ...fields }
24
26
  this.mappers.push(new FieldMapper(fields))
25
27
  this.#options = { multiselect: false, ...options }
28
+ this.#initExpandedKeys(items, this.fields)
26
29
  this.init(value)
27
30
  }
28
31
 
32
+ /**
33
+ * Scan items for pre-existing expanded flags and populate expandedKeys
34
+ * @private
35
+ */
36
+ #initExpandedKeys(items, fields, path = []) {
37
+ if (!items || !Array.isArray(items)) return
38
+ items.forEach((item, index) => {
39
+ if (item === null || item === undefined || typeof item !== 'object') return
40
+ const itemPath = [...path, index]
41
+ const children = item[fields.children]
42
+ if (Array.isArray(children) && children.length > 0) {
43
+ if (item[fields.expanded]) {
44
+ this.expandedKeys.add(getKeyFromPath(itemPath))
45
+ }
46
+ this.#initExpandedKeys(children, getNestedFields(fields), itemPath)
47
+ }
48
+ })
49
+ }
50
+
29
51
  /**
30
52
  * @private
31
53
  * @param {Array<*>} items
@@ -54,7 +76,17 @@ export class ListController {
54
76
  * @returns
55
77
  */
56
78
  findByValue(value) {
57
- const index = this.data.findIndex((row) => equals(row.value, value))
79
+ // Try exact match first (full object comparison)
80
+ let index = this.data.findIndex((row) => equals(row.value, value))
81
+
82
+ // Fallback: match by extracted value field (e.g. primitive 'a' against { text: 'A', value: 'a' })
83
+ if (index < 0) {
84
+ const valueField = this.fields.value
85
+ index = this.data.findIndex(
86
+ (row) => typeof row.value === 'object' && row.value !== null && equals(row.value[valueField], value)
87
+ )
88
+ }
89
+
58
90
  return index < 0 ? { index } : { index, ...this.data[index] }
59
91
  }
60
92
 
@@ -79,12 +111,13 @@ export class ListController {
79
111
 
80
112
  /**
81
113
  *
82
- * @param {string} path
114
+ * @param {string|number} path - path key string (e.g. "0", "1-0", "2-1-3")
83
115
  * @returns
84
116
  */
85
117
  moveTo(path) {
86
- const index = Number(path)
87
- return this.moveToIndex(index)
118
+ const key = String(path)
119
+ const index = this.data.findIndex((row) => row.key === key)
120
+ return index >= 0 ? this.moveToIndex(index) : false
88
121
  }
89
122
 
90
123
  /**
@@ -100,28 +133,44 @@ export class ListController {
100
133
  return false
101
134
  }
102
135
 
136
+ /**
137
+ * @private
138
+ * @param {number} index
139
+ * @returns {boolean}
140
+ */
141
+ #isDisabled(index) {
142
+ const item = this.data[index]?.value
143
+ if (item === null || item === undefined || typeof item !== 'object') return false
144
+ return item[this.fields.disabled] === true
145
+ }
146
+
103
147
  movePrev() {
104
- if (this.#currentIndex > 0) {
105
- return this.moveToIndex(this.#currentIndex - 1)
106
- } else if (this.#currentIndex < 0) {
107
- return this.moveLast()
148
+ if (this.#currentIndex < 0) return this.moveLast()
149
+ for (let i = this.#currentIndex - 1; i >= 0; i--) {
150
+ if (!this.#isDisabled(i)) return this.moveToIndex(i)
108
151
  }
109
152
  return false
110
153
  }
111
154
 
112
155
  moveNext() {
113
- if (this.#currentIndex < this.data.length - 1) {
114
- return this.moveToIndex(this.#currentIndex + 1)
156
+ for (let i = this.#currentIndex + 1; i < this.data.length; i++) {
157
+ if (!this.#isDisabled(i)) return this.moveToIndex(i)
115
158
  }
116
159
  return false
117
160
  }
118
161
 
119
162
  moveFirst() {
120
- return this.moveToIndex(0)
163
+ for (let i = 0; i < this.data.length; i++) {
164
+ if (!this.#isDisabled(i)) return this.moveToIndex(i)
165
+ }
166
+ return false
121
167
  }
122
168
 
123
169
  moveLast() {
124
- return this.moveToIndex(this.data.length - 1)
170
+ for (let i = this.data.length - 1; i >= 0; i--) {
171
+ if (!this.#isDisabled(i)) return this.moveToIndex(i)
172
+ }
173
+ return false
125
174
  }
126
175
 
127
176
  /**
@@ -159,6 +208,7 @@ export class ListController {
159
208
  this.selectedKeys.add(key)
160
209
  }
161
210
 
211
+ this.#anchorKey = key
162
212
  return true
163
213
  }
164
214
 
@@ -173,12 +223,74 @@ export class ListController {
173
223
  if (!this.lookup.has(key)) return false
174
224
 
175
225
  if (this.#options.multiselect) {
226
+ this.#anchorKey = key
176
227
  return this.toggleSelection(key)
177
228
  } else {
178
229
  return this.select(key)
179
230
  }
180
231
  }
181
232
 
233
+ /**
234
+ * Select all non-disabled items between the anchor and the given key (inclusive).
235
+ * Used for Shift+click range selection in multiselect mode.
236
+ * @param {string} selectedKey
237
+ * @returns {boolean}
238
+ */
239
+ selectRange(selectedKey) {
240
+ const key = selectedKey ?? this.focusedKey
241
+ if (!this.lookup.has(key)) return false
242
+
243
+ if (!this.#options.multiselect) return this.select(key)
244
+
245
+ const anchorKey = this.#anchorKey ?? this.focusedKey
246
+ if (!anchorKey) return this.select(key)
247
+
248
+ const anchorIndex = this.data.findIndex((row) => row.key === anchorKey)
249
+ const targetIndex = this.data.findIndex((row) => row.key === key)
250
+ if (anchorIndex < 0 || targetIndex < 0) return false
251
+
252
+ const start = Math.min(anchorIndex, targetIndex)
253
+ const end = Math.max(anchorIndex, targetIndex)
254
+
255
+ this.selectedKeys.clear()
256
+ for (let i = start; i <= end; i++) {
257
+ if (!this.#isDisabled(i)) {
258
+ this.selectedKeys.add(this.data[i].key)
259
+ }
260
+ }
261
+
262
+ // Move focus but don't change anchor (anchor stays for subsequent Shift+clicks)
263
+ this.moveToIndex(targetIndex)
264
+ return true
265
+ }
266
+
267
+ /**
268
+ * Find the first visible, non-disabled item whose text starts with `query`.
269
+ * Search wraps around and starts after `startAfterKey` for cycling.
270
+ *
271
+ * @param {string} query - Prefix to match (case-insensitive)
272
+ * @param {string|null} [startAfterKey] - Key to start searching after (for cycling)
273
+ * @returns {string|null} The matching item's key, or null
274
+ */
275
+ findByText(query, startAfterKey = null) {
276
+ const q = query.toLowerCase()
277
+ let startIndex = 0
278
+ if (startAfterKey !== null) {
279
+ const idx = this.data.findIndex((row) => row.key === startAfterKey)
280
+ if (idx >= 0) startIndex = idx + 1
281
+ }
282
+ for (let i = 0; i < this.data.length; i++) {
283
+ const idx = (startIndex + i) % this.data.length
284
+ if (this.#isDisabled(idx)) continue
285
+ const entry = this.lookup.get(this.data[idx].key)
286
+ const text = entry?.label ?? ''
287
+ if (String(text).toLowerCase().startsWith(q)) {
288
+ return this.data[idx].key
289
+ }
290
+ }
291
+ return null
292
+ }
293
+
182
294
  update(items) {
183
295
  this.items = items
184
296
  }
@@ -0,0 +1,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
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Default messages for UI components
3
+ * @type {import('./types').Messages}
4
+ */
5
+ const defaultMessages = {
6
+ emptyList: 'No items found',
7
+ emptyTree: 'No data available',
8
+ loading: 'Loading...',
9
+ noResults: 'No results found',
10
+ select: 'Select an option',
11
+ search: 'Search...',
12
+ 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' }
30
+ }
31
+
32
+ /**
33
+ * Messages store for localized UI strings
34
+ */
35
+ class MessagesStore {
36
+ /** @type {import('./types').Messages} */
37
+ #messages = $state({ ...defaultMessages })
38
+
39
+ /**
40
+ * Get the current messages
41
+ * @returns {import('./types').Messages}
42
+ */
43
+ get current() {
44
+ return this.#messages
45
+ }
46
+
47
+ /**
48
+ * Set custom messages (merges with defaults)
49
+ * @param {Partial<import('./types').Messages>} custom
50
+ */
51
+ set(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
61
+ }
62
+
63
+ /**
64
+ * Reset to default messages
65
+ */
66
+ reset() {
67
+ this.#messages = { ...defaultMessages }
68
+ }
69
+ }
70
+
71
+ export const messages = new MessagesStore()