@rokkit/ui 1.0.0-next.98 → 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,174 @@
|
|
|
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.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 ||
|
|
156
|
+
(index === current && stageIndex < currentStage) ||
|
|
157
|
+
undefined}
|
|
158
|
+
disabled={!isClickable(index)}
|
|
159
|
+
aria-label="Stage {stageIndex + 1}"
|
|
160
|
+
onclick={() => handleDotClick(index, stageIndex)}
|
|
161
|
+
></button>
|
|
162
|
+
{/each}
|
|
163
|
+
</div>
|
|
164
|
+
{/if}
|
|
165
|
+
</div>
|
|
166
|
+
{/each}
|
|
167
|
+
|
|
168
|
+
<!-- Content area -->
|
|
169
|
+
{#if content && steps[current]}
|
|
170
|
+
<div data-stepper-content>
|
|
171
|
+
{@render content(steps[current], current)}
|
|
172
|
+
</div>
|
|
173
|
+
{/if}
|
|
174
|
+
</div>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// @ts-nocheck
|
|
3
|
+
import { Wrapper, ProxyTree, messages } from '@rokkit/states'
|
|
4
|
+
import { Navigator } from '@rokkit/actions'
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
options = [],
|
|
8
|
+
fields: userFields = {},
|
|
9
|
+
value = $bindable(),
|
|
10
|
+
multiple = false,
|
|
11
|
+
shape = 'square',
|
|
12
|
+
size = 'md',
|
|
13
|
+
disabled = false,
|
|
14
|
+
label = messages.swatch?.label ?? 'Select',
|
|
15
|
+
class: className = '',
|
|
16
|
+
onchange,
|
|
17
|
+
item: itemSnippet,
|
|
18
|
+
..._rest
|
|
19
|
+
} = $props()
|
|
20
|
+
|
|
21
|
+
const proxyTree = $derived(new ProxyTree(options, userFields))
|
|
22
|
+
const wrapper = $derived(new Wrapper(proxyTree, { onselect: handleSelect }))
|
|
23
|
+
|
|
24
|
+
let containerRef = $state<HTMLElement | null>(null)
|
|
25
|
+
|
|
26
|
+
$effect(() => {
|
|
27
|
+
if (!containerRef) return
|
|
28
|
+
const nav = new Navigator(containerRef, wrapper, { orientation: 'horizontal' })
|
|
29
|
+
return () => nav.destroy()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
$effect(() => {
|
|
33
|
+
wrapper.moveToValue(value)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
function isSelected(proxy) {
|
|
37
|
+
if (multiple && Array.isArray(value)) return value.includes(proxy.value)
|
|
38
|
+
return proxy.value === value
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toggleMultiValue(extracted, original) {
|
|
42
|
+
const arr = Array.isArray(value) ? [...value] : []
|
|
43
|
+
const idx = arr.indexOf(extracted)
|
|
44
|
+
if (idx >= 0) arr.splice(idx, 1)
|
|
45
|
+
else arr.push(extracted)
|
|
46
|
+
value = arr
|
|
47
|
+
onchange?.(value, original)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function selectSingleValue(extracted, original) {
|
|
51
|
+
if (extracted === value) return
|
|
52
|
+
value = extracted
|
|
53
|
+
onchange?.(extracted, original)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function handleSelect(extracted, proxy) {
|
|
57
|
+
if (proxy.disabled || disabled) return
|
|
58
|
+
if (multiple) toggleMultiValue(extracted, proxy.original)
|
|
59
|
+
else selectSingleValue(extracted, proxy.original)
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<div
|
|
64
|
+
bind:this={containerRef}
|
|
65
|
+
data-swatch
|
|
66
|
+
data-swatch-size={size}
|
|
67
|
+
data-swatch-shape={shape}
|
|
68
|
+
data-swatch-disabled={disabled || undefined}
|
|
69
|
+
data-swatch-multiple={multiple || undefined}
|
|
70
|
+
role={multiple ? 'group' : 'radiogroup'}
|
|
71
|
+
aria-label={label}
|
|
72
|
+
aria-disabled={disabled || undefined}
|
|
73
|
+
class={className || undefined}
|
|
74
|
+
>
|
|
75
|
+
{#each wrapper.flatView as node (node.key)}
|
|
76
|
+
{@const proxy = node.proxy}
|
|
77
|
+
{@const sel = isSelected(proxy)}
|
|
78
|
+
{@const fill = proxy.get(userFields.fill ?? 'fill')}
|
|
79
|
+
{@const stroke = proxy.get(userFields.stroke ?? 'stroke')}
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
data-swatch-item
|
|
83
|
+
data-path={node.key}
|
|
84
|
+
data-selected={sel || undefined}
|
|
85
|
+
data-disabled={proxy.disabled || undefined}
|
|
86
|
+
role={multiple ? 'checkbox' : 'radio'}
|
|
87
|
+
aria-checked={sel}
|
|
88
|
+
aria-label={proxy.label}
|
|
89
|
+
title={proxy.label}
|
|
90
|
+
disabled={proxy.disabled || disabled}
|
|
91
|
+
style={fill ? `--swatch-fill:${fill};--swatch-stroke:${stroke ?? fill}` : undefined}
|
|
92
|
+
>
|
|
93
|
+
{#if itemSnippet}
|
|
94
|
+
{@render itemSnippet(proxy, sel)}
|
|
95
|
+
{:else if shape === 'circle'}
|
|
96
|
+
<span data-swatch-circle></span>
|
|
97
|
+
{:else}
|
|
98
|
+
<span data-swatch-square></span>
|
|
99
|
+
{/if}
|
|
100
|
+
</button>
|
|
101
|
+
{/each}
|
|
102
|
+
</div>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { SwitchProps, SwitchItem } from '../types/switch.js'
|
|
3
|
+
import { ProxyItem } from '@rokkit/states'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_OPTIONS: [SwitchItem, SwitchItem] = [false, true]
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
options = DEFAULT_OPTIONS,
|
|
9
|
+
fields: userFields,
|
|
10
|
+
value = $bindable(),
|
|
11
|
+
onchange,
|
|
12
|
+
showLabels = false,
|
|
13
|
+
size = 'md',
|
|
14
|
+
disabled = false,
|
|
15
|
+
class: className = ''
|
|
16
|
+
}: SwitchProps = $props()
|
|
17
|
+
|
|
18
|
+
let offProxy = $derived(new ProxyItem(options[0], userFields))
|
|
19
|
+
let onProxy = $derived(new ProxyItem(options[1], userFields))
|
|
20
|
+
let isChecked = $derived(value === onProxy.value)
|
|
21
|
+
let currentProxy = $derived(isChecked ? onProxy : offProxy)
|
|
22
|
+
|
|
23
|
+
function toggle() {
|
|
24
|
+
if (disabled) return
|
|
25
|
+
const next = isChecked ? offProxy : onProxy
|
|
26
|
+
const nextValue = next.value
|
|
27
|
+
value = nextValue
|
|
28
|
+
onchange?.(nextValue, next.original as SwitchItem)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shouldToggle(key: string): boolean {
|
|
32
|
+
if (key === ' ' || key === 'Enter') return true
|
|
33
|
+
if (key === 'ArrowRight') return !isChecked
|
|
34
|
+
if (key === 'ArrowLeft') return isChecked
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
39
|
+
if (disabled) return
|
|
40
|
+
if (shouldToggle(event.key)) {
|
|
41
|
+
event.preventDefault()
|
|
42
|
+
toggle()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
role="switch"
|
|
50
|
+
data-switch
|
|
51
|
+
data-switch-size={size}
|
|
52
|
+
data-switch-disabled={disabled || undefined}
|
|
53
|
+
aria-checked={isChecked}
|
|
54
|
+
aria-label={currentProxy.label || undefined}
|
|
55
|
+
title={currentProxy.get('subtext') ?? currentProxy.label ?? undefined}
|
|
56
|
+
{disabled}
|
|
57
|
+
class={className || undefined}
|
|
58
|
+
onclick={toggle}
|
|
59
|
+
onkeydown={handleKeyDown}
|
|
60
|
+
>
|
|
61
|
+
<span data-switch-track>
|
|
62
|
+
<span data-switch-thumb>
|
|
63
|
+
{#if currentProxy.get('icon')}
|
|
64
|
+
<span data-switch-icon class={currentProxy.get('icon')} aria-hidden="true"></span>
|
|
65
|
+
{/if}
|
|
66
|
+
</span>
|
|
67
|
+
</span>
|
|
68
|
+
{#if showLabels && currentProxy.label}
|
|
69
|
+
<span data-switch-label>{currentProxy.label}</span>
|
|
70
|
+
{/if}
|
|
71
|
+
</button>
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { TableProps, TableColumn, TableSortIcons } from '../types/table.js'
|
|
3
|
+
import { defaultTableSortIcons } from '../types/table.js'
|
|
4
|
+
import { TableController, messages } from '@rokkit/states'
|
|
5
|
+
import { navigator } from '@rokkit/actions'
|
|
6
|
+
import { untrack } from 'svelte'
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
data = [],
|
|
10
|
+
columns: userColumns,
|
|
11
|
+
value,
|
|
12
|
+
values = $bindable<unknown[]>([]),
|
|
13
|
+
selectable = 'single' as 'single' | 'multi' | false,
|
|
14
|
+
caption,
|
|
15
|
+
size = 'md',
|
|
16
|
+
striped = false,
|
|
17
|
+
responsive = false,
|
|
18
|
+
disabled = false,
|
|
19
|
+
fields: userFields,
|
|
20
|
+
onselect,
|
|
21
|
+
onsort,
|
|
22
|
+
class: className = '',
|
|
23
|
+
icons: userIcons,
|
|
24
|
+
header: headerSnippet,
|
|
25
|
+
row: rowSnippet,
|
|
26
|
+
cell: cellSnippet,
|
|
27
|
+
empty: emptySnippet
|
|
28
|
+
}: TableProps = $props()
|
|
29
|
+
|
|
30
|
+
const icons = $derived<TableSortIcons>({ ...defaultTableSortIcons, ...userIcons })
|
|
31
|
+
|
|
32
|
+
// ─── Controller ─────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
let controller = untrack(
|
|
35
|
+
() =>
|
|
36
|
+
new TableController(data, {
|
|
37
|
+
columns: userColumns,
|
|
38
|
+
fields: userFields,
|
|
39
|
+
value,
|
|
40
|
+
multiselect: selectable === 'multi'
|
|
41
|
+
})
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// Sync values binding from controller selection in multi-select mode
|
|
45
|
+
$effect(() => {
|
|
46
|
+
if (selectable === 'multi') {
|
|
47
|
+
values = controller.selected.slice()
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
let tableRef = $state<HTMLElement | null>(null)
|
|
51
|
+
|
|
52
|
+
// Sync data changes to controller
|
|
53
|
+
$effect(() => {
|
|
54
|
+
controller.update(data)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Sync columns changes
|
|
58
|
+
$effect(() => {
|
|
59
|
+
if (userColumns) {
|
|
60
|
+
controller.columns = userColumns.map((c) => ({
|
|
61
|
+
sortable: true,
|
|
62
|
+
sorted: 'none',
|
|
63
|
+
...c
|
|
64
|
+
}))
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// ─── Focus management ───────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
function focusByKey(el: HTMLElement, key: string) {
|
|
71
|
+
const target = el.querySelector(`[data-path="${key}"]`) as HTMLElement | null
|
|
72
|
+
if (target && target !== document.activeElement) {
|
|
73
|
+
target.focus()
|
|
74
|
+
target.scrollIntoView?.({ block: 'nearest', inline: 'nearest' })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
$effect(() => {
|
|
79
|
+
if (!tableRef) return
|
|
80
|
+
const el = tableRef
|
|
81
|
+
|
|
82
|
+
function onAction(event: Event) {
|
|
83
|
+
const detail = (event as CustomEvent).detail
|
|
84
|
+
if (detail.name === 'move') {
|
|
85
|
+
const key = controller.focusedKey
|
|
86
|
+
if (key) focusByKey(el, key)
|
|
87
|
+
}
|
|
88
|
+
if (detail.name === 'select') handleSelectAction()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
el.addEventListener('action', onAction)
|
|
92
|
+
return () => el.removeEventListener('action', onAction)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
function handleFocusIn(event: FocusEvent) {
|
|
96
|
+
const target = event.target as HTMLElement
|
|
97
|
+
if (!target) return
|
|
98
|
+
const path = target.dataset.path
|
|
99
|
+
if (path !== undefined) {
|
|
100
|
+
controller.moveTo(path)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function handleSelectAction() {
|
|
105
|
+
if (selectable === false || disabled) return
|
|
106
|
+
|
|
107
|
+
const key = controller.focusedKey
|
|
108
|
+
if (!key) return
|
|
109
|
+
|
|
110
|
+
const proxy = controller.lookup.get(key)
|
|
111
|
+
if (!proxy) return
|
|
112
|
+
|
|
113
|
+
onselect?.(proxy.value, proxy.value as Record<string, unknown>)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Sort ───────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function handleSort(event: MouseEvent, column: TableColumn) {
|
|
119
|
+
if (column.sortable === false || disabled) return
|
|
120
|
+
controller.sortBy(column.name, event.shiftKey)
|
|
121
|
+
onsort?.(controller.sortState)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── Cell rendering helpers ─────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function getCellValue(row: Record<string, unknown>, column: TableColumn): unknown {
|
|
127
|
+
const fieldName = column.fields?.text ?? column.name
|
|
128
|
+
return row[fieldName]
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatCellValue(row: Record<string, unknown>, column: TableColumn): string {
|
|
132
|
+
const value = getCellValue(row, column)
|
|
133
|
+
if (column.formatter) return column.formatter(value, row)
|
|
134
|
+
return value !== null && value !== undefined ? String(value) : ''
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getCellIcon(row: Record<string, unknown>, column: TableColumn): string | null {
|
|
138
|
+
if (!column.fields?.icon) return null
|
|
139
|
+
const iconValue = row[column.fields.icon]
|
|
140
|
+
if (!iconValue) return null
|
|
141
|
+
if (column.iconFormatter) return column.iconFormatter(iconValue)
|
|
142
|
+
return String(iconValue)
|
|
143
|
+
}
|
|
144
|
+
</script>
|
|
145
|
+
|
|
146
|
+
<div
|
|
147
|
+
bind:this={tableRef}
|
|
148
|
+
data-table
|
|
149
|
+
data-size={size}
|
|
150
|
+
data-selectable={selectable || undefined}
|
|
151
|
+
data-disabled={disabled || undefined}
|
|
152
|
+
data-table-responsive={responsive || undefined}
|
|
153
|
+
class={className || undefined}
|
|
154
|
+
onfocusin={handleFocusIn}
|
|
155
|
+
use:navigator={{ wrapper: controller, orientation: 'vertical' }}
|
|
156
|
+
>
|
|
157
|
+
<table role="grid" aria-label={caption} data-table-striped={striped || undefined}>
|
|
158
|
+
{#if caption}
|
|
159
|
+
<caption data-table-caption>{caption}</caption>
|
|
160
|
+
{/if}
|
|
161
|
+
|
|
162
|
+
<thead data-table-header>
|
|
163
|
+
{#if headerSnippet}
|
|
164
|
+
{@render headerSnippet(controller.columns, controller.sortState)}
|
|
165
|
+
{:else}
|
|
166
|
+
<tr>
|
|
167
|
+
{#each controller.columns as column (column.name)}
|
|
168
|
+
<th
|
|
169
|
+
data-table-header-cell
|
|
170
|
+
data-column={column.name}
|
|
171
|
+
data-sortable={column.sortable !== false || undefined}
|
|
172
|
+
data-sort-order={column.sorted ?? 'none'}
|
|
173
|
+
scope="col"
|
|
174
|
+
aria-sort={column.sorted === 'ascending'
|
|
175
|
+
? 'ascending'
|
|
176
|
+
: column.sorted === 'descending'
|
|
177
|
+
? 'descending'
|
|
178
|
+
: 'none'}
|
|
179
|
+
style:width={column.width}
|
|
180
|
+
style:text-align={column.align}
|
|
181
|
+
onclick={(e) => handleSort(e, column)}
|
|
182
|
+
>
|
|
183
|
+
<span data-table-header-text>{column.label ?? column.name}</span>
|
|
184
|
+
{#if column.sortable !== false}
|
|
185
|
+
{@const sortIcon = icons[column.sorted ?? 'none'] ?? icons.none}
|
|
186
|
+
<span data-table-sort-icon class={sortIcon} aria-hidden="true"></span>
|
|
187
|
+
{/if}
|
|
188
|
+
</th>
|
|
189
|
+
{/each}
|
|
190
|
+
</tr>
|
|
191
|
+
{/if}
|
|
192
|
+
</thead>
|
|
193
|
+
|
|
194
|
+
<tbody data-table-body>
|
|
195
|
+
{#if controller.data.length === 0}
|
|
196
|
+
{#if emptySnippet}
|
|
197
|
+
<tr data-table-empty-row>
|
|
198
|
+
<td colspan={controller.columns.length}>
|
|
199
|
+
{@render emptySnippet()}
|
|
200
|
+
</td>
|
|
201
|
+
</tr>
|
|
202
|
+
{:else}
|
|
203
|
+
<tr data-table-empty-row>
|
|
204
|
+
<td data-table-empty colspan={controller.columns.length}>{messages.table.empty}</td>
|
|
205
|
+
</tr>
|
|
206
|
+
{/if}
|
|
207
|
+
{:else}
|
|
208
|
+
{#each controller.data as entry, rowIndex (entry.key)}
|
|
209
|
+
{@const row = entry.value as Record<string, unknown>}
|
|
210
|
+
{@const isSelected = controller.selectedKeys.has(entry.key)}
|
|
211
|
+
{@const isFocused = controller.focusedKey === entry.key}
|
|
212
|
+
{#if rowSnippet}
|
|
213
|
+
{@render rowSnippet(row, controller.columns, rowIndex, isSelected)}
|
|
214
|
+
{:else}
|
|
215
|
+
<tr
|
|
216
|
+
data-table-row
|
|
217
|
+
data-path={entry.key}
|
|
218
|
+
data-selected={isSelected || undefined}
|
|
219
|
+
data-focused={isFocused || undefined}
|
|
220
|
+
aria-selected={isSelected}
|
|
221
|
+
aria-rowindex={rowIndex + 1}
|
|
222
|
+
tabindex={isFocused ? 0 : -1}
|
|
223
|
+
>
|
|
224
|
+
{#each controller.columns as column (column.name)}
|
|
225
|
+
{#if cellSnippet}
|
|
226
|
+
<td data-table-cell data-column={column.name} data-label={column.label ?? column.name} style:text-align={column.align}>
|
|
227
|
+
{@render cellSnippet(getCellValue(row, column), column, row)}
|
|
228
|
+
</td>
|
|
229
|
+
{:else}
|
|
230
|
+
{@const cellIcon = getCellIcon(row, column)}
|
|
231
|
+
<td data-table-cell data-column={column.name} data-label={column.label ?? column.name} style:text-align={column.align}>
|
|
232
|
+
{#if cellIcon}
|
|
233
|
+
<span data-cell-icon class={cellIcon} aria-hidden="true"></span>
|
|
234
|
+
{/if}
|
|
235
|
+
<span data-cell-value>{formatCellValue(row, column)}</span>
|
|
236
|
+
</td>
|
|
237
|
+
{/if}
|
|
238
|
+
{/each}
|
|
239
|
+
</tr>
|
|
240
|
+
{/if}
|
|
241
|
+
{/each}
|
|
242
|
+
{/if}
|
|
243
|
+
</tbody>
|
|
244
|
+
</table>
|
|
245
|
+
</div>
|