@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,396 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Select — Trigger + dropdown with List-style flatView content.
4
+ *
5
+ * Architecture:
6
+ * Trigger — manages open/close (click, Enter, Escape, click-outside)
7
+ * Wrapper — owns focusedKey $state + flatView $derived
8
+ * Navigator — attaches DOM event handlers on dropdown
9
+ * flatView loop — single flat {#each}, groups rendered as non-interactive labels
10
+ *
11
+ * Groups are pre-processed with expanded:true (always show children) and
12
+ * disabled:true (excluded from keyboard navigation). Group labels have no
13
+ * data-path so Navigator ignores them entirely.
14
+ *
15
+ * Data attributes:
16
+ * data-select — root container
17
+ * data-select-trigger — trigger button
18
+ * data-select-value — selected value display area
19
+ * data-select-value-text — selected item text
20
+ * data-select-value-icon — selected item icon
21
+ * data-select-placeholder — placeholder text
22
+ * data-select-arrow — dropdown arrow icon
23
+ * data-select-dropdown — dropdown container
24
+ * data-select-filter — filter input wrapper
25
+ * data-select-filter-input — filter text input
26
+ * data-select-option — leaf option items
27
+ * data-select-group-label — group header label (non-interactive)
28
+ * data-select-group-icon — icon inside group label
29
+ * data-select-divider — divider between groups
30
+ * data-select-check — check icon on selected item
31
+ * data-select-empty — no results message
32
+ * data-path — required by Navigator
33
+ * data-selected — selected state
34
+ * data-disabled — disabled state
35
+ * data-open — dropdown is open
36
+ * data-size — size variant
37
+ * data-align — dropdown alignment
38
+ * data-direction — dropdown direction
39
+ */
40
+ // @ts-nocheck
41
+ import type { ProxyItem } from '@rokkit/states'
42
+ import { Wrapper, ProxyTree } from '@rokkit/states'
43
+ import { Navigator, Trigger } from '@rokkit/actions'
44
+ import { DEFAULT_STATE_ICONS, resolveSnippet, ITEM_SNIPPET, GROUP_SNIPPET } from '@rokkit/core'
45
+ import ItemContent from './ItemContent.svelte'
46
+
47
+ interface SelectIcons {
48
+ opened?: string
49
+ closed?: string
50
+ checked?: string
51
+ }
52
+
53
+ let {
54
+ items = [],
55
+ fields = {},
56
+ value = $bindable(),
57
+ selected = $bindable<unknown>(null),
58
+ placeholder = 'Select...',
59
+ size = 'md',
60
+ disabled = false,
61
+ filterable = false,
62
+ filterPlaceholder = 'Search...',
63
+ align = 'start',
64
+ direction = 'down',
65
+ icons: userIcons = {} as SelectIcons,
66
+ onchange,
67
+ class: className = '',
68
+ ...snippets
69
+ }: {
70
+ items?: unknown[]
71
+ fields?: Record<string, string>
72
+ value?: unknown
73
+ selected?: unknown
74
+ placeholder?: string
75
+ size?: string
76
+ disabled?: boolean
77
+ filterable?: boolean
78
+ filterPlaceholder?: string
79
+ align?: 'start' | 'end'
80
+ direction?: 'up' | 'down'
81
+ icons?: SelectIcons
82
+ onchange?: (value: unknown, item: unknown) => void
83
+ class?: string
84
+ [key: string]: unknown
85
+ } = $props()
86
+
87
+ const icons = $derived({ ...DEFAULT_STATE_ICONS.selector, ...DEFAULT_STATE_ICONS.checkbox, ...userIcons })
88
+
89
+ // ─── Dropdown state ───────────────────────────────────────────────────────
90
+
91
+ let isOpen = $state(false)
92
+ let selectRef = $state<HTMLElement | null>(null)
93
+ let triggerRef = $state<HTMLElement | null>(null)
94
+ let dropdownRef = $state<HTMLElement | null>(null)
95
+
96
+ // ─── Filter ───────────────────────────────────────────────────────────────
97
+
98
+ let filterQuery = $state('')
99
+ let filterInputRef = $state<HTMLInputElement | null>(null)
100
+
101
+ const textField = $derived(fields?.text || 'text')
102
+ const childrenField = $derived(fields?.children || 'children')
103
+
104
+ const filteredItems = $derived.by(() => {
105
+ if (!filterable || !filterQuery) return items
106
+ const query = filterQuery.toLowerCase()
107
+ return items
108
+ .map((item) => {
109
+ const children = item[childrenField]
110
+ if (Array.isArray(children) && children.length > 0) {
111
+ const matching = children.filter((child) =>
112
+ String(child[textField] ?? '').toLowerCase().includes(query)
113
+ )
114
+ return matching.length > 0 ? { ...item, [childrenField]: matching } : null
115
+ }
116
+ return String(item[textField] ?? '').toLowerCase().includes(query) ? item : null
117
+ })
118
+ .filter(Boolean)
119
+ })
120
+
121
+ // Pre-process: force groups expanded + disabled (non-navigable labels)
122
+ const processedItems = $derived(
123
+ filteredItems.map((item) => {
124
+ const children = item[childrenField]
125
+ if (Array.isArray(children) && children.length > 0) {
126
+ return { ...item, expanded: true, disabled: true }
127
+ }
128
+ return item
129
+ })
130
+ )
131
+
132
+ // ─── Wrapper ──────────────────────────────────────────────────────────────
133
+
134
+ function handleSelect(extractedValue: unknown, proxy: ProxyItem) {
135
+ if (proxy.disabled) return
136
+ value = extractedValue
137
+ selected = proxy.original
138
+ onchange?.(extractedValue, proxy.original)
139
+ isOpen = false
140
+ filterQuery = ''
141
+ triggerRef?.focus()
142
+ }
143
+
144
+ const proxyTree = $derived(new ProxyTree(processedItems, fields))
145
+ const wrapper = $derived(new Wrapper(proxyTree, { onselect: handleSelect }))
146
+
147
+ // Override cancel/blur to close dropdown
148
+ $effect(() => {
149
+ const w = wrapper
150
+ w.cancel = () => {
151
+ isOpen = false
152
+ filterQuery = ''
153
+ triggerRef?.focus()
154
+ }
155
+ w.blur = () => {
156
+ isOpen = false
157
+ filterQuery = ''
158
+ }
159
+ })
160
+
161
+ // When wrapper recreates while open, focus first item
162
+ $effect(() => {
163
+ const _w = wrapper
164
+ if (isOpen && !filterable) _w.first(null)
165
+ })
166
+
167
+ // ─── Selected proxy for trigger display ───────────────────────────────────
168
+
169
+ const selectedProxy = $derived.by(() => {
170
+ if (value === undefined || value === null) return null
171
+ for (const [, proxy] of wrapper.lookup) {
172
+ if (!proxy.hasChildren && proxy.value === value) return proxy
173
+ }
174
+ return null
175
+ })
176
+
177
+ // Sync selected raw item
178
+ $effect(() => {
179
+ selected = selectedProxy?.original ?? null
180
+ })
181
+
182
+ // ─── Trigger action ───────────────────────────────────────────────────────
183
+
184
+ $effect(() => {
185
+ if (!triggerRef || !selectRef || disabled) return
186
+ const t = new Trigger(triggerRef, selectRef, {
187
+ isOpen: () => isOpen,
188
+ onopen: () => {
189
+ isOpen = true
190
+ requestAnimationFrame(() => {
191
+ if (filterable) {
192
+ filterInputRef?.focus()
193
+ } else {
194
+ focusSelectedOrFirst()
195
+ }
196
+ })
197
+ },
198
+ onclose: () => {
199
+ isOpen = false
200
+ filterQuery = ''
201
+ },
202
+ onlast: () => requestAnimationFrame(() => wrapper.last(null))
203
+ })
204
+ return () => t.destroy()
205
+ })
206
+
207
+ function focusSelectedOrFirst() {
208
+ if (value !== undefined && value !== null) {
209
+ for (const node of wrapper.flatView) {
210
+ if (!node.proxy.disabled && node.proxy.value === value) {
211
+ wrapper.moveTo(node.key)
212
+ return
213
+ }
214
+ }
215
+ }
216
+ wrapper.first(null)
217
+ }
218
+
219
+ // ─── Navigator on dropdown ────────────────────────────────────────────────
220
+
221
+ $effect(() => {
222
+ if (!isOpen || !dropdownRef) return
223
+ const dir = getComputedStyle(dropdownRef).direction || 'ltr'
224
+ const nav = new Navigator(dropdownRef, wrapper, { dir })
225
+ return () => nav.destroy()
226
+ })
227
+
228
+ // DOM focus sync
229
+ $effect(() => {
230
+ const key = wrapper.focusedKey
231
+ if (!isOpen || !dropdownRef || !key) return
232
+ requestAnimationFrame(() => {
233
+ const target = dropdownRef?.querySelector(`[data-path="${key}"]`) as HTMLElement | null
234
+ if (target && target !== document.activeElement) {
235
+ target.focus()
236
+ target.scrollIntoView?.({ block: 'nearest' })
237
+ }
238
+ })
239
+ })
240
+
241
+ // ─── Filter keyboard (native listener, fires before Navigator) ───────────
242
+
243
+ $effect(() => {
244
+ if (!isOpen || !filterable || !filterInputRef) return
245
+ const el = filterInputRef
246
+ const handler = (event: KeyboardEvent) => {
247
+ if (event.key === 'ArrowDown') {
248
+ event.preventDefault()
249
+ event.stopPropagation()
250
+ wrapper.first(null)
251
+ } else if (event.key === 'Escape') {
252
+ if (filterQuery) {
253
+ event.preventDefault()
254
+ event.stopPropagation()
255
+ filterQuery = ''
256
+ }
257
+ // Empty filter: let event bubble to Navigator/Trigger for close
258
+ } else if (event.key === 'Enter') {
259
+ event.preventDefault()
260
+ event.stopPropagation()
261
+ if (wrapper.focusedKey) wrapper.select(null)
262
+ }
263
+ }
264
+ el.addEventListener('keydown', handler)
265
+ return () => el.removeEventListener('keydown', handler)
266
+ })
267
+
268
+ // ─── Helpers ──────────────────────────────────────────────────────────────
269
+
270
+ /** Set of group keys that need a divider before them (not the first group) */
271
+ const groupDividers = $derived.by(() => {
272
+ const set = new Set<string>()
273
+ let foundFirst = false
274
+ for (const node of wrapper.flatView) {
275
+ if (node.hasChildren) {
276
+ if (foundFirst) set.add(node.key)
277
+ foundFirst = true
278
+ }
279
+ }
280
+ return set
281
+ })
282
+ </script>
283
+
284
+ {#snippet defaultOptionContent(proxy: ProxyItem)}
285
+ <ItemContent {proxy} />
286
+ {/snippet}
287
+
288
+ {#snippet defaultGroupContent(proxy: ProxyItem)}
289
+ {#if proxy.get('icon')}
290
+ <span data-select-group-icon class={proxy.get('icon')} aria-hidden="true"></span>
291
+ {/if}
292
+ <span>{proxy.label}</span>
293
+ {/snippet}
294
+
295
+ <div
296
+ bind:this={selectRef}
297
+ data-select
298
+ data-open={isOpen || undefined}
299
+ data-size={size}
300
+ data-disabled={disabled || undefined}
301
+ data-align={align}
302
+ data-direction={direction}
303
+ class={className || undefined}
304
+ >
305
+ <button
306
+ bind:this={triggerRef}
307
+ type="button"
308
+ data-select-trigger
309
+ {disabled}
310
+ aria-haspopup="listbox"
311
+ aria-expanded={isOpen}
312
+ >
313
+ <span data-select-value>
314
+ {#if selectedProxy}
315
+ {#if selectedProxy.get('icon')}
316
+ <span data-select-value-icon class={selectedProxy.get('icon')} aria-hidden="true"></span>
317
+ {/if}
318
+ <span data-select-value-text>{selectedProxy.label}</span>
319
+ {:else}
320
+ <span data-select-placeholder>{placeholder}</span>
321
+ {/if}
322
+ </span>
323
+ <span data-select-arrow class={isOpen ? icons.opened : icons.closed} aria-hidden="true"></span>
324
+ </button>
325
+
326
+ {#if isOpen}
327
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
328
+ <div
329
+ bind:this={dropdownRef}
330
+ data-select-dropdown
331
+ role="listbox"
332
+ aria-orientation="vertical"
333
+ >
334
+ {#if filterable}
335
+ <div data-select-filter>
336
+ <!-- svelte-ignore a11y_autofocus -->
337
+ <input
338
+ bind:this={filterInputRef}
339
+ type="text"
340
+ data-select-filter-input
341
+ placeholder={filterPlaceholder}
342
+ bind:value={filterQuery}
343
+ />
344
+ </div>
345
+ {/if}
346
+
347
+ {#each wrapper.flatView as node (node.key)}
348
+ {@const proxy = node.proxy}
349
+ {@const sel = !node.hasChildren && proxy.value === value}
350
+ {@const content = resolveSnippet(snippets as Record<string, unknown>, proxy, node.hasChildren ? GROUP_SNIPPET : ITEM_SNIPPET)}
351
+
352
+ {#if node.type === 'separator'}
353
+ <hr data-select-separator />
354
+ {:else if node.hasChildren}
355
+ {#if groupDividers.has(node.key)}
356
+ <div data-select-divider></div>
357
+ {/if}
358
+ <div data-select-group-label role="presentation">
359
+ {#if content}
360
+ {@render content(proxy)}
361
+ {:else}
362
+ {@render defaultGroupContent(proxy)}
363
+ {/if}
364
+ </div>
365
+ {:else}
366
+ <button
367
+ type="button"
368
+ title={proxy.get('tooltip')}
369
+ data-select-option
370
+ data-path={node.key}
371
+ data-level={node.level}
372
+ data-selected={sel || undefined}
373
+ data-disabled={proxy.disabled || undefined}
374
+ role="option"
375
+ aria-selected={sel}
376
+ disabled={proxy.disabled || disabled}
377
+ tabindex="-1"
378
+ >
379
+ {#if content}
380
+ {@render content(proxy)}
381
+ {:else}
382
+ {@render defaultOptionContent(proxy)}
383
+ {/if}
384
+ {#if sel}
385
+ <span data-select-check class={icons.checked} aria-hidden="true"></span>
386
+ {/if}
387
+ </button>
388
+ {/if}
389
+ {/each}
390
+
391
+ {#if filterable && filterQuery && filteredItems.length === 0}
392
+ <div data-select-empty>No results</div>
393
+ {/if}
394
+ </div>
395
+ {/if}
396
+ </div>
@@ -1,34 +1,43 @@
1
- <script>
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
2
3
  import { id } from '@rokkit/core'
3
- // import { clsx } from 'clsx'
4
4
 
5
- let {
5
+ interface ShineProps {
6
+ /** Light color (default: 'rgb(var(--primary-500))') */
7
+ color?: string
8
+ /** Light source distance/height — controls spread (default: 300) */
9
+ radius?: number
10
+ /** Gaussian blur depth (default: 1) */
11
+ depth?: number
12
+ /** Height of the surface for the light filter (default: 2) */
13
+ surfaceScale?: number
14
+ /** The bigger the value the bigger the reflection (default: 0.75) */
15
+ specularConstant?: number
16
+ /** Controls focus for the light source — bigger = brighter (default: 120) */
17
+ specularExponent?: number
18
+ /** Additional CSS class */
19
+ class?: string
20
+ children?: Snippet
21
+ }
22
+
23
+ const {
6
24
  color = 'rgb(var(--primary-500))',
7
25
  radius = 300,
8
- /** Depth of effect */
9
26
  depth = 1,
10
- /** Represents the height of the surface for a light filter primitive */
11
27
  surfaceScale = 2,
12
- /** The bigger the value the bigger the reflection */
13
28
  specularConstant = 0.75,
14
- /** controls the focus for the light source. The bigger the value the brighter the light */
15
29
  specularExponent = 120,
16
- children,
17
- ...restProps
18
- } = $props()
30
+ class: className = '',
31
+ children
32
+ }: ShineProps = $props()
19
33
 
20
34
  const filterId = id('filter')
21
35
 
22
36
  let mouse = $state({ x: 0, y: 0 })
23
37
  let wrapperBox = $state({ left: 0, top: 0 })
24
- /** @type {HTMLDivElement|null} */
25
- let wrapperEl = null
38
+ let wrapperEl: HTMLDivElement | null = $state(null)
26
39
 
27
- /**
28
- *
29
- * @param {PointerEvent} e
30
- */
31
- function onPointerMove(e) {
40
+ function onPointerMove(e: PointerEvent) {
32
41
  wrapperBox = wrapperEl?.getBoundingClientRect() ?? { left: 0, top: 0 }
33
42
  mouse = { x: e.clientX, y: e.clientY }
34
43
  }
@@ -40,7 +49,7 @@
40
49
 
41
50
  <svelte:window onpointermove={onPointerMove} onscroll={onScroll} />
42
51
 
43
- <svg data-shine-filter class="pointer-events-none fixed inset-0">
52
+ <svg data-shine-filter>
44
53
  <filter id={filterId} color-interpolation-filters="sRGB">
45
54
  <feGaussianBlur in="SourceAlpha" stdDeviation={depth} />
46
55
 
@@ -69,10 +78,9 @@
69
78
  </svg>
70
79
 
71
80
  <div
72
- data-shine-root
81
+ data-shine
73
82
  style:filter="url(#{filterId})"
74
- {...restProps}
75
- class="inline-block"
83
+ class={className || undefined}
76
84
  bind:this={wrapperEl}
77
85
  >
78
86
  {@render children?.()}
@@ -0,0 +1,172 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import { DEFAULT_STATE_ICONS } from '@rokkit/core'
4
+ import { messages } from '@rokkit/states'
5
+
6
+ interface StepperStep {
7
+ /** Step label (shown below circle) */
8
+ text: string
9
+ /** Short text inside circle (default: step number) */
10
+ label?: string
11
+ /** Step is finished (shows checkmark) */
12
+ completed?: boolean
13
+ /** Step cannot be navigated to */
14
+ disabled?: boolean
15
+ /** Number of sub-stages within this step (default: 1) */
16
+ stages?: number
17
+ }
18
+
19
+ interface StepperIcons {
20
+ /** Icon class for check/completed state */
21
+ check?: string
22
+ }
23
+
24
+ interface StepperProps {
25
+ /** Array of step definitions */
26
+ steps?: StepperStep[]
27
+ /** Current step index (bindable) */
28
+ current?: number
29
+ /** Current sub-stage within step (bindable, 0-based) */
30
+ currentStage?: number
31
+ /** Only allow clicking completed steps + first incomplete */
32
+ linear?: boolean
33
+ /** Layout orientation */
34
+ orientation?: 'horizontal' | 'vertical'
35
+ /** Custom icons */
36
+ icons?: StepperIcons
37
+ /** Callback when a step or dot is clicked */
38
+ onclick?: (step: number, stage?: number) => void
39
+ /** Content snippet rendered below the stepper */
40
+ content?: Snippet<[StepperStep, number]>
41
+ /** Additional CSS class */
42
+ class?: string
43
+ }
44
+
45
+ const defaultIcons: StepperIcons = {
46
+ check: DEFAULT_STATE_ICONS.action.check
47
+ }
48
+
49
+ let {
50
+ steps = [],
51
+ current = $bindable(0),
52
+ currentStage = $bindable(0),
53
+ linear = false,
54
+ orientation = 'horizontal',
55
+ label = messages.current.stepper.label,
56
+ icons: userIcons,
57
+ onclick,
58
+ content,
59
+ class: className = ''
60
+ }: StepperProps & { label?: string } = $props()
61
+
62
+ const icons = $derived<StepperIcons>({ ...defaultIcons, ...userIcons })
63
+
64
+ /**
65
+ * Whether a step can be clicked
66
+ */
67
+ function isClickable(index: number): boolean {
68
+ const step = steps[index]
69
+ if (step.disabled) return false
70
+ if (!linear) return true
71
+ // Linear mode: allow completed steps and the first incomplete step
72
+ if (step.completed) return true
73
+ // First incomplete step = first step where index >= first non-completed
74
+ const firstIncomplete = steps.findIndex((s) => !s.completed)
75
+ return index === firstIncomplete
76
+ }
77
+
78
+ /**
79
+ * Whether the connector line before step N should look "completed"
80
+ */
81
+ function isConnectorCompleted(index: number): boolean {
82
+ return index > 0 && Boolean(steps[index - 1]?.completed)
83
+ }
84
+
85
+ /**
86
+ * Handle step circle click
87
+ */
88
+ function handleStepClick(index: number) {
89
+ if (!isClickable(index)) return
90
+ current = index
91
+ currentStage = 0
92
+ onclick?.(index)
93
+ }
94
+
95
+ /**
96
+ * Handle sub-stage dot click
97
+ */
98
+ function handleDotClick(stepIndex: number, stageIndex: number) {
99
+ if (!isClickable(stepIndex)) return
100
+ current = stepIndex
101
+ currentStage = stageIndex
102
+ onclick?.(stepIndex, stageIndex)
103
+ }
104
+ </script>
105
+
106
+ <div
107
+ data-stepper
108
+ data-orientation={orientation}
109
+ class={className || undefined}
110
+ role="group"
111
+ aria-label={label}
112
+ >
113
+ {#each steps as step, index (index)}
114
+ <!-- Connector line before step (except first) -->
115
+ {#if index > 0}
116
+ <div
117
+ data-stepper-connector
118
+ data-completed={isConnectorCompleted(index) || undefined}
119
+ aria-hidden="true"
120
+ ></div>
121
+ {/if}
122
+
123
+ <!-- Step -->
124
+ <div
125
+ data-stepper-step
126
+ data-completed={step.completed || undefined}
127
+ data-active={index === current || undefined}
128
+ data-disabled={step.disabled || undefined}
129
+ >
130
+ <button
131
+ type="button"
132
+ data-stepper-circle
133
+ disabled={!isClickable(index)}
134
+ aria-label="{step.text}{step.completed ? ' (completed)' : ''}"
135
+ aria-current={index === current ? 'step' : undefined}
136
+ onclick={() => handleStepClick(index)}
137
+ >
138
+ {#if step.completed}
139
+ <span data-stepper-check-icon class={icons.check} aria-hidden="true"></span>
140
+ {:else}
141
+ {step.label ?? index + 1}
142
+ {/if}
143
+ </button>
144
+
145
+ <span data-stepper-label>{step.text}</span>
146
+
147
+ <!-- Sub-stage dots -->
148
+ {#if step.stages && step.stages > 1}
149
+ <div data-stepper-dots aria-label="Sub-stages for {step.text}">
150
+ {#each Array(step.stages) as _, stageIndex (stageIndex)}
151
+ <button
152
+ type="button"
153
+ data-stepper-dot
154
+ data-active={index === current && stageIndex === currentStage || undefined}
155
+ data-completed={step.completed || (index === current && stageIndex < currentStage) || undefined}
156
+ disabled={!isClickable(index)}
157
+ aria-label="Stage {stageIndex + 1}"
158
+ onclick={() => handleDotClick(index, stageIndex)}
159
+ ></button>
160
+ {/each}
161
+ </div>
162
+ {/if}
163
+ </div>
164
+ {/each}
165
+
166
+ <!-- Content area -->
167
+ {#if content && steps[current]}
168
+ <div data-stepper-content>
169
+ {@render content(steps[current], current)}
170
+ </div>
171
+ {/if}
172
+ </div>