@svgrid/grid 2.2.25 → 2.2.27
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 +199 -145
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +179 -179
- package/dist/GridMenus.svelte +734 -728
- package/dist/SvAutoComplete.svelte +165 -165
- package/dist/SvCalendar.svelte +503 -503
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvCheckBox.svelte +101 -101
- package/dist/SvColorInput.svelte +181 -181
- package/dist/SvComboBox.svelte +275 -275
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +160 -160
- package/dist/SvDrawer.svelte +250 -250
- package/dist/SvDropDownList.svelte +374 -374
- package/dist/SvDurationInput.svelte +126 -126
- package/dist/SvField.svelte +293 -293
- package/dist/SvForm.svelte +437 -437
- package/dist/SvGrid.css +2723 -2719
- package/dist/SvGrid.svelte +3333 -3333
- package/dist/SvGridChart.svelte +1724 -1724
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridChartView.svelte +70 -70
- package/dist/SvGridDropdown.svelte +696 -696
- package/dist/SvGridSelect.svelte +259 -259
- package/dist/SvGroupCell.svelte +109 -109
- package/dist/SvListBox.svelte +287 -287
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +281 -281
- package/dist/SvNumberInput.svelte +168 -168
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPasswordInput.svelte +151 -151
- package/dist/SvPhoneInput.svelte +131 -131
- package/dist/SvPopover.svelte +197 -197
- package/dist/SvRadioGroup.svelte +107 -107
- package/dist/SvRowGroupPanel.svelte +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +200 -200
- package/dist/SvSwitchButton.svelte +107 -107
- package/dist/SvTagsInput.svelte +112 -112
- package/dist/SvTextInput.svelte +147 -147
- package/dist/SvTimePicker.svelte +245 -245
- package/dist/SvToaster.svelte +159 -159
- package/dist/SvToggleButton.svelte +85 -85
- package/dist/SvTooltip.svelte +161 -161
- package/dist/SvTour.svelte +204 -204
- package/dist/SvTree.svelte +437 -437
- package/dist/SvTreeSelect.svelte +233 -233
- package/dist/a11y/dismissable.d.ts +11 -0
- package/dist/a11y/dismissable.js +21 -0
- package/dist/ai.js +3 -3
- package/dist/cdn/GridMenus-BzxEYNtW.js +421 -0
- package/dist/cdn/GridMenus-Dr-nGEXn.js +417 -0
- package/dist/cdn/{SvDateTimePicker-CYC7gRER.js → SvDateTimePicker-B1sDc9Pz.js} +451 -444
- package/dist/cdn/{SvDateTimePicker-DtQKd4Ns.js → SvDateTimePicker-Bh0FTs1E.js} +114 -107
- package/dist/cdn/{SvGridChart-BhnFdLd0.js → SvGridChart-BKQsO5fZ.js} +1 -1
- package/dist/cdn/{SvGridChart-BcLCYvw9.js → SvGridChart-Bgk4hDs8.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-DvjwxLvK.js → SvGridChartPanel-DSkHIQDd.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-Cmx9DHAS.js → SvGridChartPanel-DkCc2Z7b.js} +1 -1
- package/dist/cdn/{SvGridChartView-B40DTJSX.js → SvGridChartView-BrbVaELm.js} +1 -1
- package/dist/cdn/{SvGridChartView-PwqI4s02.js → SvGridChartView-i9rWvSKg.js} +1 -1
- package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-CGxxopQ4.js} +1 -1
- package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D08sVmAy.js} +1 -1
- package/dist/cdn/{src-DysW9qbk.js → src-Cw8d9U8j.js} +5753 -5753
- package/dist/cdn/{src-bYfr_7aR.js → src-cgs1wcUS.js} +8102 -8102
- package/dist/cdn/svgrid.js +9 -9
- package/dist/cdn/svgrid.svelte-external.js +9 -9
- package/dist/chart-export.js +8 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +30 -13
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +179 -179
- package/src/GridMenus.svelte +734 -728
- package/src/SvAutoComplete.svelte +165 -165
- package/src/SvCalendar.svelte +503 -503
- package/src/SvCalendar.test.ts +226 -226
- package/src/SvCarousel.svelte +141 -141
- package/src/SvCheckBox.svelte +101 -101
- package/src/SvColorInput.svelte +181 -181
- package/src/SvComboBox.svelte +275 -275
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +160 -160
- package/src/SvDrawer.svelte +250 -250
- package/src/SvDropDownList.svelte +374 -374
- package/src/SvDurationInput.svelte +126 -126
- package/src/SvField.svelte +293 -293
- package/src/SvForm.svelte +437 -437
- package/src/SvForm.test.ts +411 -411
- package/src/SvGrid.controller.svelte.ts +3239 -3239
- package/src/SvGrid.css +2723 -2719
- package/src/SvGrid.svelte +3333 -3333
- package/src/SvGrid.types.ts +1613 -1613
- package/src/SvGridChart.svelte +1724 -1724
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridChartView.svelte +70 -70
- package/src/SvGridDropdown.svelte +696 -696
- package/src/SvGridSelect.svelte +259 -259
- package/src/SvGroupCell.svelte +109 -109
- package/src/SvListBox.svelte +287 -287
- package/src/SvMaskedInput.svelte +122 -122
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -97
- package/src/SvMenuList.svelte +146 -146
- package/src/SvMultiSelect.svelte +281 -281
- package/src/SvNumberInput.svelte +168 -168
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPasswordInput.svelte +151 -151
- package/src/SvPhoneInput.svelte +131 -131
- package/src/SvPopover.svelte +197 -197
- package/src/SvRadioGroup.svelte +107 -107
- package/src/SvRowGroupPanel.svelte +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +200 -200
- package/src/SvSwitchButton.svelte +107 -107
- package/src/SvTagsInput.svelte +112 -112
- package/src/SvTextInput.svelte +147 -147
- package/src/SvTimePicker.svelte +245 -245
- package/src/SvToaster.svelte +159 -159
- package/src/SvToaster.test.ts +95 -95
- package/src/SvToggleButton.svelte +85 -85
- package/src/SvTooltip.svelte +161 -161
- package/src/SvTour.svelte +204 -204
- package/src/SvTree.svelte +437 -437
- package/src/SvTreeSelect.svelte +233 -233
- package/src/a11y/dismissable.test.ts +119 -95
- package/src/a11y/dismissable.ts +114 -94
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/ai.test.ts +502 -502
- package/src/ai.ts +1391 -1391
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +895 -895
- package/src/builtin-editors.grid.test.ts +85 -85
- package/src/cell-formatting.ts +169 -169
- package/src/cell-render.ts +469 -469
- package/src/chart-export.ts +201 -201
- package/src/chart-view.svelte.ts +36 -36
- package/src/chart.ts +2296 -2296
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1111 -1111
- package/src/createAutocomplete.svelte.ts +132 -132
- package/src/createCombobox.svelte.ts +191 -191
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +168 -168
- package/src/createForm.svelte.ts +386 -386
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/createMenu.svelte.ts +225 -225
- package/src/createPopoverSelect.svelte.ts +213 -213
- package/src/createSlider.svelte.ts +191 -191
- package/src/createTooltip.svelte.ts +144 -144
- package/src/createTree.svelte.ts +319 -319
- package/src/editing.test.ts +859 -859
- package/src/editing.ts +675 -675
- package/src/editor-contract.ts +157 -157
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +598 -598
- package/src/filter-operators.ts +157 -157
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/index.ts +713 -712
- package/src/js-scroller.svelte.ts +173 -173
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/list-nav.test.ts +49 -49
- package/src/list-nav.ts +29 -29
- package/src/list-option.test.ts +56 -56
- package/src/list-option.ts +172 -172
- package/src/menus.test.ts +700 -700
- package/src/menus.ts +652 -652
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/render-component.ts +28 -28
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +872 -872
- package/src/selection.test.ts +754 -754
- package/src/selection.ts +600 -600
- package/src/server-data-source.test.ts +289 -289
- package/src/server-data-source.ts +413 -413
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +489 -489
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/svgrid-wrapper.types.ts +524 -524
- package/src/svgrid.behavior.test.ts +706 -706
- package/src/svgrid.charting.test.ts +534 -534
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.filter-menu-scroll.test.ts +102 -0
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.wrapper.test.ts +40 -40
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +215 -215
- package/src/toast-store.svelte.ts +246 -246
- package/src/toast-store.test.ts +147 -147
- package/src/ui-buttons.test.ts +144 -144
- package/src/ui-inputs.test.ts +118 -118
- package/src/ui-range.test.ts +70 -70
- package/src/ui-selection.test.ts +155 -155
- package/src/ui-tier1.test.ts +142 -142
- package/src/virtual.test.ts +88 -88
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +296 -296
- package/themes/ember.css +71 -71
- package/dist/cdn/GridMenus-GfCAHgkZ.js +0 -420
- package/dist/cdn/GridMenus-GiVNSeDU.js +0 -416
package/dist/SvTimePicker.svelte
CHANGED
|
@@ -1,245 +1,245 @@
|
|
|
1
|
-
<script lang="ts" module>
|
|
2
|
-
export type { TimeValue } from './createTimePicker.svelte'
|
|
3
|
-
</script>
|
|
4
|
-
|
|
5
|
-
<script lang="ts">
|
|
6
|
-
/**
|
|
7
|
-
* SvTimePicker - an analog clock-dial time picker (parity: Smart
|
|
8
|
-
* `smart-time-picker`). 12/24-hour, minute snapping, hour->minute auto-switch,
|
|
9
|
-
* pointer-drag + keyboard. It is a styled renderer over the headless
|
|
10
|
-
* `createTimePicker` core: the core owns the value math, dial geometry, keyboard
|
|
11
|
-
* and ARIA; this component keeps the DOM-bound pointer capture + hit-testing.
|
|
12
|
-
* All visuals come from `--sg-*` tokens so every theme applies for free.
|
|
13
|
-
*/
|
|
14
|
-
import SvField from './SvField.svelte'
|
|
15
|
-
import { nextEditorId, resolveMessages, type EditorDir, type EditorSize } from './editor-contract'
|
|
16
|
-
import { createTimePicker, type TimeValue, type TimeFormat, type TimeSelection } from './createTimePicker.svelte'
|
|
17
|
-
|
|
18
|
-
/** User-facing strings (localizable via `messages`). */
|
|
19
|
-
type TimeMessages = { label: string; am: string; pm: string; now: string }
|
|
20
|
-
const DEFAULT_MESSAGES: TimeMessages = { label: 'Time picker', am: 'AM', pm: 'PM', now: 'Now' }
|
|
21
|
-
|
|
22
|
-
type Props = {
|
|
23
|
-
/** Date, "HH:MM[:SS]" string, or epoch ms. */
|
|
24
|
-
value?: TimeValue
|
|
25
|
-
/** Fires with a Date (today's date carrying the picked time). */
|
|
26
|
-
onChange?: (value: Date) => void
|
|
27
|
-
format?: TimeFormat
|
|
28
|
-
minuteInterval?: number
|
|
29
|
-
/** After picking an hour, jump the dial to minutes. Default true. */
|
|
30
|
-
autoSwitchToMinutes?: boolean
|
|
31
|
-
/** Show the Now footer button. */
|
|
32
|
-
footer?: boolean
|
|
33
|
-
disabled?: boolean
|
|
34
|
-
readonly?: boolean
|
|
35
|
-
name?: string
|
|
36
|
-
/** Which dial opens first. */
|
|
37
|
-
selection?: TimeSelection
|
|
38
|
-
/** Text direction (rtl mirrors layout; auto/undefined inherits). */
|
|
39
|
-
dir?: EditorDir
|
|
40
|
-
/** Override the built-in strings (group label + AM/PM/Now). */
|
|
41
|
-
messages?: Partial<TimeMessages>
|
|
42
|
-
// Optional field chrome: when any of label/hint/error is set, the dial is
|
|
43
|
-
// wrapped in <SvField> so it gains a label/hint/error line; otherwise it
|
|
44
|
-
// renders bare (as embedded inside SvDateTimePicker's popover).
|
|
45
|
-
label?: string
|
|
46
|
-
hint?: string
|
|
47
|
-
error?: string
|
|
48
|
-
required?: boolean
|
|
49
|
-
invalid?: boolean
|
|
50
|
-
size?: EditorSize
|
|
51
|
-
id?: string
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
let {
|
|
55
|
-
value = null,
|
|
56
|
-
onChange,
|
|
57
|
-
format = '24-hour',
|
|
58
|
-
minuteInterval = 1,
|
|
59
|
-
autoSwitchToMinutes = true,
|
|
60
|
-
footer = false,
|
|
61
|
-
disabled = false,
|
|
62
|
-
readonly = false,
|
|
63
|
-
name,
|
|
64
|
-
selection = 'hour',
|
|
65
|
-
dir,
|
|
66
|
-
messages,
|
|
67
|
-
label,
|
|
68
|
-
hint,
|
|
69
|
-
error,
|
|
70
|
-
required = false,
|
|
71
|
-
invalid = false,
|
|
72
|
-
id,
|
|
73
|
-
}: Props = $props()
|
|
74
|
-
|
|
75
|
-
const autoId = nextEditorId('sv-tp')
|
|
76
|
-
const uid = $derived(id ?? autoId)
|
|
77
|
-
const hasChrome = $derived(!!(label || hint || error))
|
|
78
|
-
|
|
79
|
-
const M = $derived(resolveMessages(DEFAULT_MESSAGES, messages))
|
|
80
|
-
const resolvedDir = $derived(dir === 'ltr' || dir === 'rtl' ? dir : undefined)
|
|
81
|
-
|
|
82
|
-
// The headless core owns all state, geometry, keyboard + ARIA. Reactive inputs
|
|
83
|
-
// are getters; callbacks are closures.
|
|
84
|
-
const tp = createTimePicker({
|
|
85
|
-
value: () => value,
|
|
86
|
-
onChange: (d) => onChange?.(d),
|
|
87
|
-
format: () => format,
|
|
88
|
-
minuteInterval: () => minuteInterval,
|
|
89
|
-
autoSwitchToMinutes: () => autoSwitchToMinutes,
|
|
90
|
-
disabled: () => disabled,
|
|
91
|
-
readonly: () => readonly,
|
|
92
|
-
selection: () => selection,
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
// --- Pointer drag on the dial (DOM-bound; stays in the component) -----------
|
|
96
|
-
// The core exposes SIZE/C + a pure `pointerSelect(angle, dist)`; here we only
|
|
97
|
-
// turn a pointer event into that angle via the live element rect.
|
|
98
|
-
let dialEl: SVGSVGElement | null = null
|
|
99
|
-
let dragging = false
|
|
100
|
-
|
|
101
|
-
function angleFromEvent(e: PointerEvent): { angle: number; dist: number } {
|
|
102
|
-
const rect = dialEl!.getBoundingClientRect()
|
|
103
|
-
const scale = tp.SIZE / rect.width
|
|
104
|
-
const x = (e.clientX - rect.left) * scale - tp.C
|
|
105
|
-
const y = (e.clientY - rect.top) * scale - tp.C
|
|
106
|
-
let deg = (Math.atan2(y, x) * 180) / Math.PI + 90
|
|
107
|
-
if (deg < 0) deg += 360
|
|
108
|
-
return { angle: deg, dist: Math.hypot(x, y) }
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function onDialPointerDown(e: PointerEvent) {
|
|
112
|
-
if (!tp.isInteractive) return
|
|
113
|
-
dragging = true
|
|
114
|
-
dialEl?.setPointerCapture(e.pointerId)
|
|
115
|
-
const { angle, dist } = angleFromEvent(e)
|
|
116
|
-
tp.pointerSelect(angle, dist)
|
|
117
|
-
}
|
|
118
|
-
function onDialPointerMove(e: PointerEvent) {
|
|
119
|
-
if (!dragging) return
|
|
120
|
-
const { angle, dist } = angleFromEvent(e)
|
|
121
|
-
tp.pointerSelect(angle, dist)
|
|
122
|
-
}
|
|
123
|
-
function onDialPointerUp(e: PointerEvent) {
|
|
124
|
-
if (!dragging) return
|
|
125
|
-
dragging = false
|
|
126
|
-
dialEl?.releasePointerCapture(e.pointerId)
|
|
127
|
-
tp.endPointer()
|
|
128
|
-
}
|
|
129
|
-
</script>
|
|
130
|
-
|
|
131
|
-
{#snippet dialBody()}
|
|
132
|
-
<div class="sv-tp" class:sv-tp--disabled={disabled} class:sv-tp--invalid={invalid} id={uid} role="group" aria-label={label ?? M.label} dir={resolvedDir}>
|
|
133
|
-
<div class="sv-tp__head">
|
|
134
|
-
<button class="sv-tp__seg" class:is-active={tp.selection === 'hour'} {...tp.segProps('hour')}>
|
|
135
|
-
{String(tp.displayHour).padStart(2, '0')}
|
|
136
|
-
</button>
|
|
137
|
-
<span class="sv-tp__colon">:</span>
|
|
138
|
-
<button class="sv-tp__seg" class:is-active={tp.selection === 'minute'} {...tp.segProps('minute')}>
|
|
139
|
-
{tp.mm}
|
|
140
|
-
</button>
|
|
141
|
-
{#if tp.is12}
|
|
142
|
-
<div class="sv-tp__ampm">
|
|
143
|
-
<button class:is-active={!tp.isPm} {...tp.ampmProps(false)}>{M.am}</button>
|
|
144
|
-
<button class:is-active={tp.isPm} {...tp.ampmProps(true)}>{M.pm}</button>
|
|
145
|
-
</div>
|
|
146
|
-
{/if}
|
|
147
|
-
</div>
|
|
148
|
-
|
|
149
|
-
<svg
|
|
150
|
-
bind:this={dialEl}
|
|
151
|
-
class="sv-tp__dial"
|
|
152
|
-
viewBox={`0 0 ${tp.SIZE} ${tp.SIZE}`}
|
|
153
|
-
width="100%"
|
|
154
|
-
{...tp.dialProps()}
|
|
155
|
-
onpointerdown={onDialPointerDown}
|
|
156
|
-
onpointermove={onDialPointerMove}
|
|
157
|
-
onpointerup={onDialPointerUp}
|
|
158
|
-
onpointercancel={onDialPointerUp}
|
|
159
|
-
>
|
|
160
|
-
<circle class="sv-tp__face" cx={tp.C} cy={tp.C} r={tp.C - 4} />
|
|
161
|
-
<!-- hand -->
|
|
162
|
-
<line class="sv-tp__hand" x1={tp.C} y1={tp.C} x2={tp.handEnd.x} y2={tp.handEnd.y} />
|
|
163
|
-
<circle class="sv-tp__hub" cx={tp.C} cy={tp.C} r="3" />
|
|
164
|
-
<circle class="sv-tp__knob" cx={tp.handEnd.x} cy={tp.handEnd.y} r="15" />
|
|
165
|
-
{#if tp.selection === 'hour'}
|
|
166
|
-
{#each tp.hourTicks as t (t.ring + '-' + t.value)}
|
|
167
|
-
<text class="sv-tp__num" class:is-active={tp.isActiveHour(t)} class:is-inner={t.ring === 'inner'} x={t.x} y={t.y} dominant-baseline="central" text-anchor="middle">{t.label}</text>
|
|
168
|
-
{/each}
|
|
169
|
-
{:else}
|
|
170
|
-
{#each tp.minuteTicks as t (t.value)}
|
|
171
|
-
<text class="sv-tp__num" class:is-active={t.value === tp.minutes} x={t.x} y={t.y} dominant-baseline="central" text-anchor="middle">{t.label}</text>
|
|
172
|
-
{/each}
|
|
173
|
-
{/if}
|
|
174
|
-
</svg>
|
|
175
|
-
|
|
176
|
-
{#if footer}
|
|
177
|
-
<div class="sv-tp__footer">
|
|
178
|
-
<button class="sv-tp__now" {...tp.nowProps()}>{M.now}</button>
|
|
179
|
-
</div>
|
|
180
|
-
{/if}
|
|
181
|
-
|
|
182
|
-
{#if name}<input type="hidden" {name} value={`${String(tp.hours).padStart(2, '0')}:${tp.mm}`} />{/if}
|
|
183
|
-
</div>
|
|
184
|
-
{/snippet}
|
|
185
|
-
|
|
186
|
-
{#if hasChrome}
|
|
187
|
-
<SvField id={uid} {label} {hint} {error} {required} {dir}>{@render dialBody()}</SvField>
|
|
188
|
-
{:else}
|
|
189
|
-
{@render dialBody()}
|
|
190
|
-
{/if}
|
|
191
|
-
|
|
192
|
-
<style>
|
|
193
|
-
.sv-tp {
|
|
194
|
-
--_accent: var(--sg-accent, #2563eb);
|
|
195
|
-
--_bg: var(--sg-bg, #fff);
|
|
196
|
-
--_fg: var(--sg-fg, #0f172a);
|
|
197
|
-
--_muted: var(--sg-muted, #64748b);
|
|
198
|
-
--_border: var(--sg-border, #e2e8f0);
|
|
199
|
-
--_face: var(--sg-header-bg, #f1f5f9);
|
|
200
|
-
--_radius: var(--sg-radius, 8px);
|
|
201
|
-
display: inline-flex; flex-direction: column; gap: 10px; align-items: center;
|
|
202
|
-
width: 260px; padding: 12px;
|
|
203
|
-
background: var(--_bg); color: var(--_fg);
|
|
204
|
-
border: 1px solid var(--_border); border-radius: calc(var(--_radius) + 4px);
|
|
205
|
-
user-select: none;
|
|
206
|
-
}
|
|
207
|
-
.sv-tp--invalid { border-color: var(--sg-danger, #dc2626); }
|
|
208
|
-
.sv-tp--disabled { opacity: 0.55; pointer-events: none; }
|
|
209
|
-
|
|
210
|
-
.sv-tp__head { display: flex; align-items: center; gap: 2px; font-variant-numeric: tabular-nums; }
|
|
211
|
-
.sv-tp__seg {
|
|
212
|
-
font-size: 30px; font-weight: 700; line-height: 1;
|
|
213
|
-
background: none; border: 0; color: var(--_muted); cursor: pointer;
|
|
214
|
-
padding: 2px 6px; border-radius: var(--_radius);
|
|
215
|
-
}
|
|
216
|
-
.sv-tp__seg.is-active { color: var(--_accent); background: color-mix(in srgb, var(--_accent) 12%, transparent); }
|
|
217
|
-
.sv-tp__colon { font-size: 28px; font-weight: 700; color: var(--_muted); }
|
|
218
|
-
.sv-tp__ampm { display: flex; flex-direction: column; margin-inline-start: 8px; gap: 2px; }
|
|
219
|
-
.sv-tp__ampm button {
|
|
220
|
-
font-size: 11px; font-weight: 700; padding: 3px 8px;
|
|
221
|
-
background: none; border: 1px solid var(--_border); border-radius: 6px; color: var(--_muted); cursor: pointer;
|
|
222
|
-
}
|
|
223
|
-
.sv-tp__ampm button.is-active { background: var(--_accent); color: var(--sg-on-accent, #fff); border-color: var(--_accent); }
|
|
224
|
-
|
|
225
|
-
.sv-tp__dial { touch-action: none; cursor: pointer; outline: none; display: block; width: 100%; height: auto; aspect-ratio: 1; }
|
|
226
|
-
.sv-tp__dial:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--_accent)); outline-offset: 2px; border-radius: 50%; }
|
|
227
|
-
.sv-tp__face { fill: var(--_face); }
|
|
228
|
-
.sv-tp__hand { stroke: var(--_accent); stroke-width: 2; }
|
|
229
|
-
.sv-tp__hub { fill: var(--_accent); }
|
|
230
|
-
.sv-tp__knob { fill: var(--_accent); opacity: 0.9; }
|
|
231
|
-
.sv-tp__num {
|
|
232
|
-
font-size: 13px; fill: var(--_fg); pointer-events: none;
|
|
233
|
-
font-family: inherit;
|
|
234
|
-
}
|
|
235
|
-
.sv-tp__num.is-inner { font-size: 11px; fill: var(--_muted); }
|
|
236
|
-
.sv-tp__num.is-active { fill: var(--sg-on-accent, #fff); font-weight: 700; }
|
|
237
|
-
|
|
238
|
-
.sv-tp__footer { width: 100%; display: flex; justify-content: center; border-top: 1px solid var(--_border); padding-top: 8px; }
|
|
239
|
-
.sv-tp__now {
|
|
240
|
-
padding: 5px 14px; font: inherit; font-size: 12px; font-weight: 600;
|
|
241
|
-
background: none; border: 1px solid var(--_border); border-radius: var(--_radius);
|
|
242
|
-
color: var(--_accent); cursor: pointer;
|
|
243
|
-
}
|
|
244
|
-
.sv-tp__now:hover { background: color-mix(in srgb, var(--_accent) 10%, transparent); }
|
|
245
|
-
</style>
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type { TimeValue } from './createTimePicker.svelte'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
/**
|
|
7
|
+
* SvTimePicker - an analog clock-dial time picker (parity: Smart
|
|
8
|
+
* `smart-time-picker`). 12/24-hour, minute snapping, hour->minute auto-switch,
|
|
9
|
+
* pointer-drag + keyboard. It is a styled renderer over the headless
|
|
10
|
+
* `createTimePicker` core: the core owns the value math, dial geometry, keyboard
|
|
11
|
+
* and ARIA; this component keeps the DOM-bound pointer capture + hit-testing.
|
|
12
|
+
* All visuals come from `--sg-*` tokens so every theme applies for free.
|
|
13
|
+
*/
|
|
14
|
+
import SvField from './SvField.svelte'
|
|
15
|
+
import { nextEditorId, resolveMessages, type EditorDir, type EditorSize } from './editor-contract'
|
|
16
|
+
import { createTimePicker, type TimeValue, type TimeFormat, type TimeSelection } from './createTimePicker.svelte'
|
|
17
|
+
|
|
18
|
+
/** User-facing strings (localizable via `messages`). */
|
|
19
|
+
type TimeMessages = { label: string; am: string; pm: string; now: string }
|
|
20
|
+
const DEFAULT_MESSAGES: TimeMessages = { label: 'Time picker', am: 'AM', pm: 'PM', now: 'Now' }
|
|
21
|
+
|
|
22
|
+
type Props = {
|
|
23
|
+
/** Date, "HH:MM[:SS]" string, or epoch ms. */
|
|
24
|
+
value?: TimeValue
|
|
25
|
+
/** Fires with a Date (today's date carrying the picked time). */
|
|
26
|
+
onChange?: (value: Date) => void
|
|
27
|
+
format?: TimeFormat
|
|
28
|
+
minuteInterval?: number
|
|
29
|
+
/** After picking an hour, jump the dial to minutes. Default true. */
|
|
30
|
+
autoSwitchToMinutes?: boolean
|
|
31
|
+
/** Show the Now footer button. */
|
|
32
|
+
footer?: boolean
|
|
33
|
+
disabled?: boolean
|
|
34
|
+
readonly?: boolean
|
|
35
|
+
name?: string
|
|
36
|
+
/** Which dial opens first. */
|
|
37
|
+
selection?: TimeSelection
|
|
38
|
+
/** Text direction (rtl mirrors layout; auto/undefined inherits). */
|
|
39
|
+
dir?: EditorDir
|
|
40
|
+
/** Override the built-in strings (group label + AM/PM/Now). */
|
|
41
|
+
messages?: Partial<TimeMessages>
|
|
42
|
+
// Optional field chrome: when any of label/hint/error is set, the dial is
|
|
43
|
+
// wrapped in <SvField> so it gains a label/hint/error line; otherwise it
|
|
44
|
+
// renders bare (as embedded inside SvDateTimePicker's popover).
|
|
45
|
+
label?: string
|
|
46
|
+
hint?: string
|
|
47
|
+
error?: string
|
|
48
|
+
required?: boolean
|
|
49
|
+
invalid?: boolean
|
|
50
|
+
size?: EditorSize
|
|
51
|
+
id?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let {
|
|
55
|
+
value = null,
|
|
56
|
+
onChange,
|
|
57
|
+
format = '24-hour',
|
|
58
|
+
minuteInterval = 1,
|
|
59
|
+
autoSwitchToMinutes = true,
|
|
60
|
+
footer = false,
|
|
61
|
+
disabled = false,
|
|
62
|
+
readonly = false,
|
|
63
|
+
name,
|
|
64
|
+
selection = 'hour',
|
|
65
|
+
dir,
|
|
66
|
+
messages,
|
|
67
|
+
label,
|
|
68
|
+
hint,
|
|
69
|
+
error,
|
|
70
|
+
required = false,
|
|
71
|
+
invalid = false,
|
|
72
|
+
id,
|
|
73
|
+
}: Props = $props()
|
|
74
|
+
|
|
75
|
+
const autoId = nextEditorId('sv-tp')
|
|
76
|
+
const uid = $derived(id ?? autoId)
|
|
77
|
+
const hasChrome = $derived(!!(label || hint || error))
|
|
78
|
+
|
|
79
|
+
const M = $derived(resolveMessages(DEFAULT_MESSAGES, messages))
|
|
80
|
+
const resolvedDir = $derived(dir === 'ltr' || dir === 'rtl' ? dir : undefined)
|
|
81
|
+
|
|
82
|
+
// The headless core owns all state, geometry, keyboard + ARIA. Reactive inputs
|
|
83
|
+
// are getters; callbacks are closures.
|
|
84
|
+
const tp = createTimePicker({
|
|
85
|
+
value: () => value,
|
|
86
|
+
onChange: (d) => onChange?.(d),
|
|
87
|
+
format: () => format,
|
|
88
|
+
minuteInterval: () => minuteInterval,
|
|
89
|
+
autoSwitchToMinutes: () => autoSwitchToMinutes,
|
|
90
|
+
disabled: () => disabled,
|
|
91
|
+
readonly: () => readonly,
|
|
92
|
+
selection: () => selection,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// --- Pointer drag on the dial (DOM-bound; stays in the component) -----------
|
|
96
|
+
// The core exposes SIZE/C + a pure `pointerSelect(angle, dist)`; here we only
|
|
97
|
+
// turn a pointer event into that angle via the live element rect.
|
|
98
|
+
let dialEl: SVGSVGElement | null = null
|
|
99
|
+
let dragging = false
|
|
100
|
+
|
|
101
|
+
function angleFromEvent(e: PointerEvent): { angle: number; dist: number } {
|
|
102
|
+
const rect = dialEl!.getBoundingClientRect()
|
|
103
|
+
const scale = tp.SIZE / rect.width
|
|
104
|
+
const x = (e.clientX - rect.left) * scale - tp.C
|
|
105
|
+
const y = (e.clientY - rect.top) * scale - tp.C
|
|
106
|
+
let deg = (Math.atan2(y, x) * 180) / Math.PI + 90
|
|
107
|
+
if (deg < 0) deg += 360
|
|
108
|
+
return { angle: deg, dist: Math.hypot(x, y) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function onDialPointerDown(e: PointerEvent) {
|
|
112
|
+
if (!tp.isInteractive) return
|
|
113
|
+
dragging = true
|
|
114
|
+
dialEl?.setPointerCapture(e.pointerId)
|
|
115
|
+
const { angle, dist } = angleFromEvent(e)
|
|
116
|
+
tp.pointerSelect(angle, dist)
|
|
117
|
+
}
|
|
118
|
+
function onDialPointerMove(e: PointerEvent) {
|
|
119
|
+
if (!dragging) return
|
|
120
|
+
const { angle, dist } = angleFromEvent(e)
|
|
121
|
+
tp.pointerSelect(angle, dist)
|
|
122
|
+
}
|
|
123
|
+
function onDialPointerUp(e: PointerEvent) {
|
|
124
|
+
if (!dragging) return
|
|
125
|
+
dragging = false
|
|
126
|
+
dialEl?.releasePointerCapture(e.pointerId)
|
|
127
|
+
tp.endPointer()
|
|
128
|
+
}
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
{#snippet dialBody()}
|
|
132
|
+
<div class="sv-tp" class:sv-tp--disabled={disabled} class:sv-tp--invalid={invalid} id={uid} role="group" aria-label={label ?? M.label} dir={resolvedDir}>
|
|
133
|
+
<div class="sv-tp__head">
|
|
134
|
+
<button class="sv-tp__seg" class:is-active={tp.selection === 'hour'} {...tp.segProps('hour')}>
|
|
135
|
+
{String(tp.displayHour).padStart(2, '0')}
|
|
136
|
+
</button>
|
|
137
|
+
<span class="sv-tp__colon">:</span>
|
|
138
|
+
<button class="sv-tp__seg" class:is-active={tp.selection === 'minute'} {...tp.segProps('minute')}>
|
|
139
|
+
{tp.mm}
|
|
140
|
+
</button>
|
|
141
|
+
{#if tp.is12}
|
|
142
|
+
<div class="sv-tp__ampm">
|
|
143
|
+
<button class:is-active={!tp.isPm} {...tp.ampmProps(false)}>{M.am}</button>
|
|
144
|
+
<button class:is-active={tp.isPm} {...tp.ampmProps(true)}>{M.pm}</button>
|
|
145
|
+
</div>
|
|
146
|
+
{/if}
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<svg
|
|
150
|
+
bind:this={dialEl}
|
|
151
|
+
class="sv-tp__dial"
|
|
152
|
+
viewBox={`0 0 ${tp.SIZE} ${tp.SIZE}`}
|
|
153
|
+
width="100%"
|
|
154
|
+
{...tp.dialProps()}
|
|
155
|
+
onpointerdown={onDialPointerDown}
|
|
156
|
+
onpointermove={onDialPointerMove}
|
|
157
|
+
onpointerup={onDialPointerUp}
|
|
158
|
+
onpointercancel={onDialPointerUp}
|
|
159
|
+
>
|
|
160
|
+
<circle class="sv-tp__face" cx={tp.C} cy={tp.C} r={tp.C - 4} />
|
|
161
|
+
<!-- hand -->
|
|
162
|
+
<line class="sv-tp__hand" x1={tp.C} y1={tp.C} x2={tp.handEnd.x} y2={tp.handEnd.y} />
|
|
163
|
+
<circle class="sv-tp__hub" cx={tp.C} cy={tp.C} r="3" />
|
|
164
|
+
<circle class="sv-tp__knob" cx={tp.handEnd.x} cy={tp.handEnd.y} r="15" />
|
|
165
|
+
{#if tp.selection === 'hour'}
|
|
166
|
+
{#each tp.hourTicks as t (t.ring + '-' + t.value)}
|
|
167
|
+
<text class="sv-tp__num" class:is-active={tp.isActiveHour(t)} class:is-inner={t.ring === 'inner'} x={t.x} y={t.y} dominant-baseline="central" text-anchor="middle">{t.label}</text>
|
|
168
|
+
{/each}
|
|
169
|
+
{:else}
|
|
170
|
+
{#each tp.minuteTicks as t (t.value)}
|
|
171
|
+
<text class="sv-tp__num" class:is-active={t.value === tp.minutes} x={t.x} y={t.y} dominant-baseline="central" text-anchor="middle">{t.label}</text>
|
|
172
|
+
{/each}
|
|
173
|
+
{/if}
|
|
174
|
+
</svg>
|
|
175
|
+
|
|
176
|
+
{#if footer}
|
|
177
|
+
<div class="sv-tp__footer">
|
|
178
|
+
<button class="sv-tp__now" {...tp.nowProps()}>{M.now}</button>
|
|
179
|
+
</div>
|
|
180
|
+
{/if}
|
|
181
|
+
|
|
182
|
+
{#if name}<input type="hidden" {name} value={`${String(tp.hours).padStart(2, '0')}:${tp.mm}`} />{/if}
|
|
183
|
+
</div>
|
|
184
|
+
{/snippet}
|
|
185
|
+
|
|
186
|
+
{#if hasChrome}
|
|
187
|
+
<SvField id={uid} {label} {hint} {error} {required} {dir}>{@render dialBody()}</SvField>
|
|
188
|
+
{:else}
|
|
189
|
+
{@render dialBody()}
|
|
190
|
+
{/if}
|
|
191
|
+
|
|
192
|
+
<style>
|
|
193
|
+
.sv-tp {
|
|
194
|
+
--_accent: var(--sg-accent, #2563eb);
|
|
195
|
+
--_bg: var(--sg-bg, #fff);
|
|
196
|
+
--_fg: var(--sg-fg, #0f172a);
|
|
197
|
+
--_muted: var(--sg-muted, #64748b);
|
|
198
|
+
--_border: var(--sg-border, #e2e8f0);
|
|
199
|
+
--_face: var(--sg-header-bg, #f1f5f9);
|
|
200
|
+
--_radius: var(--sg-radius, 8px);
|
|
201
|
+
display: inline-flex; flex-direction: column; gap: 10px; align-items: center;
|
|
202
|
+
width: 260px; padding: 12px;
|
|
203
|
+
background: var(--_bg); color: var(--_fg);
|
|
204
|
+
border: 1px solid var(--_border); border-radius: calc(var(--_radius) + 4px);
|
|
205
|
+
user-select: none;
|
|
206
|
+
}
|
|
207
|
+
.sv-tp--invalid { border-color: var(--sg-danger, #dc2626); }
|
|
208
|
+
.sv-tp--disabled { opacity: 0.55; pointer-events: none; }
|
|
209
|
+
|
|
210
|
+
.sv-tp__head { display: flex; align-items: center; gap: 2px; font-variant-numeric: tabular-nums; }
|
|
211
|
+
.sv-tp__seg {
|
|
212
|
+
font-size: 30px; font-weight: 700; line-height: 1;
|
|
213
|
+
background: none; border: 0; color: var(--_muted); cursor: pointer;
|
|
214
|
+
padding: 2px 6px; border-radius: var(--_radius);
|
|
215
|
+
}
|
|
216
|
+
.sv-tp__seg.is-active { color: var(--_accent); background: color-mix(in srgb, var(--_accent) 12%, transparent); }
|
|
217
|
+
.sv-tp__colon { font-size: 28px; font-weight: 700; color: var(--_muted); }
|
|
218
|
+
.sv-tp__ampm { display: flex; flex-direction: column; margin-inline-start: 8px; gap: 2px; }
|
|
219
|
+
.sv-tp__ampm button {
|
|
220
|
+
font-size: 11px; font-weight: 700; padding: 3px 8px;
|
|
221
|
+
background: none; border: 1px solid var(--_border); border-radius: 6px; color: var(--_muted); cursor: pointer;
|
|
222
|
+
}
|
|
223
|
+
.sv-tp__ampm button.is-active { background: var(--_accent); color: var(--sg-on-accent, #fff); border-color: var(--_accent); }
|
|
224
|
+
|
|
225
|
+
.sv-tp__dial { touch-action: none; cursor: pointer; outline: none; display: block; width: 100%; height: auto; aspect-ratio: 1; }
|
|
226
|
+
.sv-tp__dial:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--_accent)); outline-offset: 2px; border-radius: 50%; }
|
|
227
|
+
.sv-tp__face { fill: var(--_face); }
|
|
228
|
+
.sv-tp__hand { stroke: var(--_accent); stroke-width: 2; }
|
|
229
|
+
.sv-tp__hub { fill: var(--_accent); }
|
|
230
|
+
.sv-tp__knob { fill: var(--_accent); opacity: 0.9; }
|
|
231
|
+
.sv-tp__num {
|
|
232
|
+
font-size: 13px; fill: var(--_fg); pointer-events: none;
|
|
233
|
+
font-family: inherit;
|
|
234
|
+
}
|
|
235
|
+
.sv-tp__num.is-inner { font-size: 11px; fill: var(--_muted); }
|
|
236
|
+
.sv-tp__num.is-active { fill: var(--sg-on-accent, #fff); font-weight: 700; }
|
|
237
|
+
|
|
238
|
+
.sv-tp__footer { width: 100%; display: flex; justify-content: center; border-top: 1px solid var(--_border); padding-top: 8px; }
|
|
239
|
+
.sv-tp__now {
|
|
240
|
+
padding: 5px 14px; font: inherit; font-size: 12px; font-weight: 600;
|
|
241
|
+
background: none; border: 1px solid var(--_border); border-radius: var(--_radius);
|
|
242
|
+
color: var(--_accent); cursor: pointer;
|
|
243
|
+
}
|
|
244
|
+
.sv-tp__now:hover { background: color-mix(in srgb, var(--_accent) 10%, transparent); }
|
|
245
|
+
</style>
|