@rokkit/ui 1.0.0-next.99 → 1.0.2

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 (83) hide show
  1. package/README.md +159 -1
  2. package/package.json +52 -53
  3. package/src/MarkdownRenderer.svelte +106 -0
  4. package/src/components/AlertList.svelte +59 -0
  5. package/src/components/Avatar.svelte +68 -0
  6. package/src/components/Badge.svelte +57 -0
  7. package/src/components/BreadCrumbs.svelte +94 -0
  8. package/src/components/Button.svelte +91 -0
  9. package/src/components/ButtonGroup.svelte +9 -0
  10. package/src/components/Card.svelte +71 -0
  11. package/src/components/Carousel.svelte +169 -0
  12. package/src/components/Code.svelte +189 -0
  13. package/src/components/Connector.svelte +46 -0
  14. package/src/components/Divider.svelte +24 -0
  15. package/src/components/Dropdown.svelte +202 -0
  16. package/src/components/FloatingAction.svelte +367 -0
  17. package/src/components/FloatingNavigation.svelte +256 -0
  18. package/src/components/Grid.svelte +128 -0
  19. package/src/components/ItemContent.svelte +25 -0
  20. package/src/components/LazyTree.svelte +171 -0
  21. package/src/components/List.svelte +224 -0
  22. package/src/components/Menu.svelte +270 -0
  23. package/src/components/Message.svelte +75 -0
  24. package/src/components/MultiSelect.svelte +367 -0
  25. package/src/components/NavContent.svelte +42 -0
  26. package/src/components/PaletteManager.svelte +360 -0
  27. package/src/components/Pill.svelte +83 -0
  28. package/src/components/ProgressBar.svelte +31 -0
  29. package/src/components/Range.svelte +346 -0
  30. package/src/components/Rating.svelte +115 -0
  31. package/src/components/ResponsiveGrid.svelte +34 -0
  32. package/src/components/Reveal.svelte +46 -0
  33. package/src/components/SearchFilter.svelte +88 -0
  34. package/src/components/Select.svelte +424 -0
  35. package/src/components/Shine.svelte +87 -0
  36. package/src/components/Stack.svelte +38 -0
  37. package/src/components/StatusList.svelte +18 -0
  38. package/src/components/Stepper.svelte +174 -0
  39. package/src/components/Swatch.svelte +102 -0
  40. package/src/components/Switch.svelte +71 -0
  41. package/src/components/Table.svelte +245 -0
  42. package/src/components/Tabs.svelte +202 -0
  43. package/src/components/Tilt.svelte +68 -0
  44. package/src/components/Timeline.svelte +61 -0
  45. package/src/components/Toggle.svelte +93 -0
  46. package/src/components/Toolbar.svelte +306 -0
  47. package/src/components/ToolbarGroup.svelte +17 -0
  48. package/src/components/Tooltip.svelte +121 -0
  49. package/src/components/Tree.svelte +143 -0
  50. package/src/components/UploadFileStatus.svelte +102 -0
  51. package/src/components/UploadProgress.svelte +127 -0
  52. package/src/components/UploadTarget.svelte +126 -0
  53. package/src/components/index.ts +50 -0
  54. package/src/index.ts +61 -0
  55. package/src/markdown-plugin.ts +12 -0
  56. package/src/types/button.ts +86 -0
  57. package/src/types/code.ts +46 -0
  58. package/src/types/floating-action.ts +123 -0
  59. package/src/types/floating-navigation.ts +80 -0
  60. package/src/types/index.ts +58 -0
  61. package/src/types/list.ts +200 -0
  62. package/src/types/menu.ts +95 -0
  63. package/src/types/palette.ts +160 -0
  64. package/src/types/range.ts +51 -0
  65. package/src/types/search-filter.ts +67 -0
  66. package/src/types/select.ts +176 -0
  67. package/src/types/switch.ts +68 -0
  68. package/src/types/table.ts +219 -0
  69. package/src/types/tabs.ts +103 -0
  70. package/src/types/timeline.ts +53 -0
  71. package/src/types/toggle.ts +68 -0
  72. package/src/types/toolbar.ts +167 -0
  73. package/src/types/tooltip.ts +21 -0
  74. package/src/types/tree.ts +250 -0
  75. package/src/types/upload-file-status.ts +45 -0
  76. package/src/types/upload-progress.ts +111 -0
  77. package/src/types/upload-target.ts +71 -0
  78. package/src/utils/palette.ts +618 -0
  79. package/src/utils/shiki.ts +114 -0
  80. package/src/utils/upload.js +145 -0
  81. package/LICENSE +0 -21
  82. package/src/index.js +0 -8
  83. package/src/wrappers.js +0 -2
@@ -0,0 +1,306 @@
1
+ <script lang="ts">
2
+ import type {
3
+ ToolbarProps,
4
+ ToolbarItem,
5
+ ToolbarItemSnippet,
6
+ ToolbarItemHandlers
7
+ } from '../types/toolbar.js'
8
+ import { getSnippet } from '../types/menu.js'
9
+ import { ProxyItem, messages } from '@rokkit/states'
10
+ import { ListController } from '@rokkit/states'
11
+ import { SvelteMap } from 'svelte/reactivity'
12
+ import { navigator } from '@rokkit/actions'
13
+ import { untrack } from 'svelte'
14
+
15
+ const {
16
+ items = [],
17
+ fields: userFields,
18
+ position = 'top',
19
+ size = 'md',
20
+ width = 'full',
21
+ sticky = false,
22
+ compact = false,
23
+ showDividers = true,
24
+ disabled = false,
25
+ label = messages.toolbar.label,
26
+ onclick,
27
+ class: className = '',
28
+ item: itemSnippet,
29
+ start,
30
+ center,
31
+ end,
32
+ children,
33
+ ...snippets
34
+ }: ToolbarProps & { label?: string; [key: string]: ToolbarItemSnippet | unknown } = $props()
35
+
36
+ /**
37
+ * Create a ProxyItem for the given item
38
+ */
39
+ function createProxy(item: ToolbarItem): ProxyItem {
40
+ return new ProxyItem(item, userFields)
41
+ }
42
+
43
+ // ─── Controller + Navigator ────────────────────────────────────
44
+
45
+ /** Only interactive items are tracked by the controller */
46
+ function isInteractive(item: ToolbarItem): boolean {
47
+ const proxy = createProxy(item)
48
+ const type = proxy.type
49
+ return type !== 'separator' && type !== 'spacer'
50
+ }
51
+
52
+ const interactiveItems = $derived(items.filter(isInteractive))
53
+
54
+ /** Map from item → its data-path key (index within interactive items) */
55
+ const itemPathMap = $derived.by(() => {
56
+ const map = new SvelteMap<unknown, string>()
57
+ let idx = 0
58
+ for (const item of items) {
59
+ if (isInteractive(item)) {
60
+ map.set(item, String(idx))
61
+ idx++
62
+ }
63
+ }
64
+ return map
65
+ })
66
+
67
+ const orientation = $derived<'horizontal' | 'vertical'>(
68
+ position === 'left' || position === 'right' ? 'vertical' : 'horizontal'
69
+ )
70
+
71
+ let controller = untrack(() => {
72
+ const c = new ListController(interactiveItems, undefined, userFields)
73
+ c.moveFirst()
74
+ return c
75
+ })
76
+ let containerRef: HTMLElement | null = $state(null)
77
+
78
+ $effect(() => {
79
+ controller.update(interactiveItems)
80
+ })
81
+
82
+ // Sync controller position when a toolbar item receives focus (e.g. Tab from outside)
83
+ $effect(() => {
84
+ if (!containerRef) return
85
+ const el = containerRef
86
+
87
+ function onFocusIn(event: Event) {
88
+ const target = event.target as HTMLElement
89
+ const path = target.closest('[data-path]')?.getAttribute('data-path')
90
+ if (path) {
91
+ controller.moveTo(path)
92
+ }
93
+ }
94
+
95
+ el.addEventListener('focusin', onFocusIn)
96
+ return () => el.removeEventListener('focusin', onFocusIn)
97
+ })
98
+
99
+ function focusKeyedItem(el: HTMLElement, key: string) {
100
+ const target = el.querySelector(`[data-path="${key}"]`) as HTMLElement | null
101
+ if (target && target !== document.activeElement) target.focus()
102
+ }
103
+
104
+ // Focus the item matching controller.focusedKey on navigator action events
105
+ $effect(() => {
106
+ if (!containerRef) return
107
+ const el = containerRef
108
+
109
+ function onAction(event: Event) {
110
+ const detail = (event as CustomEvent).detail
111
+ if (detail.name === 'move') {
112
+ const key = controller.focusedKey
113
+ if (key) focusKeyedItem(el, key)
114
+ }
115
+ if (detail.name === 'select') handleSelectAction()
116
+ }
117
+
118
+ el.addEventListener('action', onAction)
119
+ return () => el.removeEventListener('action', onAction)
120
+ })
121
+
122
+ /**
123
+ * Handle the navigator's select action (Enter/Space or click on data-path item)
124
+ */
125
+ function handleSelectAction() {
126
+ const key = controller.focusedKey
127
+ if (!key) return
128
+ const proxy = controller.lookup.get(key)
129
+ if (!proxy) return
130
+ const itemProxy = createProxy(proxy.value)
131
+ handleItemClick(itemProxy)
132
+ }
133
+
134
+ /**
135
+ * Get the data-path key for an item
136
+ */
137
+ function getPathKey(item: ToolbarItem): string | undefined {
138
+ return itemPathMap.get(item)
139
+ }
140
+
141
+ // ─── Item Handlers ─────────────────────────────────────────────
142
+
143
+ function handleItemClick(proxy: ProxyItem) {
144
+ if (proxy.disabled || disabled) return
145
+ onclick?.(proxy.value, proxy.original as ToolbarItem)
146
+ }
147
+
148
+ function handleItemKeyDown(event: KeyboardEvent, proxy: ProxyItem) {
149
+ if (event.key === 'Enter' || event.key === ' ') {
150
+ event.preventDefault()
151
+ handleItemClick(proxy)
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Create handlers object for custom snippets
157
+ */
158
+ function createHandlers(proxy: ProxyItem): ToolbarItemHandlers {
159
+ return {
160
+ onclick: () => handleItemClick(proxy),
161
+ onkeydown: (event: KeyboardEvent) => handleItemKeyDown(event, proxy)
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Resolve which snippet to use for an item
167
+ */
168
+ function resolveItemSnippet(proxy: ProxyItem): ToolbarItemSnippet | null {
169
+ // Check for per-item snippet name
170
+ const snippetName = proxy.get('snippet')
171
+ if (snippetName) {
172
+ const namedSnippet = getSnippet(snippets, snippetName)
173
+ if (namedSnippet) {
174
+ return namedSnippet as ToolbarItemSnippet
175
+ }
176
+ }
177
+
178
+ // Fall back to the generic item snippet
179
+ return itemSnippet ?? null
180
+ }
181
+
182
+ // Check if we have data-driven items or slot-based content
183
+ const hasItems = $derived(items.length > 0)
184
+ const hasSlots = $derived(start || center || end || children)
185
+
186
+ // Determine if toolbar is horizontal or vertical
187
+ const isHorizontal = $derived(position === 'top' || position === 'bottom')
188
+ </script>
189
+
190
+ {#snippet defaultItem(proxy: ProxyItem, pathKey: string | undefined)}
191
+ <button
192
+ type="button"
193
+ data-toolbar-item
194
+ data-path={pathKey}
195
+ data-active={proxy.get('active') || undefined}
196
+ data-disabled={proxy.disabled || undefined}
197
+ disabled={proxy.disabled || disabled}
198
+ aria-label={proxy.label}
199
+ aria-pressed={proxy.get('active')}
200
+ title={proxy.get('shortcut') ? `${proxy.label} (${proxy.get('shortcut')})` : proxy.label}
201
+ >
202
+ {#if proxy.get('icon')}
203
+ <span data-toolbar-icon class={proxy.get('icon')} aria-hidden="true"></span>
204
+ {/if}
205
+ {#if proxy.label && !proxy.get('icon')}
206
+ <span data-toolbar-label>{proxy.label}</span>
207
+ {/if}
208
+ </button>
209
+ {/snippet}
210
+
211
+ {#snippet separator()}
212
+ <div
213
+ data-toolbar-separator
214
+ role="separator"
215
+ aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
216
+ ></div>
217
+ {/snippet}
218
+
219
+ {#snippet spacer()}
220
+ <div data-toolbar-spacer></div>
221
+ {/snippet}
222
+
223
+ {#snippet divider()}
224
+ <div data-toolbar-divider aria-hidden="true"></div>
225
+ {/snippet}
226
+
227
+ {#snippet renderItem(proxy: ProxyItem, item: ToolbarItem)}
228
+ {@const customSnippet = resolveItemSnippet(proxy)}
229
+ {@const handlers = createHandlers(proxy)}
230
+ {@const itemType = proxy.type}
231
+ {@const pathKey = getPathKey(item)}
232
+
233
+ {#if itemType === 'separator'}
234
+ {@render separator()}
235
+ {:else if itemType === 'spacer'}
236
+ {@render spacer()}
237
+ {:else if customSnippet}
238
+ <div data-toolbar-item-custom data-path={pathKey} data-disabled={proxy.disabled || undefined}>
239
+ <svelte:boundary>
240
+ {@render customSnippet(proxy.original as ToolbarItem, proxy.fields, handlers)}
241
+ {#snippet failed()}
242
+ {@render defaultItem(proxy, pathKey)}
243
+ {/snippet}
244
+ </svelte:boundary>
245
+ </div>
246
+ {:else}
247
+ {@render defaultItem(proxy, pathKey)}
248
+ {/if}
249
+ {/snippet}
250
+
251
+ <div
252
+ bind:this={containerRef}
253
+ data-toolbar
254
+ data-toolbar-position={position}
255
+ data-toolbar-size={size}
256
+ data-toolbar-width={width === 'fit' ? 'fit' : undefined}
257
+ data-toolbar-sticky={sticky || undefined}
258
+ data-toolbar-compact={compact || undefined}
259
+ data-toolbar-disabled={disabled || undefined}
260
+ class={className || undefined}
261
+ role="toolbar"
262
+ aria-label={label}
263
+ aria-disabled={disabled || undefined}
264
+ use:navigator={{ wrapper: controller, orientation }}
265
+ >
266
+ {#if hasItems}
267
+ <!-- Data-driven items -->
268
+ {#each items as item, index (index)}
269
+ {@const proxy = createProxy(item)}
270
+ {@render renderItem(proxy, item)}
271
+ {/each}
272
+ {:else if hasSlots}
273
+ <!-- Slot-based content -->
274
+ {#if start}
275
+ <div data-toolbar-section="start">
276
+ {@render start()}
277
+ </div>
278
+
279
+ {#if showDividers && (center || end)}
280
+ {@render divider()}
281
+ {/if}
282
+ {/if}
283
+
284
+ {#if center}
285
+ <div data-toolbar-section="center">
286
+ {@render center()}
287
+ </div>
288
+
289
+ {#if showDividers && end}
290
+ {@render divider()}
291
+ {/if}
292
+ {:else if !start && !end && children}
293
+ <div data-toolbar-section="content">
294
+ {@render children()}
295
+ </div>
296
+ {:else}
297
+ <div data-toolbar-spacer></div>
298
+ {/if}
299
+
300
+ {#if end}
301
+ <div data-toolbar-section="end">
302
+ {@render end()}
303
+ </div>
304
+ {/if}
305
+ {/if}
306
+ </div>
@@ -0,0 +1,17 @@
1
+ <script lang="ts">
2
+ import type { ToolbarGroupProps } from '../types/toolbar.js'
3
+
4
+ const { label, gap = 'sm', class: className = '', children }: ToolbarGroupProps = $props()
5
+ </script>
6
+
7
+ <div
8
+ data-toolbar-group
9
+ data-toolbar-group-gap={gap}
10
+ class={className || undefined}
11
+ role="group"
12
+ aria-label={label}
13
+ >
14
+ {#if children}
15
+ {@render children()}
16
+ {/if}
17
+ </div>
@@ -0,0 +1,121 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Tooltip — contextual text revealed on hover or focus.
4
+ *
5
+ * Wraps any trigger element. Tooltip appears after a configurable delay
6
+ * on mouseenter/focusin, hides immediately on mouseleave/focusout/Escape.
7
+ * Position auto-flips when the preferred side would overflow the viewport.
8
+ *
9
+ * Data attributes:
10
+ * data-tooltip-root — wrapper element (position: relative)
11
+ * data-tooltip-content — tooltip bubble
12
+ * data-tooltip-position — actual position after auto-flip
13
+ * data-tooltip-visible — "true" while visible
14
+ */
15
+ import type { TooltipProps } from '../types/tooltip.js'
16
+
17
+ let {
18
+ content = '',
19
+ position = 'top',
20
+ delay = 300,
21
+ class: className,
22
+ children,
23
+ tooltipContent
24
+ }: TooltipProps = $props()
25
+
26
+ const id = `tt-${Math.random().toString(36).slice(2, 9)}`
27
+ let visible = $state(false)
28
+ // positionOverride is set by auto-flip during show; null resets to the position prop
29
+ let positionOverride = $state<typeof position | null>(null)
30
+ const resolvedPosition = $derived(positionOverride ?? position)
31
+ let timer: ReturnType<typeof setTimeout> | null = null
32
+ let rootEl = $state<HTMLElement | null>(null)
33
+ let tooltipEl = $state<HTMLElement | null>(null)
34
+
35
+ const GAP = 6
36
+
37
+ function resolveFlip(
38
+ triggerRect: DOMRect,
39
+ tooltipRect: DOMRect,
40
+ preferred: typeof position
41
+ ): typeof position {
42
+ const vw = window.innerWidth
43
+ const vh = window.innerHeight
44
+ const fits: Record<string, boolean> = {
45
+ top: triggerRect.top >= tooltipRect.height + GAP,
46
+ bottom: triggerRect.bottom + tooltipRect.height + GAP <= vh,
47
+ left: triggerRect.left >= tooltipRect.width + GAP,
48
+ right: triggerRect.right + tooltipRect.width + GAP <= vw
49
+ }
50
+ if (fits[preferred]) return preferred
51
+ const flip: Record<string, typeof position> = {
52
+ top: 'bottom',
53
+ bottom: 'top',
54
+ left: 'right',
55
+ right: 'left'
56
+ }
57
+ if (fits[flip[preferred]]) return flip[preferred]
58
+ return (Object.keys(fits).find((p) => fits[p]) as typeof position) ?? preferred
59
+ }
60
+
61
+ function updatePosition() {
62
+ if (!rootEl || !tooltipEl) return
63
+ const triggerRect = rootEl.getBoundingClientRect()
64
+ const tooltipRect = tooltipEl.getBoundingClientRect()
65
+ positionOverride = resolveFlip(triggerRect, tooltipRect, position)
66
+ }
67
+
68
+ function show() {
69
+ updatePosition()
70
+ visible = true
71
+ }
72
+
73
+ function hide() {
74
+ if (timer) clearTimeout(timer)
75
+ visible = false
76
+ positionOverride = null
77
+ }
78
+
79
+ function onMouseEnter() {
80
+ timer = setTimeout(show, delay)
81
+ }
82
+
83
+ function onMouseLeave() {
84
+ if (timer) clearTimeout(timer)
85
+ hide()
86
+ }
87
+
88
+ function onKeydown(e: KeyboardEvent) {
89
+ if (e.key === 'Escape') hide()
90
+ }
91
+ </script>
92
+
93
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
94
+ <span
95
+ data-tooltip-root
96
+ role="group"
97
+ aria-describedby={id}
98
+ class={className || undefined}
99
+ onmouseenter={onMouseEnter}
100
+ onmouseleave={onMouseLeave}
101
+ onfocusin={show}
102
+ onfocusout={hide}
103
+ onkeydown={onKeydown}
104
+ bind:this={rootEl}
105
+ >
106
+ {@render children?.()}
107
+ <div
108
+ data-tooltip-content
109
+ data-tooltip-position={resolvedPosition}
110
+ data-tooltip-visible={visible ? 'true' : 'false'}
111
+ {id}
112
+ role="tooltip"
113
+ bind:this={tooltipEl}
114
+ >
115
+ {#if tooltipContent}
116
+ {@render tooltipContent()}
117
+ {:else}
118
+ {content}
119
+ {/if}
120
+ </div>
121
+ </span>
@@ -0,0 +1,143 @@
1
+ <script lang="ts">
2
+ // @ts-nocheck
3
+ /**
4
+ * Tree — Hierarchical data navigation with tree lines and keyboard navigation.
5
+ *
6
+ * Architecture:
7
+ * LazyWrapper — owns focusedKey $state + flatView $derived (with lineTypes)
8
+ * Navigator — attaches DOM event handlers, calls wrapper[action](path)
9
+ * flatView — single flat {#each}, connectors rendered from node.lineTypes
10
+ *
11
+ * Default item content: icon + label + badge + shortcut (no avatar/description).
12
+ * Parent nodes hide their icon by default (showIcon=false) so only the
13
+ * expand/collapse chevron + label are shown, aligning with leaf icon + label.
14
+ */
15
+ import type { ProxyItem } from '@rokkit/states'
16
+ import { LazyWrapper, ProxyTree, messages } from '@rokkit/states'
17
+ import { Navigator } from '@rokkit/actions'
18
+ import { DEFAULT_STATE_ICONS, resolveSnippet, ITEM_SNIPPET } from '@rokkit/core'
19
+ import ItemContent from './ItemContent.svelte'
20
+ import Connector from './Connector.svelte'
21
+
22
+ let {
23
+ items = [],
24
+ fields = {},
25
+ value,
26
+ size = 'md',
27
+ lineStyle = 'solid',
28
+ labels: userLabels = {},
29
+ icons: userIcons = {},
30
+ onselect,
31
+ class: className = '',
32
+ ...snippets
33
+ }: {
34
+ items?: unknown[]
35
+ fields?: Record<string, string>
36
+ value?: unknown
37
+ size?: string
38
+ lineStyle?: 'none' | 'solid' | 'dashed' | 'dotted'
39
+ labels?: Record<string, string>
40
+ icons?: { opened?: string; closed?: string }
41
+ onselect?: (value: unknown, proxy: ProxyItem) => void
42
+ class?: string
43
+ [key: string]: unknown
44
+ } = $props()
45
+
46
+ const labels = $derived({ ...messages.tree, ...userLabels })
47
+
48
+ const icons = $derived({ ...DEFAULT_STATE_ICONS.folder, ...userIcons })
49
+
50
+ const proxyTree = $derived(new ProxyTree(items, fields))
51
+ const wrapper = $derived(new LazyWrapper(proxyTree, { onselect }))
52
+
53
+ let treeRef = $state<HTMLElement | null>(null)
54
+
55
+ $effect(() => {
56
+ if (!treeRef) return
57
+ const dir = getComputedStyle(treeRef).direction || 'ltr'
58
+ const nav = new Navigator(treeRef, wrapper, { collapsible: true, dir })
59
+ return () => nav.destroy()
60
+ })
61
+
62
+ $effect(() => {
63
+ wrapper.moveToValue(value)
64
+ })
65
+ </script>
66
+
67
+ <div
68
+ bind:this={treeRef}
69
+ data-tree
70
+ data-size={size}
71
+ data-line-style={lineStyle}
72
+ class={className || undefined}
73
+ role="tree"
74
+ tabindex="0"
75
+ aria-label={labels.label}
76
+ >
77
+ {#each wrapper.flatView as node (node.key)}
78
+ {@const proxy = node.proxy}
79
+ {@const isActive = proxy.value === value}
80
+ {@const content = resolveSnippet(snippets, proxy, ITEM_SNIPPET)}
81
+
82
+ <div
83
+ data-tree-node
84
+ data-tree-path={node.key}
85
+ data-tree-level={node.level - 1}
86
+ data-tree-has-children={node.isExpandable || undefined}
87
+ data-active={isActive || undefined}
88
+ role="treeitem"
89
+ aria-expanded={node.isExpandable ? proxy.expanded : undefined}
90
+ aria-selected={isActive}
91
+ aria-level={node.level}
92
+ >
93
+ <div data-tree-node-row>
94
+ {#each node.lineTypes as lineType, lineIndex (lineIndex)}
95
+ {#if lineType === 'icon'}
96
+ <button
97
+ type="button"
98
+ data-tree-toggle-btn
99
+ onclick={() => wrapper.toggle(node.key)}
100
+ aria-label={proxy.expanded ? labels.collapse : labels.expand}
101
+ tabindex={-1}
102
+ >
103
+ <span class={proxy.expanded ? icons.opened : icons.closed} aria-hidden="true"></span>
104
+ </button>
105
+ {:else}
106
+ <Connector type={lineType} />
107
+ {/if}
108
+ {/each}
109
+
110
+ {#if proxy.get('href')}
111
+ <a
112
+ href={proxy.get('href')}
113
+ data-tree-item-content
114
+ data-path={node.key}
115
+ data-active={isActive || undefined}
116
+ aria-label={proxy.label}
117
+ aria-current={isActive ? 'page' : undefined}
118
+ >
119
+ {#if content}
120
+ {@render content(proxy)}
121
+ {:else}
122
+ <ItemContent {proxy} showIcon={!node.isExpandable} showSubtext={false} />
123
+ {/if}
124
+ </a>
125
+ {:else}
126
+ <button
127
+ type="button"
128
+ data-tree-item-content
129
+ data-path={node.key}
130
+ data-active={isActive || undefined}
131
+ aria-label={proxy.label}
132
+ >
133
+ {#if content}
134
+ {@render content(proxy)}
135
+ {:else}
136
+ <ItemContent {proxy} showIcon={!node.isExpandable} showSubtext={false} />
137
+ {/if}
138
+ </button>
139
+ {/if}
140
+ </div>
141
+ </div>
142
+ {/each}
143
+ </div>
@@ -0,0 +1,102 @@
1
+ <script lang="ts">
2
+ /**
3
+ * UploadFileStatus — Renders a single file's upload status and action buttons.
4
+ *
5
+ * Data attributes:
6
+ * data-upload-file-status — root
7
+ * data-upload-file-icon — file type icon
8
+ * data-upload-status — status text badge
9
+ * data-upload-bar — progress bar track
10
+ * data-upload-fill — progress bar fill
11
+ * data-upload-actions — action buttons wrapper
12
+ * data-upload-cancel — cancel button
13
+ * data-upload-retry — retry button
14
+ * data-upload-remove — remove button
15
+ * data-status — status value on root (for CSS theming)
16
+ */
17
+ import type { UploadFileStatusProps } from '../types/upload-file-status.js'
18
+ import { messages } from '@rokkit/states'
19
+ import { DEFAULT_STATE_ICONS } from '@rokkit/core'
20
+ import { inferIcon, formatSize } from '../utils/upload.js'
21
+
22
+ let {
23
+ proxy,
24
+ cancelWhen = [] as string[],
25
+ retryWhen = [] as string[],
26
+ removeWhen = [] as string[],
27
+ oncancel,
28
+ onretry,
29
+ onremove,
30
+ labels: userLabels = {} as Record<string, string>,
31
+ icons: userIcons = {} as Record<string, string>
32
+ }: UploadFileStatusProps = $props()
33
+
34
+ const labels = $derived({ ...messages.uploadProgress, ...userLabels })
35
+ const icons = $derived({
36
+ cancel: DEFAULT_STATE_ICONS.action.cancel,
37
+ retry: DEFAULT_STATE_ICONS.action.retry,
38
+ remove: DEFAULT_STATE_ICONS.action.remove,
39
+ ...userIcons
40
+ })
41
+
42
+ const status = $derived(String(proxy.get('status') ?? 'unknown'))
43
+ const progress = $derived(Number(proxy.get('progress') ?? 0))
44
+ const size = $derived(Number(proxy.get('size') ?? 0))
45
+ const fileIcon = $derived(
46
+ proxy.get('icon')
47
+ ? String(proxy.get('icon'))
48
+ : inferIcon(proxy.get('type') as string | undefined)
49
+ )
50
+ const statusLabel = $derived(labels[status] ?? status)
51
+
52
+ const showBar = $derived(progress > 0 && progress < 100)
53
+ const showCancel = $derived(cancelWhen.includes(status) && Boolean(oncancel))
54
+ const showRetry = $derived(retryWhen.includes(status) && Boolean(onretry))
55
+ const showRemove = $derived(removeWhen.includes(status) && Boolean(onremove))
56
+ </script>
57
+
58
+ <div data-upload-file-status data-status={status}>
59
+ <span data-upload-file-icon class={fileIcon} aria-hidden="true"></span>
60
+ <span data-upload-file-name>{proxy.label}</span>
61
+ <span data-upload-file-size>{formatSize(size)}</span>
62
+ {#if showBar}
63
+ <div data-upload-bar>
64
+ <div data-upload-fill style:width="{progress}%"></div>
65
+ </div>
66
+ {/if}
67
+ <span data-upload-status>{statusLabel}</span>
68
+ {#if showCancel || showRetry || showRemove}
69
+ <div data-upload-actions>
70
+ {#if showCancel}
71
+ <button
72
+ type="button"
73
+ data-upload-cancel
74
+ aria-label="{labels.cancel} {proxy.label}"
75
+ onclick={() => oncancel?.(proxy)}
76
+ >
77
+ <span class={icons.cancel} aria-hidden="true"></span>
78
+ </button>
79
+ {/if}
80
+ {#if showRetry}
81
+ <button
82
+ type="button"
83
+ data-upload-retry
84
+ aria-label="{labels.retry} {proxy.label}"
85
+ onclick={() => onretry?.(proxy)}
86
+ >
87
+ <span class={icons.retry} aria-hidden="true"></span>
88
+ </button>
89
+ {/if}
90
+ {#if showRemove}
91
+ <button
92
+ type="button"
93
+ data-upload-remove
94
+ aria-label="{labels.remove} {proxy.label}"
95
+ onclick={() => onremove?.(proxy)}
96
+ >
97
+ <span class={icons.remove} aria-hidden="true"></span>
98
+ </button>
99
+ {/if}
100
+ </div>
101
+ {/if}
102
+ </div>