@rokkit/ui 1.7.0 → 1.8.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.
@@ -168,7 +168,7 @@
168
168
  <aside
169
169
  id="codegroup-drawer"
170
170
  class="rail"
171
- data-open={drawerOpen ? '' : undefined}
171
+ data-open={drawerOpen || undefined}
172
172
  aria-label="Files"
173
173
  >
174
174
  <div class="rail-header">Files</div>
@@ -25,6 +25,8 @@
25
25
  * data-list-group — theme hook for group headers
26
26
  * data-list-group-icon — icon span inside group headers
27
27
  * data-active — highlights current value match
28
+ * data-selected — "true" on every row in the selection, multiselect only
29
+ * data-multiselect — "true" on the root when multiselect (base CSS: no text selection)
28
30
  * data-disabled — disabled state
29
31
  */
30
32
  import type { ProxyItem } from '@rokkit/states'
@@ -38,12 +40,15 @@
38
40
  items = [],
39
41
  fields = {},
40
42
  value = $bindable(),
43
+ multiselect = false,
44
+ values = $bindable([]),
41
45
  size = 'md',
42
46
  disabled = false,
43
47
  collapsible = false,
44
48
  label = messages.list.label,
45
49
  icons: userIcons = {} as ListStateIcons,
46
50
  onselect,
51
+ onchange,
47
52
  class: className = '',
48
53
  itemContent,
49
54
  groupContent,
@@ -92,11 +97,29 @@
92
97
  onselect?.(next, proxy)
93
98
  }
94
99
 
100
+ /**
101
+ * The outbound half of `values` (multiselect). The wrapper reports the set only when it
102
+ * actually changed, so this writes and notifies once per real change. As with `value`,
103
+ * the write lands before the callback, so a consumer reading the bound set inside
104
+ * `onchange` sees the new one.
105
+ */
106
+ function handleSelectionChange(next: unknown[]) {
107
+ values = next
108
+ onchange?.(next)
109
+ }
110
+
95
111
  // Single source of truth.
96
112
  // Navigator calls wrapper[action](path) → focusedKey / proxy.expanded updates →
97
113
  // flatView $derived re-computes → Svelte re-renders the changed nodes.
98
114
  const proxyTree = $derived(new ProxyTree(items, fields))
99
- const wrapper = $derived(new Wrapper(proxyTree, { onselect: handleSelect, collapsible }))
115
+ const wrapper = $derived(
116
+ new Wrapper(proxyTree, {
117
+ onselect: handleSelect,
118
+ onselectionchange: handleSelectionChange,
119
+ collapsible,
120
+ multiselect
121
+ })
122
+ )
100
123
 
101
124
  let listRef = $state<HTMLElement | null>(null)
102
125
 
@@ -150,6 +173,12 @@
150
173
  $effect(() => {
151
174
  wrapper.moveToValue(value)
152
175
  })
176
+
177
+ // The inbound half of `values`. moveToValues does not report back, so an outbound change
178
+ // re-syncs to the set it already holds and settles.
179
+ $effect(() => {
180
+ if (multiselect) wrapper.moveToValues(values)
181
+ })
153
182
  </script>
154
183
 
155
184
  {#snippet collapsibleIcon(proxy: ProxyItem)}
@@ -168,12 +197,14 @@
168
197
  data-size={size}
169
198
  data-disabled={disabled || undefined}
170
199
  data-collapsible={collapsible || undefined}
200
+ data-multiselect={multiselect || undefined}
171
201
  class={className || undefined}
172
202
  aria-label={label}
173
203
  >
174
204
  {#each wrapper.flatView as node (node.key)}
175
205
  {@const proxy = node.proxy}
176
206
  {@const isActive = proxy.value === value}
207
+ {@const isSelected = multiselect && wrapper.selectedKeys.has(node.key)}
177
208
  {@const content = resolveSnippet(
178
209
  snippetBag,
179
210
  proxy,
@@ -245,6 +276,8 @@
245
276
  data-path={node.key}
246
277
  data-level={node.level}
247
278
  data-active={isActive || undefined}
279
+ data-selected={isSelected || undefined}
280
+ aria-pressed={multiselect ? isSelected : undefined}
248
281
  data-disabled={proxy.disabled || undefined}
249
282
  disabled={proxy.disabled || disabled}
250
283
  >
@@ -1,4 +1,4 @@
1
1
  import type { ListProps } from '../types/list.js';
2
- declare const List: import("svelte").Component<ListProps, {}, "value">;
2
+ declare const List: import("svelte").Component<ListProps, {}, "values" | "value">;
3
3
  type List = ReturnType<typeof List>;
4
4
  export default List;
@@ -26,10 +26,10 @@
26
26
  */
27
27
  import type { ProxyItem } from '@rokkit/states'
28
28
  import { Wrapper, ProxyTree, messages } from '@rokkit/states'
29
- import { SvelteSet } from 'svelte/reactivity'
30
- import { Navigator, Trigger } from '@rokkit/actions'
29
+ import { Navigator, Trigger, focusItem } from '@rokkit/actions'
31
30
  import { DEFAULT_STATE_ICONS, resolveSnippet, ITEM_SNIPPET, GROUP_SNIPPET } from '@rokkit/core'
32
31
  import ItemContent from './ItemContent.svelte'
32
+ import { groupsAsLabels, groupDividerKeys } from '../utils/dropdown.js'
33
33
  import type { MultiSelectProps, SelectStateIcons } from '../types/select.js'
34
34
 
35
35
  let {
@@ -74,17 +74,8 @@
74
74
 
75
75
  const childrenField = $derived(fields?.children || 'children')
76
76
 
77
- // Force groups expanded + disabled (non-navigable labels)
78
- const processedItems = $derived(
79
- items.map((item) => {
80
- const asRecord = item as Record<string, unknown>
81
- const children = asRecord[childrenField]
82
- if (Array.isArray(children) && children.length > 0) {
83
- return { ...asRecord, expanded: true, disabled: true }
84
- }
85
- return item
86
- })
87
- )
77
+ // Groups render as non-navigable labels
78
+ const processedItems = $derived(groupsAsLabels(items, childrenField))
88
79
 
89
80
  // ─── Wrapper ──────────────────────────────────────────────────────────────
90
81
 
@@ -194,32 +185,19 @@
194
185
  return () => nav.destroy()
195
186
  })
196
187
 
197
- // DOM focus sync
188
+ // DOM focus sync. focusItem, not focus() + scrollIntoView(): those scroll every scrollable
189
+ // ancestor, so arrowing through a MultiSelect inside a scrolling panel moved the page.
198
190
  $effect(() => {
199
191
  const key = wrapper.focusedKey
200
192
  if (!isOpen || !dropdownRef || !key) return
201
193
  requestAnimationFrame(() => {
202
- const target = dropdownRef?.querySelector(`[data-path="${key}"]`) as HTMLElement | null
203
- if (target && target !== document.activeElement) {
204
- target.focus()
205
- target.scrollIntoView?.({ block: 'nearest' })
206
- }
194
+ if (dropdownRef) focusItem(dropdownRef, key)
207
195
  })
208
196
  })
209
197
 
210
198
  // ─── Helpers ──────────────────────────────────────────────────────────────
211
199
 
212
- const groupDividers = $derived.by(() => {
213
- const set = new SvelteSet<string>()
214
- let foundFirst = false
215
- for (const node of wrapper.flatView) {
216
- if (node.hasChildren) {
217
- if (foundFirst) set.add(node.key)
218
- foundFirst = true
219
- }
220
- }
221
- return set
222
- })
200
+ const groupDividers = $derived(groupDividerKeys(wrapper.flatView))
223
201
  </script>
224
202
 
225
203
  {#snippet defaultOptionContent(proxy: ProxyItem)}
@@ -39,10 +39,16 @@
39
39
  */
40
40
  import type { ProxyItem } from '@rokkit/states'
41
41
  import { Wrapper, ProxyTree, messages } from '@rokkit/states'
42
- import { SvelteSet } from 'svelte/reactivity'
43
- import { Navigator, Trigger } from '@rokkit/actions'
42
+ import { Navigator, Trigger, focusItem } from '@rokkit/actions'
44
43
  import { DEFAULT_STATE_ICONS, resolveSnippet, ITEM_SNIPPET, GROUP_SNIPPET } from '@rokkit/core'
45
44
  import ItemContent from './ItemContent.svelte'
45
+ import {
46
+ filterItems,
47
+ groupsAsLabels,
48
+ groupDividerKeys,
49
+ valueKey,
50
+ dropdownPlacement
51
+ } from '../utils/dropdown.js'
46
52
  import type { SelectProps, SelectStateIcons } from '../types/select.js'
47
53
 
48
54
  let {
@@ -92,46 +98,13 @@
92
98
  const textField = $derived(fields?.label || 'label')
93
99
  const childrenField = $derived(fields?.children || 'children')
94
100
 
95
- function childMatchesQuery(child: unknown, query: string): boolean {
96
- return String((child as Record<string, unknown>)[textField] ?? '')
97
- .toLowerCase()
98
- .includes(query)
99
- }
100
-
101
- function filterGroupChildren(asRecord: Record<string, unknown>, query: string): unknown | null {
102
- const children = asRecord[childrenField] as unknown[]
103
- const matching = children.filter((child: unknown) => childMatchesQuery(child, query))
104
- return matching.length > 0 ? { ...asRecord, [childrenField]: matching } : null
105
- }
106
-
107
- function filterItem(item: unknown, query: string): unknown | null {
108
- const asRecord = item as Record<string, unknown>
109
- const children = asRecord[childrenField]
110
- if (Array.isArray(children) && children.length > 0) {
111
- return filterGroupChildren(asRecord, query)
112
- }
113
- const text = String(asRecord[textField] ?? '').toLowerCase()
114
- return text.includes(query) ? item : null
115
- }
116
-
117
- const filteredItems = $derived.by(() => {
118
- if (!filterable || !filterQuery) return items
119
- const query = filterQuery.toLowerCase()
120
- return items.map((item) => filterItem(item, query)).filter(Boolean)
121
- })
122
-
123
- // Pre-process: force groups expanded + disabled (non-navigable labels)
124
- const processedItems = $derived(
125
- filteredItems.map((item) => {
126
- const asRecord = item as Record<string, unknown>
127
- const children = asRecord[childrenField]
128
- if (Array.isArray(children) && children.length > 0) {
129
- return { ...asRecord, expanded: true, disabled: true }
130
- }
131
- return item
132
- })
101
+ const filteredItems = $derived(
102
+ filterable && filterQuery ? filterItems(items, filterQuery, { textField, childrenField }) : items
133
103
  )
134
104
 
105
+ // Groups render as non-navigable labels
106
+ const processedItems = $derived(groupsAsLabels(filteredItems, childrenField))
107
+
135
108
  // ─── Wrapper ──────────────────────────────────────────────────────────────
136
109
 
137
110
  function handleSelect(extractedValue: unknown, proxy: ProxyItem) {
@@ -194,31 +167,12 @@
194
167
 
195
168
  // ─── Trigger action ───────────────────────────────────────────────────────
196
169
 
197
- /** Bring `el` into view inside the dropdown only — never scroll an ancestor. */
198
- function scrollIntoDropdown(el: HTMLElement) {
199
- if (!dropdownRef) return
200
- const top = el.offsetTop
201
- const bottom = top + el.offsetHeight
202
- if (top < dropdownRef.scrollTop) {
203
- dropdownRef.scrollTop = top
204
- } else if (bottom > dropdownRef.scrollTop + dropdownRef.clientHeight) {
205
- dropdownRef.scrollTop = bottom - dropdownRef.clientHeight
206
- }
207
- }
208
-
209
170
  /**
210
- * Move DOM focus to the wrapper's focused item so subsequent keydown events
211
- * fire inside the dropdown, where Navigator is listening. preventScroll plus
212
- * the dropdown-only scroll above keeps ancestor containers still.
171
+ * Move DOM focus to the wrapper's focused item so subsequent keydown events fire inside the
172
+ * dropdown, where Navigator is listening — without scrolling any ancestor (focusItem).
213
173
  */
214
174
  function focusFocusedItem() {
215
- if (!dropdownRef) return
216
- const key = wrapper.focusedKey
217
- if (!key) return
218
- const el = dropdownRef.querySelector(`[data-path="${key}"]`) as HTMLElement | null
219
- if (!el) return
220
- el.focus({ preventScroll: true })
221
- scrollIntoDropdown(el)
175
+ if (dropdownRef) focusItem(dropdownRef, wrapper.focusedKey)
222
176
  }
223
177
 
224
178
  /** Where focus lands once the dropdown is open: the filter box, or the current item. */
@@ -248,19 +202,10 @@
248
202
  return () => t.destroy()
249
203
  })
250
204
 
251
- function moveToSelectedValue(): boolean {
252
- for (const node of wrapper.flatView) {
253
- if (!node.proxy.disabled && node.proxy.value === value) {
254
- wrapper.moveTo(node.key)
255
- return true
256
- }
257
- }
258
- return false
259
- }
260
-
261
205
  function focusSelectedOrFirst() {
262
- if (value !== undefined && value !== null && moveToSelectedValue()) return
263
- wrapper.first(null)
206
+ const key = valueKey(wrapper.flatView, value)
207
+ if (key) wrapper.moveTo(key)
208
+ else wrapper.first(null)
264
209
  }
265
210
 
266
211
  // ─── Navigator on dropdown ────────────────────────────────────────────────
@@ -298,24 +243,12 @@
298
243
  // away from the trigger.
299
244
  function positionDropdown() {
300
245
  if (!dropdownRef || !triggerRef) return
301
- const r = triggerRef.getBoundingClientRect()
302
- const gap = 4
303
- dropdownRef.style.position = 'fixed'
304
- dropdownRef.style.minWidth = `${r.width}px`
305
- if (direction === 'up') {
306
- dropdownRef.style.top = 'auto'
307
- dropdownRef.style.bottom = `${window.innerHeight - r.top + gap}px`
308
- } else {
309
- dropdownRef.style.top = `${r.bottom + gap}px`
310
- dropdownRef.style.bottom = 'auto'
311
- }
312
- if (align === 'end') {
313
- dropdownRef.style.left = 'auto'
314
- dropdownRef.style.right = `${window.innerWidth - r.right}px`
315
- } else {
316
- dropdownRef.style.left = `${r.left}px`
317
- dropdownRef.style.right = 'auto'
318
- }
246
+ const placement = dropdownPlacement(
247
+ triggerRef.getBoundingClientRect(),
248
+ { width: window.innerWidth, height: window.innerHeight },
249
+ { direction, align }
250
+ )
251
+ Object.assign(dropdownRef.style, placement)
319
252
  }
320
253
 
321
254
  $effect(() => {
@@ -339,7 +272,7 @@
339
272
  }
340
273
  })
341
274
 
342
- // Focus sync is owned by Navigator (#syncFocus). The previous duplicate
275
+ // Focus sync after a key is owned by Navigator (focusItem). The previous duplicate
343
276
  // $effect here competed with Navigator's own focus + scroll logic and
344
277
  // caused race conditions (page-scroll on arrow nav, layout shift on
345
278
  // reopen). Single source of truth lives in the Navigator class.
@@ -376,17 +309,7 @@
376
309
  // ─── Helpers ──────────────────────────────────────────────────────────────
377
310
 
378
311
  /** Set of group keys that need a divider before them (not the first group) */
379
- const groupDividers = $derived.by(() => {
380
- const set = new SvelteSet<string>()
381
- let foundFirst = false
382
- for (const node of wrapper.flatView) {
383
- if (node.hasChildren) {
384
- if (foundFirst) set.add(node.key)
385
- foundFirst = true
386
- }
387
- }
388
- return set
389
- })
312
+ const groupDividers = $derived(groupDividerKeys(wrapper.flatView))
390
313
  </script>
391
314
 
392
315
  {#snippet defaultOptionContent(proxy: ProxyItem)}
@@ -62,6 +62,13 @@ export interface ListProps extends ItemSnippets {
62
62
  fields?: Record<string, string>;
63
63
  /** Selected value (bindable) — matched against each item's value field */
64
64
  value?: unknown;
65
+ /**
66
+ * Let the reader select several rows: a click selects one, ctrl/⌘-click toggles,
67
+ * shift-click extends a range. Off by default.
68
+ */
69
+ multiselect?: boolean;
70
+ /** The selected values when `multiselect` (bindable) — matched by identity */
71
+ values?: unknown[];
65
72
  /** Size variant */
66
73
  size?: string;
67
74
  /** Whether the entire list is disabled */
@@ -74,6 +81,8 @@ export interface ListProps extends ItemSnippets {
74
81
  icons?: ListStateIcons;
75
82
  /** Called when an item is selected, with the item's `ProxyItem` */
76
83
  onselect?: (value: unknown, proxy: ProxyItem) => void;
84
+ /** Called with the new set whenever a multiselect selection changes */
85
+ onchange?: (values: unknown[]) => void;
77
86
  /** Additional CSS classes */
78
87
  class?: string;
79
88
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The pure parts of a list dropdown (Select, MultiSelect): filtering, groups as labels, the
3
+ * dividers between groups, finding a value's node, and fixed-position placement.
4
+ */
5
+ export type ItemFields = {
6
+ textField: string;
7
+ childrenField: string;
8
+ };
9
+ /** A node of a Wrapper's flat view — only the members these helpers read. */
10
+ export type FlatNode = {
11
+ key: string;
12
+ hasChildren?: boolean;
13
+ proxy?: {
14
+ value: unknown;
15
+ disabled?: boolean;
16
+ };
17
+ };
18
+ /**
19
+ * The items a filter query keeps. A leaf stays when its text contains the query
20
+ * (case-insensitive); a group stays with only its matching children, and goes when none match.
21
+ * A group is an item with a non-empty children array.
22
+ */
23
+ export declare function filterItems(items: unknown[], query: string, { textField, childrenField }: ItemFields): unknown[];
24
+ /**
25
+ * Groups rendered as labels: always expanded (children always shown) and disabled, so the
26
+ * Navigator skips them. Leaves pass through untouched.
27
+ */
28
+ export declare function groupsAsLabels(items: unknown[], childrenField: string): unknown[];
29
+ /** The group keys that get a divider before them — every group after the first. */
30
+ export declare function groupDividerKeys(flatView: FlatNode[]): Set<string>;
31
+ /** The key of the first enabled node holding `value`, or null (also for a null/undefined value). */
32
+ export declare function valueKey(flatView: FlatNode[], value: unknown): string | null;
33
+ export type Placement = {
34
+ position: 'fixed';
35
+ minWidth: string;
36
+ top: string;
37
+ bottom: string;
38
+ left: string;
39
+ right: string;
40
+ };
41
+ /**
42
+ * Where a `position: fixed` dropdown sits against its trigger: `gap` px below it (or above for
43
+ * `direction: 'up'`), its start edge on the trigger's (or its end edge for `align: 'end'`), and
44
+ * at least as wide as the trigger. Fixed, so it escapes an ancestor's `overflow` clipping.
45
+ */
46
+ export declare function dropdownPlacement(trigger: {
47
+ top: number;
48
+ bottom: number;
49
+ left: number;
50
+ right: number;
51
+ width: number;
52
+ }, viewport: {
53
+ width: number;
54
+ height: number;
55
+ }, { direction, align, gap }: {
56
+ direction: string;
57
+ align: string;
58
+ gap?: number;
59
+ }): Placement;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The pure parts of a list dropdown (Select, MultiSelect): filtering, groups as labels, the
3
+ * dividers between groups, finding a value's node, and fixed-position placement.
4
+ */
5
+ const childrenOf = (item, childrenField) => {
6
+ const children = item[childrenField];
7
+ return Array.isArray(children) && children.length > 0 ? children : null;
8
+ };
9
+ const textMatches = (item, textField, query) => String(item[textField] ?? '')
10
+ .toLowerCase()
11
+ .includes(query);
12
+ /**
13
+ * The items a filter query keeps. A leaf stays when its text contains the query
14
+ * (case-insensitive); a group stays with only its matching children, and goes when none match.
15
+ * A group is an item with a non-empty children array.
16
+ */
17
+ export function filterItems(items, query, { textField, childrenField }) {
18
+ const q = query.toLowerCase();
19
+ return items
20
+ .map((item) => {
21
+ const children = childrenOf(item, childrenField);
22
+ if (!children)
23
+ return textMatches(item, textField, q) ? item : null;
24
+ const matching = children.filter((child) => textMatches(child, textField, q));
25
+ return matching.length > 0 ? { ...item, [childrenField]: matching } : null;
26
+ })
27
+ .filter(Boolean);
28
+ }
29
+ /**
30
+ * Groups rendered as labels: always expanded (children always shown) and disabled, so the
31
+ * Navigator skips them. Leaves pass through untouched.
32
+ */
33
+ export function groupsAsLabels(items, childrenField) {
34
+ return items.map((item) => childrenOf(item, childrenField) ? { ...item, expanded: true, disabled: true } : item);
35
+ }
36
+ /** The group keys that get a divider before them — every group after the first. */
37
+ export function groupDividerKeys(flatView) {
38
+ const groups = flatView.filter((node) => node.hasChildren).map((node) => node.key);
39
+ return new Set(groups.slice(1));
40
+ }
41
+ /** The key of the first enabled node holding `value`, or null (also for a null/undefined value). */
42
+ export function valueKey(flatView, value) {
43
+ if (value === undefined || value === null)
44
+ return null;
45
+ const node = flatView.find((n) => !n.proxy?.disabled && n.proxy?.value === value);
46
+ return node ? node.key : null;
47
+ }
48
+ /**
49
+ * Where a `position: fixed` dropdown sits against its trigger: `gap` px below it (or above for
50
+ * `direction: 'up'`), its start edge on the trigger's (or its end edge for `align: 'end'`), and
51
+ * at least as wide as the trigger. Fixed, so it escapes an ancestor's `overflow` clipping.
52
+ */
53
+ export function dropdownPlacement(trigger, viewport, { direction, align, gap = 4 }) {
54
+ const up = direction === 'up';
55
+ const end = align === 'end';
56
+ return {
57
+ position: 'fixed',
58
+ minWidth: `${trigger.width}px`,
59
+ top: up ? 'auto' : `${trigger.bottom + gap}px`,
60
+ bottom: up ? `${viewport.height - trigger.top + gap}px` : 'auto',
61
+ left: end ? 'auto' : `${trigger.left}px`,
62
+ right: end ? `${viewport.width - trigger.right}px` : 'auto'
63
+ };
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rokkit/ui",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "Data driven UI components for Rokkit applications",
5
5
  "repository": {
6
6
  "type": "git",
@@ -45,15 +45,15 @@
45
45
  "dropdown"
46
46
  ],
47
47
  "dependencies": {
48
- "@rokkit/core": "1.7.0",
49
- "@rokkit/data": "1.7.0",
50
- "@rokkit/states": "1.7.0",
51
- "@rokkit/actions": "1.7.0",
48
+ "@rokkit/core": "1.8.0",
49
+ "@rokkit/data": "1.8.0",
50
+ "@rokkit/states": "1.8.0",
51
+ "@rokkit/actions": "1.8.0",
52
52
  "marked": "^15.0.0",
53
- "dompurify": "^3.4.13"
53
+ "dompurify": "^3.4.16"
54
54
  },
55
55
  "peerDependencies": {
56
- "shiki": "^3.23.0",
56
+ "shiki": "^3.23.0 || ^4",
57
57
  "svelte": "^5.0.0"
58
58
  },
59
59
  "devDependencies": {