@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 +5 -6
- package/src/constants.js +1 -0
- package/src/derive.svelte.js +30 -18
- package/src/index.js +7 -3
- package/src/lazy-wrapper.svelte.js +119 -0
- package/src/list-controller.svelte.js +128 -16
- package/src/media.svelte.js +24 -0
- package/src/messages.svelte.js +71 -0
- package/src/proxy-item.svelte.js +320 -0
- package/src/proxy-tree.svelte.js +158 -0
- package/src/table-controller.svelte.js +199 -0
- package/src/vibe.svelte.js +36 -5
- package/src/wrapper.svelte.js +231 -0
- package/src/nested-controller.svelte.js +0 -71
- package/src/proxy.svelte.js +0 -126
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,11 +30,10 @@
|
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@
|
|
34
|
-
"@rokkit/
|
|
33
|
+
"@rokkit/core": "1.0.0-next.128",
|
|
34
|
+
"@rokkit/data": "1.0.0-next.128",
|
|
35
35
|
"d3-array": "^3.2.4",
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"svelte": "^5.39.2"
|
|
36
|
+
"ramda": "^0.32.0",
|
|
37
|
+
"svelte": "^5.53.5"
|
|
39
38
|
}
|
|
40
39
|
}
|
package/src/constants.js
CHANGED
|
@@ -9,6 +9,7 @@ export const DEFAULT_EVENTS = {
|
|
|
9
9
|
|
|
10
10
|
export const VALID_DENSITIES = ['compact', 'comfortable', 'cozy']
|
|
11
11
|
export const VALID_MODES = ['light', 'dark']
|
|
12
|
+
export const VALID_DIRECTIONS = ['ltr', 'rtl']
|
|
12
13
|
/** @type {string[]} */
|
|
13
14
|
export const DEFAULT_STYLES = ['rokkit', 'minimal', 'material']
|
|
14
15
|
|
package/src/derive.svelte.js
CHANGED
|
@@ -1,52 +1,64 @@
|
|
|
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
|
|
8
|
+
* @returns {Array<{ key: string, value: any, level: number, hasChildren: boolean }>}
|
|
9
9
|
*/
|
|
10
|
-
export function flatVisibleNodes(items, fields =
|
|
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
|
|
16
|
-
Array.isArray(item[fields.children]) &&
|
|
17
|
-
|
|
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
|
|
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,
|
|
42
|
+
* @returns {Map<string, { value: *, original: *, label: string, get: (f: string) => * }>}
|
|
38
43
|
*/
|
|
39
|
-
export function deriveLookupWithProxy(items, fields =
|
|
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
|
|
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,
|
|
48
|
-
const 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 {
|
|
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 {
|
|
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,
|
|
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()
|
|
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 = { ...
|
|
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
|
-
|
|
79
|
+
// Try exact match first (full object comparison)
|
|
80
|
+
let index = this.data.findIndex((row) => equals(row.value, value))
|
|
81
|
+
|
|
82
|
+
// Fallback: match by extracted value field (e.g. primitive 'a' against { text: 'A', value: 'a' })
|
|
83
|
+
if (index < 0) {
|
|
84
|
+
const valueField = this.fields.value
|
|
85
|
+
index = this.data.findIndex(
|
|
86
|
+
(row) => typeof row.value === 'object' && row.value !== null && equals(row.value[valueField], value)
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
58
90
|
return index < 0 ? { index } : { index, ...this.data[index] }
|
|
59
91
|
}
|
|
60
92
|
|
|
@@ -79,12 +111,13 @@ export class ListController {
|
|
|
79
111
|
|
|
80
112
|
/**
|
|
81
113
|
*
|
|
82
|
-
* @param {string} path
|
|
114
|
+
* @param {string|number} path - path key string (e.g. "0", "1-0", "2-1-3")
|
|
83
115
|
* @returns
|
|
84
116
|
*/
|
|
85
117
|
moveTo(path) {
|
|
86
|
-
const
|
|
87
|
-
|
|
118
|
+
const key = String(path)
|
|
119
|
+
const index = this.data.findIndex((row) => row.key === key)
|
|
120
|
+
return index >= 0 ? this.moveToIndex(index) : false
|
|
88
121
|
}
|
|
89
122
|
|
|
90
123
|
/**
|
|
@@ -100,28 +133,44 @@ export class ListController {
|
|
|
100
133
|
return false
|
|
101
134
|
}
|
|
102
135
|
|
|
136
|
+
/**
|
|
137
|
+
* @private
|
|
138
|
+
* @param {number} index
|
|
139
|
+
* @returns {boolean}
|
|
140
|
+
*/
|
|
141
|
+
#isDisabled(index) {
|
|
142
|
+
const item = this.data[index]?.value
|
|
143
|
+
if (item === null || item === undefined || typeof item !== 'object') return false
|
|
144
|
+
return item[this.fields.disabled] === true
|
|
145
|
+
}
|
|
146
|
+
|
|
103
147
|
movePrev() {
|
|
104
|
-
if (this.#currentIndex
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return this.moveLast()
|
|
148
|
+
if (this.#currentIndex < 0) return this.moveLast()
|
|
149
|
+
for (let i = this.#currentIndex - 1; i >= 0; i--) {
|
|
150
|
+
if (!this.#isDisabled(i)) return this.moveToIndex(i)
|
|
108
151
|
}
|
|
109
152
|
return false
|
|
110
153
|
}
|
|
111
154
|
|
|
112
155
|
moveNext() {
|
|
113
|
-
|
|
114
|
-
return this.moveToIndex(
|
|
156
|
+
for (let i = this.#currentIndex + 1; i < this.data.length; i++) {
|
|
157
|
+
if (!this.#isDisabled(i)) return this.moveToIndex(i)
|
|
115
158
|
}
|
|
116
159
|
return false
|
|
117
160
|
}
|
|
118
161
|
|
|
119
162
|
moveFirst() {
|
|
120
|
-
|
|
163
|
+
for (let i = 0; i < this.data.length; i++) {
|
|
164
|
+
if (!this.#isDisabled(i)) return this.moveToIndex(i)
|
|
165
|
+
}
|
|
166
|
+
return false
|
|
121
167
|
}
|
|
122
168
|
|
|
123
169
|
moveLast() {
|
|
124
|
-
|
|
170
|
+
for (let i = this.data.length - 1; i >= 0; i--) {
|
|
171
|
+
if (!this.#isDisabled(i)) return this.moveToIndex(i)
|
|
172
|
+
}
|
|
173
|
+
return false
|
|
125
174
|
}
|
|
126
175
|
|
|
127
176
|
/**
|
|
@@ -159,6 +208,7 @@ export class ListController {
|
|
|
159
208
|
this.selectedKeys.add(key)
|
|
160
209
|
}
|
|
161
210
|
|
|
211
|
+
this.#anchorKey = key
|
|
162
212
|
return true
|
|
163
213
|
}
|
|
164
214
|
|
|
@@ -173,12 +223,74 @@ export class ListController {
|
|
|
173
223
|
if (!this.lookup.has(key)) return false
|
|
174
224
|
|
|
175
225
|
if (this.#options.multiselect) {
|
|
226
|
+
this.#anchorKey = key
|
|
176
227
|
return this.toggleSelection(key)
|
|
177
228
|
} else {
|
|
178
229
|
return this.select(key)
|
|
179
230
|
}
|
|
180
231
|
}
|
|
181
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Select all non-disabled items between the anchor and the given key (inclusive).
|
|
235
|
+
* Used for Shift+click range selection in multiselect mode.
|
|
236
|
+
* @param {string} selectedKey
|
|
237
|
+
* @returns {boolean}
|
|
238
|
+
*/
|
|
239
|
+
selectRange(selectedKey) {
|
|
240
|
+
const key = selectedKey ?? this.focusedKey
|
|
241
|
+
if (!this.lookup.has(key)) return false
|
|
242
|
+
|
|
243
|
+
if (!this.#options.multiselect) return this.select(key)
|
|
244
|
+
|
|
245
|
+
const anchorKey = this.#anchorKey ?? this.focusedKey
|
|
246
|
+
if (!anchorKey) return this.select(key)
|
|
247
|
+
|
|
248
|
+
const anchorIndex = this.data.findIndex((row) => row.key === anchorKey)
|
|
249
|
+
const targetIndex = this.data.findIndex((row) => row.key === key)
|
|
250
|
+
if (anchorIndex < 0 || targetIndex < 0) return false
|
|
251
|
+
|
|
252
|
+
const start = Math.min(anchorIndex, targetIndex)
|
|
253
|
+
const end = Math.max(anchorIndex, targetIndex)
|
|
254
|
+
|
|
255
|
+
this.selectedKeys.clear()
|
|
256
|
+
for (let i = start; i <= end; i++) {
|
|
257
|
+
if (!this.#isDisabled(i)) {
|
|
258
|
+
this.selectedKeys.add(this.data[i].key)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Move focus but don't change anchor (anchor stays for subsequent Shift+clicks)
|
|
263
|
+
this.moveToIndex(targetIndex)
|
|
264
|
+
return true
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Find the first visible, non-disabled item whose text starts with `query`.
|
|
269
|
+
* Search wraps around and starts after `startAfterKey` for cycling.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} query - Prefix to match (case-insensitive)
|
|
272
|
+
* @param {string|null} [startAfterKey] - Key to start searching after (for cycling)
|
|
273
|
+
* @returns {string|null} The matching item's key, or null
|
|
274
|
+
*/
|
|
275
|
+
findByText(query, startAfterKey = null) {
|
|
276
|
+
const q = query.toLowerCase()
|
|
277
|
+
let startIndex = 0
|
|
278
|
+
if (startAfterKey !== null) {
|
|
279
|
+
const idx = this.data.findIndex((row) => row.key === startAfterKey)
|
|
280
|
+
if (idx >= 0) startIndex = idx + 1
|
|
281
|
+
}
|
|
282
|
+
for (let i = 0; i < this.data.length; i++) {
|
|
283
|
+
const idx = (startIndex + i) % this.data.length
|
|
284
|
+
if (this.#isDisabled(idx)) continue
|
|
285
|
+
const 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()
|