@rokkit/ui 1.0.0-next.125 → 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 (154) hide show
  1. package/README.md +198 -101
  2. package/package.json +42 -34
  3. package/src/components/BreadCrumbs.svelte +90 -0
  4. package/src/components/Button.svelte +93 -0
  5. package/src/components/ButtonGroup.svelte +18 -0
  6. package/src/components/Card.svelte +61 -0
  7. package/src/components/Carousel.svelte +174 -0
  8. package/src/components/Code.svelte +189 -0
  9. package/src/components/Connector.svelte +46 -0
  10. package/src/components/FloatingAction.svelte +334 -0
  11. package/src/components/FloatingNavigation.svelte +235 -0
  12. package/src/components/Grid.svelte +128 -0
  13. package/src/components/ItemContent.svelte +25 -0
  14. package/src/components/LazyTree.svelte +165 -0
  15. package/src/components/List.svelte +188 -0
  16. package/src/components/Menu.svelte +270 -0
  17. package/src/components/MultiSelect.svelte +369 -0
  18. package/src/components/PaletteManager.svelte +364 -0
  19. package/src/components/Pill.svelte +83 -0
  20. package/src/components/ProgressBar.svelte +31 -0
  21. package/src/components/Range.svelte +330 -0
  22. package/src/components/Rating.svelte +101 -0
  23. package/src/components/Reveal.svelte +58 -0
  24. package/src/components/SearchFilter.svelte +88 -0
  25. package/src/components/Select.svelte +396 -0
  26. package/src/{Shine.svelte → components/Shine.svelte} +29 -21
  27. package/src/components/Stepper.svelte +172 -0
  28. package/src/components/Switch.svelte +75 -0
  29. package/src/components/Table.svelte +242 -0
  30. package/src/components/Tabs.svelte +192 -0
  31. package/src/components/Tilt.svelte +68 -0
  32. package/src/components/Timeline.svelte +61 -0
  33. package/src/components/Toggle.svelte +93 -0
  34. package/src/components/Toolbar.svelte +308 -0
  35. package/src/components/ToolbarGroup.svelte +17 -0
  36. package/src/components/Tree.svelte +144 -0
  37. package/src/components/UploadFileStatus.svelte +83 -0
  38. package/src/components/UploadProgress.svelte +131 -0
  39. package/src/components/UploadTarget.svelte +124 -0
  40. package/src/components/index.ts +38 -0
  41. package/src/index.ts +46 -0
  42. package/src/types/button.ts +86 -0
  43. package/src/types/code.ts +46 -0
  44. package/src/types/floating-action.ts +123 -0
  45. package/src/types/floating-navigation.ts +80 -0
  46. package/src/types/index.ts +55 -0
  47. package/src/types/list.ts +200 -0
  48. package/src/types/menu.ts +95 -0
  49. package/src/types/palette.ts +160 -0
  50. package/src/types/range.ts +51 -0
  51. package/src/types/search-filter.ts +67 -0
  52. package/src/types/select.ts +176 -0
  53. package/src/types/switch.ts +68 -0
  54. package/src/types/table.ts +210 -0
  55. package/src/types/tabs.ts +103 -0
  56. package/src/types/timeline.ts +53 -0
  57. package/src/types/toggle.ts +68 -0
  58. package/src/types/toolbar.ts +164 -0
  59. package/src/types/tree.ts +250 -0
  60. package/src/types/upload-file-status.ts +45 -0
  61. package/src/types/upload-progress.ts +111 -0
  62. package/src/types/upload-target.ts +68 -0
  63. package/src/utils/palette.ts +582 -0
  64. package/src/utils/shiki.ts +122 -0
  65. package/src/utils/upload.js +128 -0
  66. package/dist/constants.d.ts +0 -2
  67. package/dist/index.d.ts +0 -41
  68. package/dist/lib/fields.d.ts +0 -16
  69. package/dist/lib/form.d.ts +0 -95
  70. package/dist/lib/index.d.ts +0 -6
  71. package/dist/lib/layout.d.ts +0 -7
  72. package/dist/lib/nested.d.ts +0 -48
  73. package/dist/lib/schema.d.ts +0 -7
  74. package/dist/lib/select.d.ts +0 -8
  75. package/dist/lib/tree.d.ts +0 -9
  76. package/dist/tree/List.spec.svelte.d.ts +0 -1
  77. package/dist/tree/Node.spec.svelte.d.ts +0 -1
  78. package/dist/tree/Root.spec.svelte.d.ts +0 -1
  79. package/dist/types.d.ts +0 -5
  80. package/dist/wrappers/index.d.ts +0 -3
  81. package/src/Accordion.svelte +0 -118
  82. package/src/BreadCrumbs.svelte +0 -32
  83. package/src/Button.svelte +0 -57
  84. package/src/Calendar.svelte +0 -93
  85. package/src/Card.svelte +0 -45
  86. package/src/Carousel.svelte +0 -49
  87. package/src/CheckBox.svelte +0 -56
  88. package/src/Connector.svelte +0 -40
  89. package/src/DropDown.svelte +0 -68
  90. package/src/DropSearch.svelte +0 -37
  91. package/src/Fillable.svelte +0 -19
  92. package/src/GraphPaper.svelte +0 -43
  93. package/src/Icon.svelte +0 -81
  94. package/src/Item.svelte +0 -25
  95. package/src/Link.svelte +0 -21
  96. package/src/List.svelte +0 -89
  97. package/src/ListBody.svelte +0 -43
  98. package/src/Message.svelte +0 -11
  99. package/src/MultiSelect.svelte +0 -48
  100. package/src/NestedList.svelte +0 -78
  101. package/src/NestedPaginator.svelte +0 -63
  102. package/src/Node.svelte +0 -76
  103. package/src/Overlay.svelte +0 -21
  104. package/src/PageNavigator.svelte +0 -94
  105. package/src/PickOne.svelte +0 -60
  106. package/src/Pill.svelte +0 -41
  107. package/src/ProgressBar.svelte +0 -21
  108. package/src/ProgressDots.svelte +0 -53
  109. package/src/RadioGroup.svelte +0 -52
  110. package/src/Range.svelte +0 -45
  111. package/src/RangeMinMax.svelte +0 -124
  112. package/src/RangeSlider.svelte +0 -79
  113. package/src/RangeTick.svelte +0 -28
  114. package/src/Rating.svelte +0 -95
  115. package/src/ResponsiveGrid.svelte +0 -88
  116. package/src/Scrollable.svelte +0 -7
  117. package/src/Select.svelte +0 -114
  118. package/src/Separator.svelte +0 -1
  119. package/src/Slider.svelte +0 -14
  120. package/src/SlidingColumns.svelte +0 -50
  121. package/src/Stage.svelte +0 -41
  122. package/src/Stepper.svelte +0 -66
  123. package/src/Summary.svelte +0 -22
  124. package/src/Switch.svelte +0 -106
  125. package/src/TableCell.svelte +0 -51
  126. package/src/TableHeaderCell.svelte +0 -54
  127. package/src/Tabs.svelte +0 -176
  128. package/src/Tilt.svelte +0 -66
  129. package/src/Toggle.svelte +0 -58
  130. package/src/ToggleThemeMode.svelte +0 -23
  131. package/src/Tree.svelte +0 -80
  132. package/src/TreeTable.svelte +0 -171
  133. package/src/ValidationReport.svelte +0 -23
  134. package/src/constants.js +0 -4
  135. package/src/index.js +0 -48
  136. package/src/lib/fields.js +0 -118
  137. package/src/lib/form.js +0 -72
  138. package/src/lib/index.js +0 -13
  139. package/src/lib/layout.js +0 -63
  140. package/src/lib/nested.js +0 -192
  141. package/src/lib/schema.js +0 -32
  142. package/src/lib/select.js +0 -38
  143. package/src/lib/tree.js +0 -22
  144. package/src/tree/List.spec.svelte.js +0 -84
  145. package/src/tree/List.svelte +0 -78
  146. package/src/tree/Node.spec.svelte.js +0 -104
  147. package/src/tree/Node.svelte +0 -80
  148. package/src/tree/Root.spec.svelte.js +0 -63
  149. package/src/tree/Root.svelte +0 -81
  150. package/src/types.js +0 -9
  151. package/src/wrappers/Category.svelte +0 -27
  152. package/src/wrappers/Section.svelte +0 -16
  153. package/src/wrappers/Wrapper.svelte +0 -12
  154. package/src/wrappers/index.js +0 -3
@@ -0,0 +1,334 @@
1
+ <script lang="ts">
2
+ import type {
3
+ FloatingActionProps,
4
+ FloatingActionItem,
5
+ FloatingActionItemSnippet,
6
+ FloatingActionItemHandlers,
7
+ FloatingActionIcons
8
+ } from '../types/floating-action.js'
9
+ import { getSnippet } from '../types/floating-action.js'
10
+ import { ProxyItem } from '@rokkit/states'
11
+ import { DEFAULT_STATE_ICONS } from '@rokkit/core'
12
+
13
+ let {
14
+ items = [],
15
+ fields: userFields,
16
+ icons: userIcons = {} as FloatingActionIcons,
17
+ label = 'Actions',
18
+ size = 'md',
19
+ position = 'bottom-right',
20
+ expand = 'vertical',
21
+ itemAlign = 'center',
22
+ disabled = false,
23
+ open = $bindable(false),
24
+ backdrop = true,
25
+ contained = false,
26
+ onselect,
27
+ onopen,
28
+ onclose,
29
+ class: className = '',
30
+ item: itemSnippet,
31
+ ...snippets
32
+ }: FloatingActionProps & { [key: string]: FloatingActionItemSnippet | unknown } = $props()
33
+
34
+ const icons = $derived({ add: DEFAULT_STATE_ICONS.action.add, close: DEFAULT_STATE_ICONS.action.close, ...userIcons })
35
+
36
+ /**
37
+ * Create a ProxyItem for the given item
38
+ */
39
+ function createProxy(item: FloatingActionItem): ProxyItem {
40
+ return new ProxyItem(item, userFields)
41
+ }
42
+
43
+ let fabRef = $state<HTMLDivElement | null>(null)
44
+ let focusedIndex = $state(-1)
45
+
46
+ // Flatten items for keyboard navigation (excluding disabled)
47
+ const flatItems = $derived.by(() => {
48
+ return items
49
+ .map((item) => ({ proxy: createProxy(item), original: item }))
50
+ .filter((item) => !item.proxy.disabled)
51
+ })
52
+
53
+ /**
54
+ * Toggle the FAB open/closed
55
+ */
56
+ function toggle() {
57
+ if (disabled) return
58
+ if (open) {
59
+ close()
60
+ } else {
61
+ openMenu()
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Open the FAB menu
67
+ */
68
+ function openMenu() {
69
+ if (disabled || open) return
70
+ open = true
71
+ focusedIndex = 0
72
+ onopen?.()
73
+ }
74
+
75
+ /**
76
+ * Close the FAB menu
77
+ */
78
+ function close() {
79
+ if (!open) return
80
+ open = false
81
+ focusedIndex = -1
82
+ onclose?.()
83
+ }
84
+
85
+ /**
86
+ * Handle item selection
87
+ */
88
+ function handleItemClick(item: { proxy: ProxyItem; original: FloatingActionItem }) {
89
+ if (item.proxy.disabled) return
90
+ onselect?.(item.proxy.value, item.original)
91
+ close()
92
+ // Return focus to trigger
93
+ const trigger = fabRef?.querySelector('[data-fab-trigger]') as HTMLElement | undefined
94
+ trigger?.focus()
95
+ }
96
+
97
+ /**
98
+ * Focus an item by index
99
+ */
100
+ function focusItem(index: number) {
101
+ if (index < 0 || index >= flatItems.length) return
102
+ focusedIndex = index
103
+ const menu = fabRef?.querySelector('[data-fab-menu]')
104
+ if (menu) {
105
+ const menuItems = menu.querySelectorAll('[data-fab-item]:not([data-disabled])')
106
+ const menuItem = menuItems[index] as HTMLElement | undefined
107
+ menuItem?.focus()
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Handle keyboard navigation on trigger
113
+ */
114
+ function handleTriggerKeyDown(event: KeyboardEvent) {
115
+ if (event.key === 'Enter' || event.key === ' ') {
116
+ event.preventDefault()
117
+ toggle()
118
+ } else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
119
+ event.preventDefault()
120
+ openMenu()
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Handle keyboard navigation when menu is open
126
+ */
127
+ function handleKeyDown(event: KeyboardEvent) {
128
+ if (!open) return
129
+
130
+ switch (event.key) {
131
+ case 'Escape':
132
+ event.preventDefault()
133
+ close()
134
+ const trigger = fabRef?.querySelector('[data-fab-trigger]') as HTMLElement | undefined
135
+ trigger?.focus()
136
+ break
137
+ case 'ArrowDown':
138
+ event.preventDefault()
139
+ focusItem(focusedIndex < flatItems.length - 1 ? focusedIndex + 1 : 0)
140
+ break
141
+ case 'ArrowUp':
142
+ event.preventDefault()
143
+ focusItem(focusedIndex > 0 ? focusedIndex - 1 : flatItems.length - 1)
144
+ break
145
+ case 'Home':
146
+ event.preventDefault()
147
+ focusItem(0)
148
+ break
149
+ case 'End':
150
+ event.preventDefault()
151
+ focusItem(flatItems.length - 1)
152
+ break
153
+ case 'Enter':
154
+ case ' ':
155
+ event.preventDefault()
156
+ if (focusedIndex >= 0 && focusedIndex < flatItems.length) {
157
+ handleItemClick(flatItems[focusedIndex])
158
+ }
159
+ break
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Handle item-specific keyboard events
165
+ */
166
+ function handleItemKeyDown(
167
+ event: KeyboardEvent,
168
+ item: { proxy: ProxyItem; original: FloatingActionItem }
169
+ ) {
170
+ if (event.key === 'Enter' || event.key === ' ') {
171
+ event.preventDefault()
172
+ handleItemClick(item)
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Handle click outside to close
178
+ */
179
+ function handleClickOutside(event: MouseEvent) {
180
+ if (fabRef && !fabRef.contains(event.target as Node)) {
181
+ close()
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Handle backdrop click
187
+ */
188
+ function handleBackdropClick() {
189
+ close()
190
+ }
191
+
192
+ /**
193
+ * Create handlers object for custom snippets
194
+ */
195
+ function createHandlers(item: {
196
+ proxy: ProxyItem
197
+ original: FloatingActionItem
198
+ }): FloatingActionItemHandlers {
199
+ return {
200
+ onclick: () => handleItemClick(item),
201
+ onkeydown: (event: KeyboardEvent) => handleItemKeyDown(event, item)
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Resolve which snippet to use for an item
207
+ */
208
+ function resolveItemSnippet(proxy: ProxyItem): FloatingActionItemSnippet | null {
209
+ const snippetName = proxy.get('snippet')
210
+ if (snippetName) {
211
+ const namedSnippet = getSnippet(snippets, snippetName)
212
+ if (namedSnippet) {
213
+ return namedSnippet as FloatingActionItemSnippet
214
+ }
215
+ }
216
+ return itemSnippet ?? null
217
+ }
218
+
219
+ /**
220
+ * Calculate item animation delay for stagger effect
221
+ */
222
+ function getItemDelay(index: number): string {
223
+ return `${index * 50}ms`
224
+ }
225
+
226
+ // Set up click outside listener when open
227
+ $effect(() => {
228
+ if (open) {
229
+ document.addEventListener('click', handleClickOutside, true)
230
+ document.addEventListener('keydown', handleKeyDown)
231
+ // Focus first item after animation starts
232
+ requestAnimationFrame(() => {
233
+ if (flatItems.length > 0) {
234
+ focusItem(0)
235
+ }
236
+ })
237
+ }
238
+ return () => {
239
+ document.removeEventListener('click', handleClickOutside, true)
240
+ document.removeEventListener('keydown', handleKeyDown)
241
+ }
242
+ })
243
+ </script>
244
+
245
+ {#snippet defaultItem(
246
+ proxy: ProxyItem,
247
+ handlers: FloatingActionItemHandlers,
248
+ index: number,
249
+ total: number
250
+ )}
251
+ <button
252
+ type="button"
253
+ data-fab-item
254
+ data-fab-index={index}
255
+ data-disabled={proxy.disabled || undefined}
256
+ disabled={proxy.disabled || disabled}
257
+ aria-label={proxy.label}
258
+ style="--fab-index: {index}; --fab-total: {total}; --fab-delay: {getItemDelay(index)}"
259
+ onclick={handlers.onclick}
260
+ onkeydown={handlers.onkeydown}
261
+ >
262
+ {#if proxy.get('icon')}
263
+ <span data-fab-item-icon class={proxy.get('icon')} aria-hidden="true"></span>
264
+ {/if}
265
+ {#if proxy.label}
266
+ <span data-fab-item-label>{proxy.label}</span>
267
+ {/if}
268
+ </button>
269
+ {/snippet}
270
+
271
+ {#snippet renderItem(
272
+ item: { proxy: ProxyItem; original: FloatingActionItem },
273
+ index: number,
274
+ total: number
275
+ )}
276
+ {@const customSnippet = resolveItemSnippet(item.proxy)}
277
+ {@const handlers = createHandlers(item)}
278
+ {#if customSnippet}
279
+ <div
280
+ data-fab-item
281
+ data-fab-item-custom
282
+ data-fab-index={index}
283
+ data-disabled={item.proxy.disabled || undefined}
284
+ style="--fab-index: {index}; --fab-total: {total}; --fab-delay: {getItemDelay(index)}"
285
+ >
286
+ <svelte:boundary>
287
+ {@render customSnippet(item.original, item.proxy.fields, handlers)}
288
+ {#snippet failed()}
289
+ {@render defaultItem(item.proxy, handlers, index, total)}
290
+ {/snippet}
291
+ </svelte:boundary>
292
+ </div>
293
+ {:else}
294
+ {@render defaultItem(item.proxy, handlers, index, total)}
295
+ {/if}
296
+ {/snippet}
297
+
298
+ <div
299
+ bind:this={fabRef}
300
+ data-fab
301
+ data-open={open || undefined}
302
+ data-size={size}
303
+ data-position={position}
304
+ data-expand={expand}
305
+ data-item-align={itemAlign}
306
+ data-disabled={disabled || undefined}
307
+ data-contained={contained || undefined}
308
+ class={className || undefined}
309
+ >
310
+ {#if backdrop && open}
311
+ <div data-fab-backdrop role="presentation" onclick={handleBackdropClick}></div>
312
+ {/if}
313
+
314
+ {#if open}
315
+ <div data-fab-menu role="menu" aria-label={label}>
316
+ {#each flatItems as item, index (index)}
317
+ {@render renderItem(item, index, flatItems.length)}
318
+ {/each}
319
+ </div>
320
+ {/if}
321
+
322
+ <button
323
+ type="button"
324
+ data-fab-trigger
325
+ {disabled}
326
+ aria-label={label}
327
+ aria-haspopup="menu"
328
+ aria-expanded={open}
329
+ onclick={toggle}
330
+ onkeydown={handleTriggerKeyDown}
331
+ >
332
+ <span data-fab-icon class={open ? icons.close : icons.add} aria-hidden="true"></span>
333
+ </button>
334
+ </div>
@@ -0,0 +1,235 @@
1
+ <script lang="ts">
2
+ import type { FloatingNavigationProps, FloatingNavigationIcons } from '../types/floating-navigation.js'
3
+ import { ProxyItem, messages } from '@rokkit/states'
4
+ import { DEFAULT_STATE_ICONS } from '@rokkit/core'
5
+
6
+ let {
7
+ items = [],
8
+ fields: userFields,
9
+ icons: userIcons = {} as FloatingNavigationIcons,
10
+ value = $bindable(),
11
+ position = 'right',
12
+ pinned = $bindable(false),
13
+ observe = true,
14
+ observerOptions = { rootMargin: '-20% 0px -70% 0px', threshold: 0 },
15
+ size = 'md',
16
+ label = messages.current.floatingNav.label,
17
+ labels: userLabels = {},
18
+ onselect,
19
+ onpinchange,
20
+ item: itemSnippet,
21
+ class: className = ''
22
+ }: FloatingNavigationProps & { labels?: Record<string, string> } = $props()
23
+
24
+ const labels = $derived({ ...messages.current.floatingNav, ...userLabels })
25
+
26
+ const icons = $derived({ pin: DEFAULT_STATE_ICONS.action.pin, unpin: DEFAULT_STATE_ICONS.action.unpin, ...userIcons })
27
+
28
+ let navRef = $state<HTMLElement | null>(null)
29
+ let expanded = $state(false)
30
+ let focusedIndex = $state(-1)
31
+
32
+ const isVertical = $derived(position === 'left' || position === 'right')
33
+
34
+ const itemProxies = $derived(
35
+ items.map((item) => ({
36
+ proxy: new ProxyItem(item, userFields),
37
+ original: item
38
+ }))
39
+ )
40
+
41
+ const activeIndex = $derived(
42
+ itemProxies.findIndex((item) => item.proxy.value === value)
43
+ )
44
+
45
+ function togglePin() {
46
+ pinned = !pinned
47
+ if (!pinned) expanded = false
48
+ onpinchange?.(pinned)
49
+ }
50
+
51
+ function handleMouseEnter() {
52
+ if (!pinned) expanded = true
53
+ }
54
+
55
+ function handleMouseLeave() {
56
+ if (!pinned) expanded = false
57
+ }
58
+
59
+ function handleItemClick(item: { proxy: ProxyItem; original: Record<string, unknown> }) {
60
+ value = item.proxy.value
61
+ onselect?.(item.proxy.value, item.original)
62
+
63
+ // Smooth scroll to target section
64
+ const href = item.proxy.get('href') !== undefined ? String(item.original[userFields?.href ?? 'href'] ?? '') : ''
65
+ const targetId = href.startsWith('#') ? href.slice(1) : String(item.proxy.value)
66
+ const el = document.getElementById(targetId)
67
+ el?.scrollIntoView({ behavior: 'smooth' })
68
+ }
69
+
70
+ function handleKeyDown(event: KeyboardEvent) {
71
+ const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight'
72
+ const prevKey = isVertical ? 'ArrowUp' : 'ArrowLeft'
73
+
74
+ switch (event.key) {
75
+ case nextKey:
76
+ event.preventDefault()
77
+ focusItem(focusedIndex < itemProxies.length - 1 ? focusedIndex + 1 : 0)
78
+ break
79
+ case prevKey:
80
+ event.preventDefault()
81
+ focusItem(focusedIndex > 0 ? focusedIndex - 1 : itemProxies.length - 1)
82
+ break
83
+ case 'Home':
84
+ event.preventDefault()
85
+ focusItem(0)
86
+ break
87
+ case 'End':
88
+ event.preventDefault()
89
+ focusItem(itemProxies.length - 1)
90
+ break
91
+ case 'Enter':
92
+ case ' ':
93
+ event.preventDefault()
94
+ if (focusedIndex >= 0 && focusedIndex < itemProxies.length) {
95
+ handleItemClick(itemProxies[focusedIndex])
96
+ }
97
+ break
98
+ case 'Escape':
99
+ if (!pinned) {
100
+ event.preventDefault()
101
+ expanded = false
102
+ }
103
+ break
104
+ }
105
+ }
106
+
107
+ function focusItem(index: number) {
108
+ if (index < 0 || index >= itemProxies.length) return
109
+ focusedIndex = index
110
+ const itemsContainer = navRef?.querySelector('[data-floating-nav-items]')
111
+ if (itemsContainer) {
112
+ const navItems = itemsContainer.querySelectorAll('[data-floating-nav-item]')
113
+ const item = navItems[index] as HTMLElement | undefined
114
+ item?.focus()
115
+ }
116
+ }
117
+
118
+ // IntersectionObserver for active section tracking
119
+ $effect(() => {
120
+ if (!observe || itemProxies.length === 0) return
121
+
122
+ const observer = new IntersectionObserver((entries) => {
123
+ for (const entry of entries) {
124
+ if (entry.isIntersecting) {
125
+ const match = itemProxies.find((item) => {
126
+ const href = item.proxy.get('href') !== undefined
127
+ ? String(item.original[userFields?.href ?? 'href'] ?? '')
128
+ : ''
129
+ const targetId = href.startsWith('#') ? href.slice(1) : String(item.proxy.value)
130
+ return targetId === entry.target.id
131
+ })
132
+ if (match) {
133
+ value = match.proxy.value
134
+ }
135
+ }
136
+ }
137
+ }, observerOptions)
138
+
139
+ for (const item of itemProxies) {
140
+ const href = item.proxy.get('href') !== undefined
141
+ ? String(item.original[userFields?.href ?? 'href'] ?? '')
142
+ : ''
143
+ const targetId = href.startsWith('#') ? href.slice(1) : String(item.proxy.value)
144
+ const el = document.getElementById(targetId)
145
+ if (el) observer.observe(el)
146
+ }
147
+
148
+ return () => observer.disconnect()
149
+ })
150
+ </script>
151
+
152
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
153
+ <nav
154
+ bind:this={navRef}
155
+ data-floating-nav
156
+ data-position={position}
157
+ data-expanded={expanded || pinned || undefined}
158
+ data-pinned={pinned || undefined}
159
+ data-size={size}
160
+ aria-label={label}
161
+ class={className || undefined}
162
+ onmouseenter={handleMouseEnter}
163
+ onmouseleave={handleMouseLeave}
164
+ onkeydown={handleKeyDown}
165
+ >
166
+ <div data-floating-nav-header>
167
+ {#if expanded || pinned}
168
+ <span data-floating-nav-title>{label}</span>
169
+ {/if}
170
+ <button
171
+ type="button"
172
+ data-floating-nav-pin
173
+ aria-pressed={pinned}
174
+ aria-label={pinned ? labels.unpin : labels.pin}
175
+ onclick={togglePin}
176
+ >
177
+ <span data-floating-nav-pin-icon class={pinned ? icons.unpin : icons.pin} aria-hidden="true"></span>
178
+ </button>
179
+ </div>
180
+
181
+ <div data-floating-nav-items>
182
+ {#each itemProxies as item, index (item.proxy.value ?? index)}
183
+ {@const isActive = item.proxy.value === value}
184
+ {@const isLink = item.proxy.get('href') !== undefined}
185
+ {#if itemSnippet}
186
+ {@render itemSnippet(item.original, {
187
+ text: item.proxy.label,
188
+ icon: item.proxy.get('icon'),
189
+ active: isActive
190
+ })}
191
+ {:else if isLink}
192
+ <a
193
+ data-floating-nav-item
194
+ data-active={isActive || undefined}
195
+ href={String(item.original[userFields?.href ?? 'href'] ?? '')}
196
+ aria-current={isActive ? 'true' : undefined}
197
+ tabindex={index === 0 ? 0 : -1}
198
+ style="--fn-index: {index}; --fn-total: {itemProxies.length}"
199
+ onclick={(e) => {
200
+ e.preventDefault()
201
+ handleItemClick(item)
202
+ }}
203
+ >
204
+ {#if item.proxy.get('icon')}
205
+ <span data-floating-nav-icon class={item.proxy.get('icon')} aria-hidden="true"></span>
206
+ {/if}
207
+ <span data-floating-nav-label>{item.proxy.label}</span>
208
+ </a>
209
+ {:else}
210
+ <button
211
+ type="button"
212
+ data-floating-nav-item
213
+ data-active={isActive || undefined}
214
+ aria-current={isActive ? 'true' : undefined}
215
+ tabindex={index === 0 ? 0 : -1}
216
+ style="--fn-index: {index}; --fn-total: {itemProxies.length}"
217
+ onclick={() => handleItemClick(item)}
218
+ >
219
+ {#if item.proxy.get('icon')}
220
+ <span data-floating-nav-icon class={item.proxy.get('icon')} aria-hidden="true"></span>
221
+ {/if}
222
+ <span data-floating-nav-label>{item.proxy.label}</span>
223
+ </button>
224
+ {/if}
225
+ {/each}
226
+
227
+ {#if activeIndex >= 0}
228
+ <span
229
+ data-floating-nav-indicator
230
+ style="--fn-active-index: {activeIndex}"
231
+ aria-hidden="true"
232
+ ></span>
233
+ {/if}
234
+ </div>
235
+ </nav>
@@ -0,0 +1,128 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Grid — Responsive tile grid with Wrapper + Navigator horizontal navigation.
4
+ *
5
+ * Architecture:
6
+ * Wrapper — owns focusedKey $state + flatView $derived
7
+ * Navigator — attaches DOM event handlers, calls wrapper[action](path)
8
+ * horizontal orientation (ArrowLeft/ArrowRight navigation)
9
+ * flatView loop — single flat {#each} rendering items as grid tiles
10
+ *
11
+ * Snippet customization:
12
+ * itemContent — replaces inner content of <button> for each tile
13
+ * [named] — per-item override via item.snippet = 'name'; falls back to itemContent
14
+ *
15
+ * Snippets receive (proxy) only — the <button> wrapper with data-path is
16
+ * always rendered by this component, so snippets never need to handle navigation.
17
+ *
18
+ * Data attributes on rendered elements:
19
+ * data-grid — root container
20
+ * data-grid-item — each tile button
21
+ * data-path — required by Navigator for click detection + scroll
22
+ * data-active — highlights current value match
23
+ * data-disabled — disabled state
24
+ * data-size — size variant
25
+ *
26
+ * CSS custom properties:
27
+ * --grid-min-size — minimum tile width for auto-fill
28
+ * --grid-gap — grid gap
29
+ */
30
+ import type { ProxyItem } from '@rokkit/states'
31
+ import { Wrapper, ProxyTree, messages } from '@rokkit/states'
32
+ import { Navigator } from '@rokkit/actions'
33
+ import { resolveSnippet, ITEM_SNIPPET } from '@rokkit/core'
34
+ import ItemContent from './ItemContent.svelte'
35
+
36
+ let {
37
+ items = [],
38
+ fields = {},
39
+ value = $bindable(),
40
+ size = 'md',
41
+ disabled = false,
42
+ minSize = '120px',
43
+ gap = '1rem',
44
+ label = messages.current.grid.label,
45
+ onselect,
46
+ class: className = '',
47
+ ...snippets
48
+ }: {
49
+ items?: unknown[]
50
+ fields?: Record<string, string>
51
+ value?: unknown
52
+ size?: string
53
+ disabled?: boolean
54
+ minSize?: string
55
+ gap?: string
56
+ label?: string
57
+ onselect?: (value: unknown, proxy: ProxyItem) => void
58
+ class?: string
59
+ [key: string]: unknown
60
+ } = $props()
61
+
62
+ // ─── Wrapper ──────────────────────────────────────────────────────────────
63
+
64
+ const proxyTree = $derived(new ProxyTree(items, fields))
65
+ const wrapper = $derived(new Wrapper(proxyTree, { onselect: handleSelect }))
66
+
67
+ // ─── Navigator (horizontal) ───────────────────────────────────────────────
68
+
69
+ let gridRef = $state<HTMLElement | null>(null)
70
+
71
+ $effect(() => {
72
+ if (!gridRef || disabled) return
73
+ const dir = getComputedStyle(gridRef).direction || 'ltr'
74
+ const nav = new Navigator(gridRef, wrapper, { orientation: 'horizontal', dir })
75
+ return () => nav.destroy()
76
+ })
77
+
78
+ // ─── Sync external value → focused key ────────────────────────────────────
79
+
80
+ $effect(() => {
81
+ wrapper.moveToValue(value)
82
+ })
83
+
84
+ // ─── Selection handler ─────────────────────────────────────────────────────
85
+
86
+ function handleSelect(extractedValue: unknown, proxy: ProxyItem) {
87
+ if (proxy.disabled || disabled) return
88
+ value = extractedValue
89
+ onselect?.(extractedValue, proxy)
90
+ }
91
+ </script>
92
+
93
+ <div
94
+ bind:this={gridRef}
95
+ data-grid
96
+ data-grid-min-size={minSize}
97
+ data-size={size}
98
+ data-disabled={disabled || undefined}
99
+ class={className || undefined}
100
+ role="grid"
101
+ aria-label={label}
102
+ aria-disabled={disabled || undefined}
103
+ style:--grid-min-size={minSize}
104
+ style:--grid-gap={gap}
105
+ >
106
+ {#each wrapper.flatView as node (node.key)}
107
+ {@const proxy = node.proxy}
108
+ {@const sel = proxy.value === value}
109
+ {@const content = resolveSnippet(snippets as Record<string, unknown>, proxy, ITEM_SNIPPET)}
110
+ <button
111
+ type="button"
112
+ data-grid-item
113
+ data-path={node.key}
114
+ data-active={sel || undefined}
115
+ data-disabled={proxy.disabled || undefined}
116
+ role="gridcell"
117
+ aria-selected={sel}
118
+ aria-label={proxy.label}
119
+ disabled={proxy.disabled || disabled}
120
+ >
121
+ {#if content}
122
+ {@render content(proxy)}
123
+ {:else}
124
+ <ItemContent {proxy} />
125
+ {/if}
126
+ </button>
127
+ {/each}
128
+ </div>