@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.
- package/README.md +198 -101
- package/package.json +42 -34
- package/src/components/BreadCrumbs.svelte +90 -0
- package/src/components/Button.svelte +93 -0
- package/src/components/ButtonGroup.svelte +18 -0
- package/src/components/Card.svelte +61 -0
- package/src/components/Carousel.svelte +174 -0
- package/src/components/Code.svelte +189 -0
- package/src/components/Connector.svelte +46 -0
- package/src/components/FloatingAction.svelte +334 -0
- package/src/components/FloatingNavigation.svelte +235 -0
- package/src/components/Grid.svelte +128 -0
- package/src/components/ItemContent.svelte +25 -0
- package/src/components/LazyTree.svelte +165 -0
- package/src/components/List.svelte +188 -0
- package/src/components/Menu.svelte +270 -0
- package/src/components/MultiSelect.svelte +369 -0
- package/src/components/PaletteManager.svelte +364 -0
- package/src/components/Pill.svelte +83 -0
- package/src/components/ProgressBar.svelte +31 -0
- package/src/components/Range.svelte +330 -0
- package/src/components/Rating.svelte +101 -0
- package/src/components/Reveal.svelte +58 -0
- package/src/components/SearchFilter.svelte +88 -0
- package/src/components/Select.svelte +396 -0
- package/src/{Shine.svelte → components/Shine.svelte} +29 -21
- package/src/components/Stepper.svelte +172 -0
- package/src/components/Switch.svelte +75 -0
- package/src/components/Table.svelte +242 -0
- package/src/components/Tabs.svelte +192 -0
- package/src/components/Tilt.svelte +68 -0
- package/src/components/Timeline.svelte +61 -0
- package/src/components/Toggle.svelte +93 -0
- package/src/components/Toolbar.svelte +308 -0
- package/src/components/ToolbarGroup.svelte +17 -0
- package/src/components/Tree.svelte +144 -0
- package/src/components/UploadFileStatus.svelte +83 -0
- package/src/components/UploadProgress.svelte +131 -0
- package/src/components/UploadTarget.svelte +124 -0
- package/src/components/index.ts +38 -0
- package/src/index.ts +46 -0
- package/src/types/button.ts +86 -0
- package/src/types/code.ts +46 -0
- package/src/types/floating-action.ts +123 -0
- package/src/types/floating-navigation.ts +80 -0
- package/src/types/index.ts +55 -0
- package/src/types/list.ts +200 -0
- package/src/types/menu.ts +95 -0
- package/src/types/palette.ts +160 -0
- package/src/types/range.ts +51 -0
- package/src/types/search-filter.ts +67 -0
- package/src/types/select.ts +176 -0
- package/src/types/switch.ts +68 -0
- package/src/types/table.ts +210 -0
- package/src/types/tabs.ts +103 -0
- package/src/types/timeline.ts +53 -0
- package/src/types/toggle.ts +68 -0
- package/src/types/toolbar.ts +164 -0
- package/src/types/tree.ts +250 -0
- package/src/types/upload-file-status.ts +45 -0
- package/src/types/upload-progress.ts +111 -0
- package/src/types/upload-target.ts +68 -0
- package/src/utils/palette.ts +582 -0
- package/src/utils/shiki.ts +122 -0
- package/src/utils/upload.js +128 -0
- package/dist/constants.d.ts +0 -2
- package/dist/index.d.ts +0 -41
- package/dist/lib/fields.d.ts +0 -16
- package/dist/lib/form.d.ts +0 -95
- package/dist/lib/index.d.ts +0 -6
- package/dist/lib/layout.d.ts +0 -7
- package/dist/lib/nested.d.ts +0 -48
- package/dist/lib/schema.d.ts +0 -7
- package/dist/lib/select.d.ts +0 -8
- package/dist/lib/tree.d.ts +0 -9
- package/dist/tree/List.spec.svelte.d.ts +0 -1
- package/dist/tree/Node.spec.svelte.d.ts +0 -1
- package/dist/tree/Root.spec.svelte.d.ts +0 -1
- package/dist/types.d.ts +0 -5
- package/dist/wrappers/index.d.ts +0 -3
- package/src/Accordion.svelte +0 -118
- package/src/BreadCrumbs.svelte +0 -32
- package/src/Button.svelte +0 -57
- package/src/Calendar.svelte +0 -93
- package/src/Card.svelte +0 -45
- package/src/Carousel.svelte +0 -49
- package/src/CheckBox.svelte +0 -56
- package/src/Connector.svelte +0 -40
- package/src/DropDown.svelte +0 -68
- package/src/DropSearch.svelte +0 -37
- package/src/Fillable.svelte +0 -19
- package/src/GraphPaper.svelte +0 -43
- package/src/Icon.svelte +0 -81
- package/src/Item.svelte +0 -25
- package/src/Link.svelte +0 -21
- package/src/List.svelte +0 -89
- package/src/ListBody.svelte +0 -43
- package/src/Message.svelte +0 -11
- package/src/MultiSelect.svelte +0 -48
- package/src/NestedList.svelte +0 -78
- package/src/NestedPaginator.svelte +0 -63
- package/src/Node.svelte +0 -76
- package/src/Overlay.svelte +0 -21
- package/src/PageNavigator.svelte +0 -94
- package/src/PickOne.svelte +0 -60
- package/src/Pill.svelte +0 -41
- package/src/ProgressBar.svelte +0 -21
- package/src/ProgressDots.svelte +0 -53
- package/src/RadioGroup.svelte +0 -52
- package/src/Range.svelte +0 -45
- package/src/RangeMinMax.svelte +0 -124
- package/src/RangeSlider.svelte +0 -79
- package/src/RangeTick.svelte +0 -28
- package/src/Rating.svelte +0 -95
- package/src/ResponsiveGrid.svelte +0 -88
- package/src/Scrollable.svelte +0 -7
- package/src/Select.svelte +0 -114
- package/src/Separator.svelte +0 -1
- package/src/Slider.svelte +0 -14
- package/src/SlidingColumns.svelte +0 -50
- package/src/Stage.svelte +0 -41
- package/src/Stepper.svelte +0 -66
- package/src/Summary.svelte +0 -22
- package/src/Switch.svelte +0 -106
- package/src/TableCell.svelte +0 -51
- package/src/TableHeaderCell.svelte +0 -54
- package/src/Tabs.svelte +0 -176
- package/src/Tilt.svelte +0 -66
- package/src/Toggle.svelte +0 -58
- package/src/ToggleThemeMode.svelte +0 -23
- package/src/Tree.svelte +0 -80
- package/src/TreeTable.svelte +0 -171
- package/src/ValidationReport.svelte +0 -23
- package/src/constants.js +0 -4
- package/src/index.js +0 -48
- package/src/lib/fields.js +0 -118
- package/src/lib/form.js +0 -72
- package/src/lib/index.js +0 -13
- package/src/lib/layout.js +0 -63
- package/src/lib/nested.js +0 -192
- package/src/lib/schema.js +0 -32
- package/src/lib/select.js +0 -38
- package/src/lib/tree.js +0 -22
- package/src/tree/List.spec.svelte.js +0 -84
- package/src/tree/List.svelte +0 -78
- package/src/tree/Node.spec.svelte.js +0 -104
- package/src/tree/Node.svelte +0 -80
- package/src/tree/Root.spec.svelte.js +0 -63
- package/src/tree/Root.svelte +0 -81
- package/src/types.js +0 -9
- package/src/wrappers/Category.svelte +0 -27
- package/src/wrappers/Section.svelte +0 -16
- package/src/wrappers/Wrapper.svelte +0 -12
- 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>
|