@rokkit/ui 1.0.0-next.99 → 1.0.1
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 +159 -1
- package/package.json +52 -53
- package/src/MarkdownRenderer.svelte +106 -0
- package/src/components/AlertList.svelte +59 -0
- package/src/components/Avatar.svelte +68 -0
- package/src/components/Badge.svelte +57 -0
- package/src/components/BreadCrumbs.svelte +94 -0
- package/src/components/Button.svelte +91 -0
- package/src/components/ButtonGroup.svelte +9 -0
- package/src/components/Card.svelte +71 -0
- package/src/components/Carousel.svelte +169 -0
- package/src/components/Code.svelte +189 -0
- package/src/components/Connector.svelte +46 -0
- package/src/components/Divider.svelte +24 -0
- package/src/components/Dropdown.svelte +202 -0
- package/src/components/FloatingAction.svelte +367 -0
- package/src/components/FloatingNavigation.svelte +256 -0
- package/src/components/Grid.svelte +128 -0
- package/src/components/ItemContent.svelte +25 -0
- package/src/components/LazyTree.svelte +171 -0
- package/src/components/List.svelte +224 -0
- package/src/components/Menu.svelte +270 -0
- package/src/components/Message.svelte +75 -0
- package/src/components/MultiSelect.svelte +367 -0
- package/src/components/PaletteManager.svelte +360 -0
- package/src/components/Pill.svelte +83 -0
- package/src/components/ProgressBar.svelte +31 -0
- package/src/components/Range.svelte +346 -0
- package/src/components/Rating.svelte +115 -0
- package/src/components/Reveal.svelte +46 -0
- package/src/components/SearchFilter.svelte +88 -0
- package/src/components/Select.svelte +424 -0
- package/src/components/Shine.svelte +87 -0
- package/src/components/Stack.svelte +38 -0
- package/src/components/StatusList.svelte +18 -0
- package/src/components/Stepper.svelte +174 -0
- package/src/components/Swatch.svelte +102 -0
- package/src/components/Switch.svelte +71 -0
- package/src/components/Table.svelte +245 -0
- package/src/components/Tabs.svelte +202 -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 +306 -0
- package/src/components/ToolbarGroup.svelte +17 -0
- package/src/components/Tooltip.svelte +121 -0
- package/src/components/Tree.svelte +143 -0
- package/src/components/UploadFileStatus.svelte +102 -0
- package/src/components/UploadProgress.svelte +127 -0
- package/src/components/UploadTarget.svelte +126 -0
- package/src/components/index.ts +48 -0
- package/src/index.ts +59 -0
- package/src/markdown-plugin.ts +12 -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 +58 -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 +219 -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 +167 -0
- package/src/types/tooltip.ts +21 -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 +71 -0
- package/src/utils/palette.ts +618 -0
- package/src/utils/shiki.ts +114 -0
- package/src/utils/upload.js +145 -0
- package/LICENSE +0 -21
- package/src/index.js +0 -8
- 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>
|