@rokkit/ui 1.3.15 → 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 tree = $derived.by(() => {
96
- const root: unknown[] = []
97
- const lookup: Record<string, Record<string, unknown>> = {}
98
- for (const f of files) {
99
- const parts = f.path.split('/').filter(Boolean)
100
- let parentChildren = root
101
- let acc = ''
102
- for (let i = 0; i < parts.length; i++) {
103
- const part = parts[i]
104
- const isLeaf = i === parts.length - 1
105
- acc = acc ? `${acc}/${part}` : part
106
- let node = lookup[acc]
107
- if (!node) {
108
- node = {
109
- id: acc,
110
- name: part,
111
- icon: isLeaf ? iconForFile(f) : undefined,
112
- children: isLeaf ? undefined : []
113
- }
114
- lookup[acc] = node
115
- parentChildren.push(node)
116
- }
117
- if (!isLeaf) parentChildren = node.children as unknown[]
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)
@@ -98,6 +98,7 @@
98
98
 
99
99
  function handleUpperPanEnd() {
100
100
  slidingUpper = false
101
+ if (disabled) return
101
102
  // Final snap
102
103
  if (rangeMode) {
103
104
  upper = snapToStep(clamp(upper, lower, max))
@@ -175,6 +176,7 @@
175
176
 
176
177
  function handleLowerPanEnd() {
177
178
  slidingLower = false
179
+ if (disabled) return
178
180
  lower = snapToStep(clamp(lower, min, upper))
179
181
  fireChange()
180
182
  }
@@ -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.3.15",
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.3.15",
48
- "@rokkit/data": "1.3.15",
49
- "@rokkit/states": "1.3.15",
50
- "@rokkit/actions": "1.3.15",
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
  },