@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.
- package/dist/components/CodeGroup.svelte +1 -1
- package/dist/components/List.svelte +34 -1
- package/dist/components/List.svelte.d.ts +1 -1
- package/dist/components/MultiSelect.svelte +8 -30
- package/dist/components/Select.svelte +27 -104
- package/dist/types/list.d.ts +9 -0
- package/dist/utils/dropdown.d.ts +59 -0
- package/dist/utils/dropdown.js +64 -0
- package/package.json +7 -7
|
@@ -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(
|
|
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
|
>
|
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
*
|
|
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 (
|
|
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
|
-
|
|
263
|
-
wrapper.
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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 (
|
|
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
|
|
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)}
|
package/dist/types/list.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
49
|
-
"@rokkit/data": "1.
|
|
50
|
-
"@rokkit/states": "1.
|
|
51
|
-
"@rokkit/actions": "1.
|
|
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.
|
|
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": {
|