@rokkit/ui 1.4.0 → 1.4.1
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.
|
@@ -92,31 +92,45 @@
|
|
|
92
92
|
// carrying the original path as its id. Folder nodes get their icon
|
|
93
93
|
// from the Tree's default state (it owns folder open/closed via its
|
|
94
94
|
// own icons prop); file leaves get the per-extension semantic icon.
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
95
|
+
const makeNode = (id: string, name: string, isLeaf: boolean, icon?: string) => ({
|
|
96
|
+
id,
|
|
97
|
+
name,
|
|
98
|
+
icon,
|
|
99
|
+
children: isLeaf ? undefined : []
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Insert one file's path into the tree, creating any missing folder nodes on
|
|
104
|
+
* the way down. `lookup` is prototype-less: a path segment literally named
|
|
105
|
+
* `constructor` or `toString` would otherwise find an inherited value and be
|
|
106
|
+
* treated as an already-created node.
|
|
107
|
+
*/
|
|
108
|
+
function insertPath(
|
|
109
|
+
f: (typeof files)[number],
|
|
110
|
+
root: unknown[],
|
|
111
|
+
lookup: Record<string, Record<string, unknown>>
|
|
112
|
+
) {
|
|
113
|
+
const parts = f.path.split('/').filter(Boolean)
|
|
114
|
+
let parentChildren = root
|
|
115
|
+
let acc = ''
|
|
116
|
+
for (let i = 0; i < parts.length; i++) {
|
|
117
|
+
const part = parts[i]
|
|
118
|
+
const isLeaf = i === parts.length - 1
|
|
119
|
+
acc = acc ? `${acc}/${part}` : part
|
|
120
|
+
let node = lookup[acc]
|
|
121
|
+
if (!node) {
|
|
122
|
+
node = makeNode(acc, part, isLeaf, isLeaf ? iconForFile(f) : undefined)
|
|
123
|
+
lookup[acc] = node
|
|
124
|
+
parentChildren.push(node)
|
|
118
125
|
}
|
|
126
|
+
if (!isLeaf) parentChildren = node.children as unknown[]
|
|
119
127
|
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const tree = $derived.by(() => {
|
|
131
|
+
const root: unknown[] = []
|
|
132
|
+
const lookup: Record<string, Record<string, unknown>> = Object.create(null)
|
|
133
|
+
for (const f of files) insertPath(f, root, lookup)
|
|
120
134
|
return root
|
|
121
135
|
})
|
|
122
136
|
|
|
@@ -66,11 +66,46 @@
|
|
|
66
66
|
|
|
67
67
|
const icons = $derived({ ...DEFAULT_STATE_ICONS.accordion, ...userIcons })
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The outbound half of `value`. It is two-way: inbound it decides `data-active`
|
|
71
|
+
* and which group is expanded; outbound a selection publishes the new value.
|
|
72
|
+
* This is the only place `value` is written.
|
|
73
|
+
*
|
|
74
|
+
* Three deliberate details:
|
|
75
|
+
*
|
|
76
|
+
* - `proxy.disabled` bails, matching Select. A disabled row is unreachable by
|
|
77
|
+
* pointer (base CSS sets `pointer-events: none`) but not by the keyboard.
|
|
78
|
+
*
|
|
79
|
+
* - The write is skipped when nothing changed. `onselect` fires on EVERY
|
|
80
|
+
* activation, re-activating the already-active row included, and writing
|
|
81
|
+
* unconditionally would invalidate `value` for every consumer and re-run both
|
|
82
|
+
* effects below — including `syncExpandedGroups`, which would collapse any
|
|
83
|
+
* group the user had opened by hand — for no actual change.
|
|
84
|
+
*
|
|
85
|
+
* - The write lands BEFORE the consumer's callback, so a consumer reading the
|
|
86
|
+
* bound variable inside `onselect` sees the new value, and a consumer that
|
|
87
|
+
* assigns its own (transformed, or reverted) value writes last and wins. That
|
|
88
|
+
* ordering is what keeps existing `onselect={(v) => (mine = v)}` code behaving
|
|
89
|
+
* exactly as it did before `value` became two-way.
|
|
90
|
+
*
|
|
91
|
+
* No feedback loop: this runs from a DOM event, never from an effect, so writing
|
|
92
|
+
* a prop here cannot re-enter the effects that read it. `moveToValue` then
|
|
93
|
+
* re-syncs to the value it already holds and settles.
|
|
94
|
+
*
|
|
95
|
+
* Persisting the write needs `bind:value` — assigning a non-bound `$bindable`
|
|
96
|
+
* updates only the local fallback, same as every other Svelte component.
|
|
97
|
+
*/
|
|
98
|
+
function handleSelect(next: unknown, proxy: ProxyItem) {
|
|
99
|
+
if (proxy.disabled) return
|
|
100
|
+
if (next !== value) value = next
|
|
101
|
+
onselect?.(next, proxy)
|
|
102
|
+
}
|
|
103
|
+
|
|
69
104
|
// Single source of truth.
|
|
70
105
|
// Navigator calls wrapper[action](path) → focusedKey / proxy.expanded updates →
|
|
71
106
|
// flatView $derived re-computes → Svelte re-renders the changed nodes.
|
|
72
107
|
const proxyTree = $derived(new ProxyTree(items, fields))
|
|
73
|
-
const wrapper = $derived(new Wrapper(proxyTree, { onselect, collapsible }))
|
|
108
|
+
const wrapper = $derived(new Wrapper(proxyTree, { onselect: handleSelect, collapsible }))
|
|
74
109
|
|
|
75
110
|
let listRef = $state<HTMLElement | null>(null)
|
|
76
111
|
|
|
@@ -117,6 +152,9 @@
|
|
|
117
152
|
$effect(syncExpandedGroups)
|
|
118
153
|
|
|
119
154
|
// ─── Sync external value → focused key ────────────────────────────────────
|
|
155
|
+
// The inbound half of the two-way `value`. Safe against the outbound write in
|
|
156
|
+
// handleSelect: moveToValue only assigns focusedKey/selectedValue, neither of
|
|
157
|
+
// which this effect reads, so a selection settles in one pass.
|
|
120
158
|
|
|
121
159
|
$effect(() => {
|
|
122
160
|
wrapper.moveToValue(value)
|
|
@@ -209,39 +209,50 @@
|
|
|
209
209
|
|
|
210
210
|
// ─── Trigger action ───────────────────────────────────────────────────────
|
|
211
211
|
|
|
212
|
+
/** Bring `el` into view inside the dropdown only — never scroll an ancestor. */
|
|
213
|
+
function scrollIntoDropdown(el: HTMLElement) {
|
|
214
|
+
if (!dropdownRef) return
|
|
215
|
+
const top = el.offsetTop
|
|
216
|
+
const bottom = top + el.offsetHeight
|
|
217
|
+
if (top < dropdownRef.scrollTop) {
|
|
218
|
+
dropdownRef.scrollTop = top
|
|
219
|
+
} else if (bottom > dropdownRef.scrollTop + dropdownRef.clientHeight) {
|
|
220
|
+
dropdownRef.scrollTop = bottom - dropdownRef.clientHeight
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Move DOM focus to the wrapper's focused item so subsequent keydown events
|
|
226
|
+
* fire inside the dropdown, where Navigator is listening. preventScroll plus
|
|
227
|
+
* the dropdown-only scroll above keeps ancestor containers still.
|
|
228
|
+
*/
|
|
229
|
+
function focusFocusedItem() {
|
|
230
|
+
if (!dropdownRef) return
|
|
231
|
+
const key = wrapper.focusedKey
|
|
232
|
+
if (!key) return
|
|
233
|
+
const el = dropdownRef.querySelector(`[data-path="${key}"]`) as HTMLElement | null
|
|
234
|
+
if (!el) return
|
|
235
|
+
el.focus({ preventScroll: true })
|
|
236
|
+
scrollIntoDropdown(el)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Where focus lands once the dropdown is open: the filter box, or the current item. */
|
|
240
|
+
function handleOpened() {
|
|
241
|
+
if (filterable) {
|
|
242
|
+
filterInputRef?.focus()
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
focusSelectedOrFirst()
|
|
246
|
+
requestAnimationFrame(focusFocusedItem)
|
|
247
|
+
}
|
|
248
|
+
|
|
212
249
|
$effect(() => {
|
|
213
250
|
if (!triggerRef || !selectRef || disabled) return
|
|
214
251
|
const t = new Trigger(triggerRef, selectRef, {
|
|
215
252
|
isOpen: () => isOpen,
|
|
216
253
|
onopen: () => {
|
|
217
254
|
isOpen = true
|
|
218
|
-
requestAnimationFrame(
|
|
219
|
-
if (filterable) {
|
|
220
|
-
filterInputRef?.focus()
|
|
221
|
-
return
|
|
222
|
-
}
|
|
223
|
-
focusSelectedOrFirst()
|
|
224
|
-
// Move DOM focus to the wrapper's focused item so subsequent
|
|
225
|
-
// keydown events fire inside the dropdown (Navigator is
|
|
226
|
-
// listening there). preventScroll + dropdown-only scroll
|
|
227
|
-
// to avoid moving ancestor containers.
|
|
228
|
-
requestAnimationFrame(() => {
|
|
229
|
-
if (!dropdownRef) return
|
|
230
|
-
const key = wrapper.focusedKey
|
|
231
|
-
if (!key) return
|
|
232
|
-
const el = dropdownRef.querySelector(`[data-path="${key}"]`) as HTMLElement | null
|
|
233
|
-
if (el) {
|
|
234
|
-
el.focus({ preventScroll: true })
|
|
235
|
-
const top = el.offsetTop
|
|
236
|
-
const bottom = top + el.offsetHeight
|
|
237
|
-
if (top < dropdownRef.scrollTop) {
|
|
238
|
-
dropdownRef.scrollTop = top
|
|
239
|
-
} else if (bottom > dropdownRef.scrollTop + dropdownRef.clientHeight) {
|
|
240
|
-
dropdownRef.scrollTop = bottom - dropdownRef.clientHeight
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
})
|
|
244
|
-
})
|
|
255
|
+
requestAnimationFrame(handleOpened)
|
|
245
256
|
},
|
|
246
257
|
onclose: () => {
|
|
247
258
|
isOpen = false
|
|
@@ -46,8 +46,23 @@
|
|
|
46
46
|
|
|
47
47
|
const icons = $derived({ ...DEFAULT_STATE_ICONS.folder, ...userIcons })
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The outbound half of `value` — see List's handleSelect for the full
|
|
51
|
+
* reasoning; the contract is identical. Skips disabled nodes, skips a write
|
|
52
|
+
* that wouldn't change anything, and writes before notifying so a consumer's
|
|
53
|
+
* own assignment in `onselect` still wins.
|
|
54
|
+
*
|
|
55
|
+
* Runs from a DOM event rather than an effect, so writing the prop here cannot
|
|
56
|
+
* re-enter the moveToValue effect below.
|
|
57
|
+
*/
|
|
58
|
+
function handleSelect(next: unknown, proxy: ProxyItem) {
|
|
59
|
+
if (proxy.disabled) return
|
|
60
|
+
if (next !== value) value = next
|
|
61
|
+
onselect?.(next, proxy)
|
|
62
|
+
}
|
|
63
|
+
|
|
49
64
|
const proxyTree = $derived(new ProxyTree(items, fields))
|
|
50
|
-
const wrapper = $derived(new LazyWrapper(proxyTree, { onselect }))
|
|
65
|
+
const wrapper = $derived(new LazyWrapper(proxyTree, { onselect: handleSelect }))
|
|
51
66
|
|
|
52
67
|
let treeRef = $state<HTMLElement | null>(null)
|
|
53
68
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rokkit/ui",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.1",
|
|
4
4
|
"description": "Data driven UI components for Rokkit applications",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -44,10 +44,10 @@
|
|
|
44
44
|
"dropdown"
|
|
45
45
|
],
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@rokkit/core": "1.4.
|
|
48
|
-
"@rokkit/data": "1.4.
|
|
49
|
-
"@rokkit/states": "1.4.
|
|
50
|
-
"@rokkit/actions": "1.4.
|
|
47
|
+
"@rokkit/core": "1.4.1",
|
|
48
|
+
"@rokkit/data": "1.4.1",
|
|
49
|
+
"@rokkit/states": "1.4.1",
|
|
50
|
+
"@rokkit/actions": "1.4.1",
|
|
51
51
|
"marked": "^15.0.0",
|
|
52
52
|
"dompurify": "^3.0.0"
|
|
53
53
|
},
|