@rokkit/ui 1.0.0-next.127 → 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.
Files changed (53) hide show
  1. package/package.json +6 -16
  2. package/src/components/BreadCrumbs.svelte +25 -17
  3. package/src/components/Button.svelte +11 -5
  4. package/src/components/Carousel.svelte +11 -6
  5. package/src/components/Code.svelte +6 -2
  6. package/src/components/FloatingAction.svelte +24 -21
  7. package/src/components/FloatingNavigation.svelte +36 -29
  8. package/src/components/Grid.svelte +128 -0
  9. package/src/components/ItemContent.svelte +21 -20
  10. package/src/components/LazyTree.svelte +165 -0
  11. package/src/components/List.svelte +147 -435
  12. package/src/components/Menu.svelte +195 -346
  13. package/src/components/MultiSelect.svelte +238 -390
  14. package/src/components/PaletteManager.svelte +15 -5
  15. package/src/components/Pill.svelte +19 -14
  16. package/src/components/Range.svelte +8 -3
  17. package/src/components/Rating.svelte +19 -9
  18. package/src/components/SearchFilter.svelte +11 -3
  19. package/src/components/Select.svelte +265 -454
  20. package/src/components/Stepper.svelte +9 -6
  21. package/src/components/Switch.svelte +11 -11
  22. package/src/components/Table.svelte +0 -1
  23. package/src/components/Tabs.svelte +96 -172
  24. package/src/components/Timeline.svelte +5 -5
  25. package/src/components/Toggle.svelte +55 -119
  26. package/src/components/Toolbar.svelte +24 -23
  27. package/src/components/Tree.svelte +115 -584
  28. package/src/components/UploadFileStatus.svelte +83 -0
  29. package/src/components/UploadProgress.svelte +131 -0
  30. package/src/components/UploadTarget.svelte +124 -0
  31. package/src/components/index.ts +5 -0
  32. package/src/index.ts +6 -1
  33. package/src/types/button.ts +3 -0
  34. package/src/types/code.ts +4 -4
  35. package/src/types/floating-action.ts +13 -8
  36. package/src/types/floating-navigation.ts +14 -2
  37. package/src/types/index.ts +5 -3
  38. package/src/types/list.ts +10 -6
  39. package/src/types/menu.ts +38 -138
  40. package/src/types/palette.ts +17 -0
  41. package/src/types/select.ts +33 -63
  42. package/src/types/switch.ts +9 -5
  43. package/src/types/table.ts +6 -6
  44. package/src/types/tabs.ts +13 -34
  45. package/src/types/timeline.ts +5 -3
  46. package/src/types/toggle.ts +15 -56
  47. package/src/types/toolbar.ts +1 -1
  48. package/src/types/tree.ts +9 -18
  49. package/src/types/upload-file-status.ts +45 -0
  50. package/src/types/upload-progress.ts +111 -0
  51. package/src/types/upload-target.ts +68 -0
  52. package/src/utils/upload.js +128 -0
  53. package/src/types/item-proxy.ts +0 -358
@@ -1,430 +1,102 @@
1
1
  <script lang="ts">
2
- import type {
3
- ListProps,
4
- ListItem,
5
- ListItemSnippet,
6
- ListItemHandlers,
7
- ListStateIcons
8
- } from '../types/list.js'
9
- import { getSnippet, defaultListStateIcons } from '../types/list.js'
10
- import { ItemProxy } from '../types/item-proxy.js'
2
+ /**
3
+ * List — ProxyItem + Wrapper + Navigator implementation.
4
+ *
5
+ * Architecture:
6
+ * Wrapper — owns focusedKey $state + flatView $derived
7
+ * Navigator — attaches DOM event handlers, calls wrapper[action](path)
8
+ * owns focus + scrollIntoView after every keyboard action
9
+ * flatView loop single flat {#each}, no nested groups in template
10
+ *
11
+ * Snippet customization:
12
+ * itemContent — replaces inner content of <a>/<button> for leaf items
13
+ * groupContent — replaces inner content of group header <button>
14
+ * [named] — per-item override via item.snippet = 'name'; falls back to itemContent
15
+ *
16
+ * Snippets receive (proxy) only — the <a>/<button> wrapper with data-path is
17
+ * always rendered by this component, so snippets never need to handle navigation.
18
+ *
19
+ * Data attributes on rendered elements:
20
+ * data-path — required by Navigator for click detection + scroll
21
+ * data-level — nesting depth (1=root); theme CSS uses for indentation
22
+ * data-accordion-trigger — tells Navigator to dispatch toggle (not select) on click
23
+ * data-list-item — theme hook for leaf items
24
+ * data-list-item-icon — icon span inside leaf items
25
+ * data-list-group — theme hook for group headers
26
+ * data-list-group-icon — icon span inside group headers
27
+ * data-active — highlights current value match
28
+ * data-disabled — disabled state
29
+ */
30
+ import type { ProxyItem } from '@rokkit/states'
31
+ import { Wrapper, ProxyTree, messages } from '@rokkit/states'
32
+ import { Navigator } from '@rokkit/actions'
33
+ import { DEFAULT_STATE_ICONS, resolveSnippet, ITEM_SNIPPET, GROUP_SNIPPET } from '@rokkit/core'
11
34
  import ItemContent from './ItemContent.svelte'
12
- import { NestedController } from '@rokkit/states'
13
- import { navigator } from '@rokkit/actions'
14
- import { untrack } from 'svelte'
35
+
36
+ interface ListIcons {
37
+ opened?: string
38
+ closed?: string
39
+ }
15
40
 
16
41
  let {
17
42
  items = [],
18
- fields: userFields,
43
+ fields = {},
19
44
  value,
20
45
  size = 'md',
21
46
  disabled = false,
22
47
  collapsible = false,
23
- multiselect = false,
24
- expanded = $bindable({}),
25
- selected = $bindable([]),
26
- active,
48
+ label = messages.current.list.label,
49
+ icons: userIcons = {} as ListIcons,
27
50
  onselect,
28
- onselectedchange,
29
- onexpandedchange,
30
51
  class: className = '',
31
- icons: userIcons,
32
- item: itemSnippet,
33
- groupLabel: groupLabelSnippet,
34
52
  ...snippets
35
- }: ListProps & { [key: string]: ListItemSnippet | unknown } = $props()
36
-
37
- // Merge icons with defaults
38
- const icons = $derived<ListStateIcons>({ ...defaultListStateIcons, ...userIcons })
39
-
40
- /**
41
- * Create an ItemProxy for the given item
42
- */
43
- function createProxy(item: ListItem): ItemProxy {
44
- return new ItemProxy(item, userFields)
45
- }
46
-
47
- // ─── NestedController for keyboard navigation ───────────────────
53
+ }: {
54
+ items?: unknown[]
55
+ fields?: Record<string, string>
56
+ value?: unknown
57
+ size?: string
58
+ disabled?: boolean
59
+ collapsible?: boolean
60
+ label?: string
61
+ icons?: ListIcons
62
+ onselect?: (value: unknown, proxy: ProxyItem) => void
63
+ class?: string
64
+ [key: string]: unknown
65
+ } = $props()
66
+
67
+ const icons = $derived({ ...DEFAULT_STATE_ICONS.accordion, ...userIcons })
68
+
69
+ // Single source of truth.
70
+ // Navigator calls wrapper[action](path) → focusedKey / proxy.expanded updates →
71
+ // flatView $derived re-computes → Svelte re-renders the changed nodes.
72
+ const proxyTree = $derived(new ProxyTree(items, fields))
73
+ const wrapper = $derived(new Wrapper(proxyTree, { onselect }))
48
74
 
49
- let controller = untrack(() => new NestedController(items, value, userFields, { multiselect }))
50
75
  let listRef = $state<HTMLElement | null>(null)
51
76
 
52
- /**
53
- * Get expanded state for a group key from the expanded prop
54
- * Default to expanded (true) when not explicitly set
55
- */
56
- function getExpandedState(groupKey: string): boolean {
57
- if (!collapsible) return true
58
- const externalKeys = Object.keys(expanded)
59
- if (externalKeys.length > 0) {
60
- return expanded[groupKey] !== false
61
- }
62
- return true // Default: expanded
63
- }
64
-
65
- // Sync expansion state: expanded prop → controller.expandedKeys
66
- function syncExpandedToController() {
67
- for (const [key, proxy] of controller.lookup.entries()) {
68
- if (!proxy.hasChildren) continue
69
- const groupProxy = createProxy(proxy.value)
70
- const groupKey = getGroupKey(groupProxy)
71
- const shouldExpand = getExpandedState(groupKey)
72
- if (shouldExpand) {
73
- controller.expandedKeys.add(key)
74
- } else {
75
- controller.expandedKeys.delete(key)
76
- }
77
- }
78
- }
79
-
80
- // Sync on init
81
- syncExpandedToController()
82
-
77
+ // Mount Navigator on the root element; destroy when component unmounts.
83
78
  $effect(() => {
84
- controller.update(items)
85
- // Re-sync expansion after items update
86
- syncExpandedToController()
79
+ if (!listRef) return
80
+ const dir = getComputedStyle(listRef).direction || 'ltr'
81
+ const nav = new Navigator(listRef, wrapper, { collapsible, dir })
82
+ return () => nav.destroy()
87
83
  })
88
84
 
89
- // Sync expanded prop changes controller
90
- $effect(() => {
91
- // Track both expanded and collapsible to re-sync when either changes
92
- void expanded
93
- void collapsible
94
- syncExpandedToController()
95
- })
85
+ // ─── Sync external valuefocused key ────────────────────────────────────
96
86
 
97
- // Derive expanded prop from controller.expandedKeys (pathKey → groupKey mapping)
98
- function deriveExpandedFromController(): Record<string, boolean> {
99
- const result: Record<string, boolean> = {}
100
- items.forEach((item, index) => {
101
- const proxy = createProxy(item)
102
- if (!proxy.hasChildren) return
103
- const pathKey = String(index)
104
- const groupKey = getGroupKey(proxy)
105
- result[groupKey] = controller.expandedKeys.has(pathKey)
106
- })
107
- return result
108
- }
109
-
110
- // Focus the element matching controller.focusedKey on navigator action events
111
87
  $effect(() => {
112
- if (!listRef) return
113
- const el = listRef
114
-
115
- function onAction(event: Event) {
116
- const detail = (event as CustomEvent).detail
117
-
118
- if (detail.name === 'move') {
119
- const key = controller.focusedKey
120
- if (key) {
121
- const target = el.querySelector(`[data-path="${key}"]`) as HTMLElement | null
122
- if (target && target !== document.activeElement) {
123
- target.focus()
124
- target.scrollIntoView({ block: 'nearest', inline: 'nearest' })
125
- }
126
- }
127
- }
128
-
129
- if (detail.name === 'select') {
130
- handleSelectAction()
131
- syncSelectedFromController()
132
- }
133
-
134
- if (detail.name === 'toggle') {
135
- // Controller already toggled expandedKeys. Derive the expanded prop.
136
- const newExpanded = deriveExpandedFromController()
137
- expanded = newExpanded
138
- onexpandedchange?.(newExpanded)
139
- }
140
- }
141
-
142
- el.addEventListener('action', onAction)
143
- return () => el.removeEventListener('action', onAction)
88
+ wrapper.moveToValue(value)
144
89
  })
145
90
 
146
- /**
147
- * Sync DOM focus to controller state.
148
- * When a user tabs into the list or clicks an item, the controller
149
- * needs to know which element is focused for arrow keys to work correctly.
150
- */
151
- function handleFocusIn(event: FocusEvent) {
152
- const target = event.target as HTMLElement
153
- if (!target) return
154
- const path = target.dataset.path
155
- if (path !== undefined) {
156
- controller.moveTo(path)
157
- }
158
- }
159
-
160
- /**
161
- * Handle the navigator's select action (Enter/Space on focused item, or click)
162
- */
163
- function handleSelectAction() {
164
- const key = controller.focusedKey
165
- if (!key) return
166
-
167
- const proxy = controller.lookup.get(key)
168
- if (!proxy) return
169
-
170
- // If it's a group, toggle expansion
171
- if (proxy.hasChildren) {
172
- controller.toggleExpansion(key)
173
- const newExpanded = deriveExpandedFromController()
174
- expanded = newExpanded
175
- onexpandedchange?.(newExpanded)
176
- return
177
- }
178
-
179
- // Otherwise fire onselect for button items
180
- const itemProxy = createProxy(proxy.value)
181
- if (!itemProxy.disabled && !disabled) {
182
- const href = itemProxy.get<string>('href')
183
- if (!href) {
184
- onselect?.(itemProxy.itemValue, proxy.value as ListItem)
185
- }
186
- }
187
- }
188
-
189
- /**
190
- * Handle keyboard events the navigator doesn't cover:
191
- * - Enter/Space on link items: let native <a> behavior through
192
- *
193
- * Fires before navigator's keydown handler.
194
- */
195
- function handleListKeyDown(event: KeyboardEvent) {
196
- if (event.key !== 'Enter' && event.key !== ' ') return
197
-
198
- const key = controller.focusedKey
199
- if (!key) return
200
-
201
- const proxy = controller.lookup.get(key)
202
- if (!proxy) return
203
-
204
- // Link items: stop propagation to prevent navigator's preventDefault
205
- const itemProxy = createProxy(proxy.value)
206
- const href = itemProxy.get<string>('href')
207
- if (href) {
208
- event.stopPropagation()
209
- }
210
- }
211
-
212
- // ─── Multi-selection helpers ────────────────────────────────────
213
-
214
- /**
215
- * Sync the selected bindable prop from controller.selected
216
- */
217
- function syncSelectedFromController() {
218
- if (!multiselect) return
219
- selected = [...controller.selected]
220
- onselectedchange?.(selected)
221
- }
222
-
223
- /**
224
- * Check if an item is in the current selection (for data-selected attribute)
225
- */
226
- function isItemSelected(pathKey: string): boolean {
227
- if (!multiselect) return false
228
- return controller.selectedKeys.has(pathKey)
229
- }
230
-
231
- // ─── Group helpers ──────────────────────────────────────────────
232
-
233
- /**
234
- * Get the key for a group (for expanded state tracking)
235
- */
236
- function getGroupKey(proxy: ItemProxy): string {
237
- const val = proxy.itemValue
238
- return typeof val === 'string' ? val : proxy.text
239
- }
240
-
241
- /**
242
- * Check if a group is expanded (reads from controller.expandedKeys)
243
- */
244
- function isGroupExpandedByKey(pathKey: string): boolean {
245
- if (!collapsible) return true
246
- return controller.expandedKeys.has(pathKey)
247
- }
248
-
249
- /**
250
- * Toggle group expansion via the controller
251
- */
252
- function toggleGroupByKey(pathKey: string) {
253
- if (!collapsible) return
254
- controller.toggleExpansion(pathKey)
255
- const newExpanded = deriveExpandedFromController()
256
- expanded = newExpanded
257
- onexpandedchange?.(newExpanded)
258
- }
259
-
260
- // ─── Unchanged helpers ──────────────────────────────────────────
261
-
262
- /**
263
- * Check if an item is currently active
264
- */
265
- function checkIsActive(proxy: ItemProxy): boolean {
266
- if (active !== undefined) {
267
- return proxy.itemValue === active
268
- }
269
- return value !== undefined && proxy.itemValue === value
270
- }
271
-
272
- /**
273
- * Handle item click (for button items)
274
- */
275
- function handleItemClick(proxy: ItemProxy) {
276
- if (proxy.disabled || disabled) return
277
- onselect?.(proxy.itemValue, proxy.original as ListItem)
278
- }
279
-
280
- /**
281
- * Create handlers object for custom snippets
282
- */
283
- function createHandlers(proxy: ItemProxy): ListItemHandlers {
284
- return {
285
- onclick: () => handleItemClick(proxy),
286
- onkeydown: () => {}
287
- }
288
- }
289
-
290
- /**
291
- * Resolve which snippet to use for an item
292
- */
293
- function resolveItemSnippet(proxy: ItemProxy): ListItemSnippet | null {
294
- const snippetName = proxy.snippetName
295
- if (snippetName) {
296
- const namedSnippet = getSnippet(snippets, snippetName)
297
- if (namedSnippet) {
298
- return namedSnippet as ListItemSnippet
299
- }
300
- }
301
- return itemSnippet ?? null
302
- }
303
-
304
- // Track option index for divider logic
305
- function shouldShowDivider(index: number, isGroup: boolean): boolean {
306
- return isGroup && index > 0
307
- }
308
-
309
- /**
310
- * Get the controller path key for a given item index and optional child index.
311
- * Maps to the same format as getKeyFromPath: "0", "0-0", "1-2", etc.
312
- */
313
- function getPathKey(itemIndex: number, childIndex?: number): string {
314
- if (childIndex !== undefined) return `${itemIndex}-${childIndex}`
315
- return String(itemIndex)
316
- }
317
91
  </script>
318
92
 
319
- {#snippet defaultItem(
320
- proxy: ItemProxy,
321
- _handlers: ListItemHandlers,
322
- active: boolean,
323
- listIndex: string,
324
- pathKey: string
325
- )}
326
- {@const href = proxy.get<string>('href')}
327
- {@const itemSelected = isItemSelected(pathKey)}
328
- {#if href}
329
- <a
330
- {href}
331
- data-list-item
332
- data-list-index={listIndex}
333
- data-path={pathKey}
334
- data-active={active || undefined}
335
- data-selected={itemSelected || undefined}
336
- data-disabled={proxy.disabled || undefined}
337
- aria-label={proxy.label}
338
- aria-current={active ? 'page' : undefined}
339
- aria-selected={multiselect ? itemSelected : undefined}
340
- >
341
- <ItemContent {proxy} />
342
- </a>
343
- {:else}
344
- <button
345
- type="button"
346
- data-list-item
347
- data-list-index={listIndex}
348
- data-path={pathKey}
349
- data-active={active || undefined}
350
- data-selected={itemSelected || undefined}
351
- data-disabled={proxy.disabled || undefined}
352
- disabled={proxy.disabled || disabled}
353
- aria-label={proxy.label}
354
- aria-pressed={active}
355
- aria-selected={multiselect ? itemSelected : undefined}
356
- >
357
- <ItemContent {proxy} />
358
- </button>
359
- {/if}
360
- {/snippet}
361
-
362
- {#snippet defaultGroupLabel(
363
- proxy: ItemProxy,
364
- _toggle: () => void,
365
- isExpanded: boolean,
366
- listIndex: string,
367
- pathKey: string
368
- )}
369
- <button
370
- type="button"
371
- data-list-group-label
372
- data-list-index={listIndex}
373
- data-path={pathKey}
374
- data-list-group-key={getGroupKey(proxy)}
375
- aria-expanded={isExpanded}
376
- disabled={!collapsible}
377
- >
378
- {#if proxy.icon}
379
- <span data-list-group-icon class={proxy.icon} aria-hidden="true"></span>
380
- {/if}
381
- <span data-list-group-text>{proxy.text}</span>
382
- {#if collapsible}
383
- <span data-list-group-arrow class={icons.opened} aria-hidden="true"></span>
384
- {/if}
385
- </button>
386
- {/snippet}
387
-
388
- {#snippet renderItem(proxy: ItemProxy, listIndex: string, pathKey: string)}
389
- {@const customSnippet = resolveItemSnippet(proxy)}
390
- {@const handlers = createHandlers(proxy)}
391
- {@const active = checkIsActive(proxy)}
392
- {@const itemSelected = isItemSelected(pathKey)}
393
- {#if customSnippet}
394
- <div
395
- data-list-item
396
- data-list-item-custom
397
- data-list-index={listIndex}
398
- data-path={pathKey}
399
- data-active={active || undefined}
400
- data-selected={itemSelected || undefined}
401
- data-disabled={proxy.disabled || undefined}
402
- aria-selected={multiselect ? itemSelected : undefined}
403
- >
404
- <svelte:boundary>
405
- {@render customSnippet(proxy.original as ListItem, proxy.fields, handlers, active)}
406
- {#snippet failed()}
407
- {@render defaultItem(proxy, handlers, active, listIndex, pathKey)}
408
- {/snippet}
409
- </svelte:boundary>
410
- </div>
411
- {:else}
412
- {@render defaultItem(proxy, handlers, active, listIndex, pathKey)}
413
- {/if}
414
- {/snippet}
415
-
416
- {#snippet renderGroupLabel(proxy: ItemProxy, listIndex: string, pathKey: string)}
417
- {@const toggle = () => toggleGroupByKey(pathKey)}
418
- {@const isExpanded = isGroupExpandedByKey(pathKey)}
419
- {#if groupLabelSnippet}
420
- <svelte:boundary>
421
- {@render groupLabelSnippet(proxy.original as ListItem, proxy.fields, toggle, isExpanded)}
422
- {#snippet failed()}
423
- {@render defaultGroupLabel(proxy, toggle, isExpanded, listIndex, pathKey)}
424
- {/snippet}
425
- </svelte:boundary>
426
- {:else}
427
- {@render defaultGroupLabel(proxy, toggle, isExpanded, listIndex, pathKey)}
93
+ {#snippet collapsibleIcon(proxy: ProxyItem)}
94
+ {#if collapsible}
95
+ <span
96
+ data-list-expand-icon
97
+ class={proxy.expanded ? icons.opened : icons.closed}
98
+ aria-hidden="true"
99
+ ></span>
428
100
  {/if}
429
101
  {/snippet}
430
102
 
@@ -435,42 +107,82 @@
435
107
  data-size={size}
436
108
  data-disabled={disabled || undefined}
437
109
  data-collapsible={collapsible || undefined}
438
- data-multiselect={multiselect || undefined}
439
110
  class={className || undefined}
440
- aria-label="List"
441
- aria-multiselectable={multiselect || undefined}
442
- onkeydown={handleListKeyDown}
443
- onfocusin={handleFocusIn}
444
- use:navigator={{ wrapper: controller, orientation: 'vertical', nested: collapsible, typeahead: true }}
111
+ aria-label={label}
445
112
  >
446
- {#each items as item, itemIndex (itemIndex)}
447
- {@const proxy = createProxy(item)}
448
- {@const listIndex = String(itemIndex)}
449
- {@const pathKey = getPathKey(itemIndex)}
450
-
451
- {#if proxy.hasChildren}
452
- <!-- Group with children -->
453
- {#if shouldShowDivider(itemIndex, true)}
454
- <div data-list-divider role="separator"></div>
455
- {/if}
456
-
457
- <div data-list-group data-list-group-collapsed={!isGroupExpandedByKey(pathKey) || undefined}>
458
- {@render renderGroupLabel(proxy, listIndex, pathKey)}
459
-
460
- {#if isGroupExpandedByKey(pathKey)}
461
- <div data-list-group-items>
462
- {#each proxy.children as child, childIndex (childIndex)}
463
- {@const childProxy = proxy.createChildProxy(child)}
464
- {@const childListIndex = `${itemIndex}-${childIndex}`}
465
- {@const childPathKey = getPathKey(itemIndex, childIndex)}
466
- {@render renderItem(childProxy, childListIndex, childPathKey)}
467
- {/each}
468
- </div>
113
+ {#each wrapper.flatView as node (node.key)}
114
+ {@const proxy = node.proxy}
115
+ {@const isActive = proxy.value === value}
116
+ {@const content = resolveSnippet(snippets as Record<string, unknown>, proxy, node.hasChildren ? GROUP_SNIPPET : ITEM_SNIPPET)}
117
+
118
+ {#if node.type === 'separator'}
119
+ <hr data-list-separator />
120
+ {:else if node.type === 'spacer'}
121
+ <div data-list-spacer></div>
122
+ {:else if node.hasChildren}
123
+ <!--
124
+ Group header data-accordion-trigger tells Navigator to dispatch
125
+ toggle() instead of select() when this element is clicked.
126
+ aria-expanded reflects the reactive proxy.expanded state.
127
+ -->
128
+ <button
129
+ type="button"
130
+ data-list-group
131
+ data-path={node.key}
132
+ data-accordion-trigger
133
+ data-level={node.level}
134
+ aria-expanded={proxy.expanded}
135
+ disabled={!collapsible}
136
+ >
137
+ {#if content}
138
+ {@render content(proxy)}
139
+ {:else}
140
+ <ItemContent {proxy} />
141
+ {/if}
142
+ {@render collapsibleIcon(proxy)}
143
+ </button>
144
+ {:else if proxy.get('href')}
145
+ <!--
146
+ Navigation link — native <a> handles click; Navigator updates state.
147
+ aria-current marks the active route for screen readers.
148
+ -->
149
+ <a
150
+ href={proxy.get('href')}
151
+ title={proxy.get('tooltip')}
152
+ data-list-item
153
+ data-path={node.key}
154
+ data-level={node.level}
155
+ data-active={isActive || undefined}
156
+ aria-current={isActive ? 'page' : undefined}
157
+ >
158
+ {#if content}
159
+ {@render content(proxy)}
160
+ {:else}
161
+ <ItemContent {proxy} />
469
162
  {/if}
470
- </div>
163
+ {@render collapsibleIcon(proxy)}
164
+ </a>
471
165
  {:else}
472
- <!-- Standalone item (no children) -->
473
- {@render renderItem(proxy, listIndex, pathKey)}
166
+ <!--
167
+ Button item — Navigator calls wrapper.select(path) on click/Enter/Space.
168
+ The wrapper fires the onselect callback for non-group items.
169
+ -->
170
+ <button
171
+ type="button"
172
+ title={proxy.get('tooltip')}
173
+ data-list-item
174
+ data-path={node.key}
175
+ data-level={node.level}
176
+ data-active={isActive || undefined}
177
+ data-disabled={proxy.disabled || undefined}
178
+ disabled={proxy.disabled || disabled}
179
+ >
180
+ {#if content}
181
+ {@render content(proxy)}
182
+ {:else}
183
+ <ItemContent {proxy} />
184
+ {/if}
185
+ </button>
474
186
  {/if}
475
187
  {/each}
476
188
  </nav>