@svgrid/grid 1.2.22 → 1.2.24
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/dist/GridFooter.svelte +34 -20
- package/dist/GridFooter.svelte.d.ts +8 -0
- package/dist/SvAutoComplete.svelte +89 -0
- package/dist/SvAutoComplete.svelte.d.ts +16 -0
- package/dist/SvButton.svelte +97 -0
- package/dist/SvButton.svelte.d.ts +23 -0
- package/dist/SvCalendar.svelte +409 -0
- package/dist/SvCalendar.svelte.d.ts +52 -0
- package/dist/SvCheckBox.svelte +82 -0
- package/dist/SvCheckBox.svelte.d.ts +19 -0
- package/dist/SvColorInput.svelte +160 -0
- package/dist/SvColorInput.svelte.d.ts +15 -0
- package/dist/SvComboBox.svelte +108 -0
- package/dist/SvComboBox.svelte.d.ts +15 -0
- package/dist/SvCountryInput.svelte +125 -0
- package/dist/SvCountryInput.svelte.d.ts +14 -0
- package/dist/SvDateTimePicker.svelte +265 -0
- package/dist/SvDateTimePicker.svelte.d.ts +38 -0
- package/dist/SvDropDownList.svelte +120 -0
- package/dist/SvDropDownList.svelte.d.ts +15 -0
- package/dist/SvForm.svelte +147 -0
- package/dist/SvForm.svelte.d.ts +29 -0
- package/dist/SvGauge.svelte +97 -0
- package/dist/SvGauge.svelte.d.ts +26 -0
- package/dist/SvGrid.controller.svelte.d.ts +16 -2
- package/dist/SvGrid.controller.svelte.js +63 -8
- package/dist/SvGrid.css +84 -31
- package/dist/SvGrid.helpers.d.ts +4 -0
- package/dist/SvGrid.helpers.js +40 -12
- package/dist/SvGrid.svelte +84 -5
- package/dist/SvGrid.types.d.ts +36 -0
- package/dist/SvGridDropdown.svelte +16 -30
- package/dist/SvGridDropdown.svelte.d.ts +4 -0
- package/dist/SvListBox.svelte +89 -0
- package/dist/SvListBox.svelte.d.ts +22 -0
- package/dist/SvMaskedInput.svelte +66 -0
- package/dist/SvMaskedInput.svelte.d.ts +15 -0
- package/dist/SvNumberInput.svelte +116 -0
- package/dist/SvNumberInput.svelte.d.ts +18 -0
- package/dist/SvPasswordInput.svelte +100 -0
- package/dist/SvPasswordInput.svelte.d.ts +18 -0
- package/dist/SvPhoneInput.svelte +87 -0
- package/dist/SvPhoneInput.svelte.d.ts +19 -0
- package/dist/SvRadioGroup.svelte +85 -0
- package/dist/SvRadioGroup.svelte.d.ts +20 -0
- package/dist/SvRating.svelte +91 -0
- package/dist/SvRating.svelte.d.ts +18 -0
- package/dist/SvRepeatButton.svelte +89 -0
- package/dist/SvRepeatButton.svelte.d.ts +21 -0
- package/dist/SvSlider.svelte +155 -0
- package/dist/SvSlider.svelte.d.ts +20 -0
- package/dist/SvSwitchButton.svelte +87 -0
- package/dist/SvSwitchButton.svelte.d.ts +14 -0
- package/dist/SvTabs.svelte +109 -0
- package/dist/SvTabs.svelte.d.ts +26 -0
- package/dist/SvTagsInput.svelte +70 -0
- package/dist/SvTagsInput.svelte.d.ts +14 -0
- package/dist/SvTimePicker.svelte +201 -0
- package/dist/SvTimePicker.svelte.d.ts +30 -0
- package/dist/SvToggleButton.svelte +57 -0
- package/dist/SvToggleButton.svelte.d.ts +16 -0
- package/dist/SvTree.svelte +131 -0
- package/dist/SvTree.svelte.d.ts +27 -0
- package/dist/build-api.js +78 -1
- package/dist/cdn/svgrid.js +11674 -5907
- package/dist/cdn/svgrid.svelte-external.js +10436 -4671
- package/dist/cell-render.d.ts +4 -0
- package/dist/cell-render.js +35 -1
- package/dist/clipboard.js +19 -1
- package/dist/collaboration.d.ts +2 -0
- package/dist/collaboration.js +4 -0
- package/dist/core.d.ts +22 -1
- package/dist/countries.d.ts +15 -0
- package/dist/countries.js +61 -0
- package/dist/createAutocomplete.svelte.d.ts +77 -0
- package/dist/createAutocomplete.svelte.js +100 -0
- package/dist/createCalendar.svelte.d.ts +192 -0
- package/dist/createCalendar.svelte.js +402 -0
- package/dist/createCheckbox.svelte.d.ts +45 -0
- package/dist/createCheckbox.svelte.js +26 -0
- package/dist/createColorInput.svelte.d.ts +67 -0
- package/dist/createColorInput.svelte.js +100 -0
- package/dist/createCombobox.svelte.d.ts +102 -0
- package/dist/createCombobox.svelte.js +171 -0
- package/dist/createCountryInput.svelte.d.ts +86 -0
- package/dist/createCountryInput.svelte.js +126 -0
- package/dist/createDateTimePicker.svelte.d.ts +83 -0
- package/dist/createDateTimePicker.svelte.js +202 -0
- package/dist/createDropdownList.svelte.d.ts +78 -0
- package/dist/createDropdownList.svelte.js +117 -0
- package/dist/createGauge.svelte.d.ts +56 -0
- package/dist/createGauge.svelte.js +49 -0
- package/dist/createListbox.svelte.d.ts +79 -0
- package/dist/createListbox.svelte.js +117 -0
- package/dist/createMaskedInput.svelte.d.ts +30 -0
- package/dist/createMaskedInput.svelte.js +62 -0
- package/dist/createNumberInput.svelte.d.ts +72 -0
- package/dist/createNumberInput.svelte.js +167 -0
- package/dist/createPasswordInput.svelte.d.ts +60 -0
- package/dist/createPasswordInput.svelte.js +72 -0
- package/dist/createPhoneInput.svelte.d.ts +46 -0
- package/dist/createPhoneInput.svelte.js +105 -0
- package/dist/createRadioGroup.svelte.d.ts +64 -0
- package/dist/createRadioGroup.svelte.js +73 -0
- package/dist/createRating.svelte.d.ts +70 -0
- package/dist/createRating.svelte.js +85 -0
- package/dist/createSlider.svelte.d.ts +71 -0
- package/dist/createSlider.svelte.js +123 -0
- package/dist/createSwitch.svelte.d.ts +43 -0
- package/dist/createSwitch.svelte.js +41 -0
- package/dist/createTabs.svelte.d.ts +78 -0
- package/dist/createTabs.svelte.js +92 -0
- package/dist/createTagsInput.svelte.d.ts +65 -0
- package/dist/createTagsInput.svelte.js +93 -0
- package/dist/createTimePicker.svelte.d.ts +106 -0
- package/dist/createTimePicker.svelte.js +248 -0
- package/dist/createToggle.svelte.d.ts +43 -0
- package/dist/createToggle.svelte.js +39 -0
- package/dist/createTree.svelte.d.ts +96 -0
- package/dist/createTree.svelte.js +194 -0
- package/dist/datetime/date-core.d.ts +88 -0
- package/dist/datetime/date-core.js +165 -0
- package/dist/datetime/date-format.d.ts +39 -0
- package/dist/datetime/date-format.js +371 -0
- package/dist/datetime/date-restrict.d.ts +23 -0
- package/dist/datetime/date-restrict.js +50 -0
- package/dist/datetime/date-selection.d.ts +25 -0
- package/dist/datetime/date-selection.js +94 -0
- package/dist/datetime/mask.d.ts +21 -0
- package/dist/datetime/mask.js +90 -0
- package/dist/editing.d.ts +1 -1
- package/dist/editing.js +7 -0
- package/dist/editor-contract.d.ts +48 -0
- package/dist/editor-contract.js +26 -0
- package/dist/editors/cell-editors.d.ts +2 -2
- package/dist/editors/cell-editors.js +3 -1
- package/dist/export-format.d.ts +178 -0
- package/dist/export-format.js +419 -0
- package/dist/hyperformula-adapter.d.ts +4 -0
- package/dist/hyperformula-adapter.js +15 -6
- package/dist/index.d.ts +60 -0
- package/dist/index.js +72 -0
- package/dist/list-option.d.ts +10 -0
- package/dist/list-option.js +7 -0
- package/dist/menus.js +18 -2
- package/dist/popover.d.ts +48 -0
- package/dist/popover.js +65 -0
- package/dist/row-resize.d.ts +4 -4
- package/dist/row-resize.js +8 -5
- package/dist/selection.d.ts +6 -0
- package/dist/selection.js +31 -5
- package/dist/server-data-source.d.ts +43 -0
- package/dist/server-data-source.js +100 -0
- package/dist/sparkline.js +8 -4
- package/dist/svgrid-wrapper.types.d.ts +26 -1
- package/package.json +6 -1
- package/src/GridFooter.svelte +34 -20
- package/src/SvAutoComplete.svelte +89 -0
- package/src/SvButton.svelte +97 -0
- package/src/SvCalendar.svelte +409 -0
- package/src/SvCalendar.test.ts +211 -0
- package/src/SvCheckBox.svelte +82 -0
- package/src/SvColorInput.svelte +160 -0
- package/src/SvComboBox.svelte +108 -0
- package/src/SvCountryInput.svelte +125 -0
- package/src/SvDateTimePicker.svelte +265 -0
- package/src/SvDateTimePicker.test.ts +136 -0
- package/src/SvDropDownList.svelte +120 -0
- package/src/SvForm.svelte +147 -0
- package/src/SvGauge.svelte +97 -0
- package/src/SvGrid.controller.svelte.ts +74 -8
- package/src/SvGrid.css +84 -31
- package/src/SvGrid.helpers.ts +41 -12
- package/src/SvGrid.svelte +84 -5
- package/src/SvGrid.types.ts +33 -0
- package/src/SvGridDropdown.svelte +16 -30
- package/src/SvListBox.svelte +89 -0
- package/src/SvMaskedInput.svelte +66 -0
- package/src/SvNumberInput.svelte +116 -0
- package/src/SvPasswordInput.svelte +100 -0
- package/src/SvPhoneInput.svelte +87 -0
- package/src/SvRadioGroup.svelte +85 -0
- package/src/SvRating.svelte +91 -0
- package/src/SvRepeatButton.svelte +89 -0
- package/src/SvSlider.svelte +155 -0
- package/src/SvSwitchButton.svelte +87 -0
- package/src/SvTabs.svelte +109 -0
- package/src/SvTagsInput.svelte +70 -0
- package/src/SvTimePicker.svelte +201 -0
- package/src/SvTimePicker.test.ts +98 -0
- package/src/SvToggleButton.svelte +57 -0
- package/src/SvTree.svelte +131 -0
- package/src/build-api.ts +115 -0
- package/src/cell-render.test.ts +38 -0
- package/src/cell-render.ts +43 -0
- package/src/clipboard.test.ts +18 -0
- package/src/clipboard.ts +24 -1
- package/src/collaboration.test.ts +30 -0
- package/src/collaboration.ts +6 -0
- package/src/core.ts +27 -3
- package/src/countries.ts +71 -0
- package/src/createAutocomplete.svelte.ts +104 -0
- package/src/createCalendar.svelte.ts +488 -0
- package/src/createCheckbox.svelte.ts +74 -0
- package/src/createColorInput.svelte.ts +115 -0
- package/src/createCombobox.svelte.ts +158 -0
- package/src/createCountryInput.svelte.ts +126 -0
- package/src/createDateTimePicker.svelte.ts +216 -0
- package/src/createDropdownList.svelte.ts +125 -0
- package/src/createGauge.svelte.ts +86 -0
- package/src/createListbox.svelte.ts +186 -0
- package/src/createMaskedInput.svelte.ts +81 -0
- package/src/createNumberInput.svelte.ts +193 -0
- package/src/createPasswordInput.svelte.ts +87 -0
- package/src/createPhoneInput.svelte.ts +119 -0
- package/src/createRadioGroup.svelte.ts +132 -0
- package/src/createRating.svelte.ts +145 -0
- package/src/createSlider.svelte.ts +160 -0
- package/src/createSwitch.svelte.ts +78 -0
- package/src/createTabs.svelte.ts +129 -0
- package/src/createTagsInput.svelte.ts +98 -0
- package/src/createTimePicker.svelte.ts +289 -0
- package/src/createToggle.svelte.ts +69 -0
- package/src/createTree.svelte.ts +216 -0
- package/src/datetime/date-core.test.ts +217 -0
- package/src/datetime/date-core.ts +204 -0
- package/src/datetime/date-format.test.ts +121 -0
- package/src/datetime/date-format.ts +317 -0
- package/src/datetime/date-restrict.ts +60 -0
- package/src/datetime/date-selection.test.ts +129 -0
- package/src/datetime/date-selection.ts +137 -0
- package/src/datetime/mask.test.ts +36 -0
- package/src/datetime/mask.ts +84 -0
- package/src/editing.test.ts +22 -0
- package/src/editing.ts +7 -1
- package/src/editor-contract.test.ts +32 -0
- package/src/editor-contract.ts +60 -0
- package/src/editors/cell-editors.ts +7 -1
- package/src/export-data-api.test.ts +126 -0
- package/src/export-format.test.ts +107 -0
- package/src/export-format.ts +598 -0
- package/src/hyperformula-adapter.test.ts +35 -1
- package/src/hyperformula-adapter.ts +18 -6
- package/src/index.ts +110 -0
- package/src/list-option.ts +15 -0
- package/src/menus.ts +21 -2
- package/src/popover.ts +79 -0
- package/src/row-resize.test.ts +25 -0
- package/src/row-resize.ts +8 -5
- package/src/selection.test.ts +36 -4
- package/src/selection.ts +30 -9
- package/src/server-data-source.test.ts +201 -0
- package/src/server-data-source.ts +148 -0
- package/src/sparkline.test.ts +9 -0
- package/src/sparkline.ts +9 -4
- package/src/svgrid-wrapper.types.ts +28 -1
- package/src/svgrid.behavior.test.ts +27 -0
- package/src/svgrid.new-features.wrapper.test.ts +27 -1
- package/src/ui-buttons.test.ts +105 -0
- package/src/ui-composite.test.ts +89 -0
- package/src/ui-inputs.test.ts +118 -0
- package/src/ui-range.test.ts +61 -0
- package/src/ui-selection.test.ts +119 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type { DateTimeValue } from './createDateTimePicker.svelte'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
/**
|
|
7
|
+
* SvDateTimePicker - a text input (formatted via the date-format token engine)
|
|
8
|
+
* plus a dropdown that composes SvCalendar + SvTimePicker behind DATE / TIME
|
|
9
|
+
* tabs. Parity target: Smart `smart-date-time-picker`. It is a styled renderer
|
|
10
|
+
* over the headless `createDateTimePicker` core (value math, parse/format,
|
|
11
|
+
* clamp, dropdown open/tab state, sub-picker wiring); the component keeps the
|
|
12
|
+
* render-only concerns - the portalled popover positioning (via popover.ts), DOM
|
|
13
|
+
* refs, and outside-click / reposition listeners - so the dropdown is never
|
|
14
|
+
* clipped by the grid's scroll container.
|
|
15
|
+
*/
|
|
16
|
+
import SvCalendar from './SvCalendar.svelte'
|
|
17
|
+
import SvTimePicker from './SvTimePicker.svelte'
|
|
18
|
+
import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
|
|
19
|
+
import { createDateTimePicker, type DateTimeValue, type DropDownDisplayMode } from './createDateTimePicker.svelte'
|
|
20
|
+
import type { DateLike } from './datetime/date-core'
|
|
21
|
+
|
|
22
|
+
type Props = {
|
|
23
|
+
value?: DateTimeValue
|
|
24
|
+
onChange?: (value: Date | null) => void
|
|
25
|
+
/** Fired when the value is finalized (Enter, blur, or a single-date pick).
|
|
26
|
+
* Used by the grid to save the cell. */
|
|
27
|
+
onCommit?: (value: Date | null) => void
|
|
28
|
+
/** Fired on Escape / dismiss without committing (grid cancels the edit). */
|
|
29
|
+
onCancel?: () => void
|
|
30
|
+
/** Display / parse mask (token engine). */
|
|
31
|
+
formatString?: string
|
|
32
|
+
min?: DateLike | null
|
|
33
|
+
max?: DateLike | null
|
|
34
|
+
nullable?: boolean
|
|
35
|
+
placeholder?: string
|
|
36
|
+
disabled?: boolean
|
|
37
|
+
readonly?: boolean
|
|
38
|
+
name?: string
|
|
39
|
+
locale?: string
|
|
40
|
+
firstDayOfWeek?: number
|
|
41
|
+
weekNumbers?: boolean
|
|
42
|
+
hourFormat?: '12-hour' | '24-hour'
|
|
43
|
+
minuteInterval?: number
|
|
44
|
+
/** Which tabs the dropdown shows. */
|
|
45
|
+
dropDownDisplayMode?: DropDownDisplayMode
|
|
46
|
+
/** Up/down spinner buttons that bump the value by `stepMinutes`. */
|
|
47
|
+
spinButtons?: boolean
|
|
48
|
+
stepMinutes?: number
|
|
49
|
+
/** Open the dropdown as soon as the field is focused (grid in-cell editing). */
|
|
50
|
+
autoOpen?: boolean
|
|
51
|
+
/** Animate the calendar's month/drill navigation (honors reduced-motion). */
|
|
52
|
+
animate?: boolean | 'slide' | 'fade'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let {
|
|
56
|
+
value = null,
|
|
57
|
+
onChange,
|
|
58
|
+
onCommit,
|
|
59
|
+
onCancel,
|
|
60
|
+
formatString = 'yyyy-MM-dd HH:mm',
|
|
61
|
+
min = null,
|
|
62
|
+
max = null,
|
|
63
|
+
nullable = true,
|
|
64
|
+
placeholder = 'Select date & time',
|
|
65
|
+
disabled = false,
|
|
66
|
+
readonly = false,
|
|
67
|
+
name,
|
|
68
|
+
locale = typeof navigator !== 'undefined' ? navigator.language : 'en-US',
|
|
69
|
+
firstDayOfWeek = 0,
|
|
70
|
+
weekNumbers = false,
|
|
71
|
+
hourFormat = '24-hour',
|
|
72
|
+
minuteInterval = 1,
|
|
73
|
+
dropDownDisplayMode = 'both',
|
|
74
|
+
spinButtons = false,
|
|
75
|
+
stepMinutes = 1,
|
|
76
|
+
autoOpen = false,
|
|
77
|
+
animate = false,
|
|
78
|
+
}: Props = $props()
|
|
79
|
+
|
|
80
|
+
// The headless core owns value math, parse/format, clamping, the dropdown
|
|
81
|
+
// open/tab state and the sub-picker wiring. Reactive inputs are getters;
|
|
82
|
+
// callbacks are closures.
|
|
83
|
+
const dtp = createDateTimePicker({
|
|
84
|
+
value: () => value,
|
|
85
|
+
onChange: (d) => onChange?.(d),
|
|
86
|
+
onCommit: (d) => onCommit?.(d),
|
|
87
|
+
onCancel: () => onCancel?.(),
|
|
88
|
+
formatString: () => formatString,
|
|
89
|
+
min: () => min,
|
|
90
|
+
max: () => max,
|
|
91
|
+
nullable: () => nullable,
|
|
92
|
+
locale: () => locale,
|
|
93
|
+
dropDownDisplayMode: () => dropDownDisplayMode,
|
|
94
|
+
spinButtons: () => spinButtons,
|
|
95
|
+
stepMinutes: () => stepMinutes,
|
|
96
|
+
disabled: () => disabled,
|
|
97
|
+
readonly: () => readonly,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// --- Portalled dropdown positioning (DOM-bound; stays in the component) -----
|
|
101
|
+
let triggerEl = $state<HTMLDivElement | null>(null)
|
|
102
|
+
let panelEl = $state<HTMLDivElement | null>(null)
|
|
103
|
+
let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
|
|
104
|
+
|
|
105
|
+
function updatePos() {
|
|
106
|
+
if (!triggerEl) return
|
|
107
|
+
panelRect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: 360, minWidth: 300 })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Reposition on open + while open (scroll/resize), and dismiss on outside click.
|
|
111
|
+
$effect(() => {
|
|
112
|
+
if (!dtp.open) return
|
|
113
|
+
updatePos()
|
|
114
|
+
const reposition = () => updatePos()
|
|
115
|
+
window.addEventListener('scroll', reposition, true)
|
|
116
|
+
window.addEventListener('resize', reposition)
|
|
117
|
+
const onDown = (e: PointerEvent) => {
|
|
118
|
+
const t = e.target as Node | null
|
|
119
|
+
if (!t) return
|
|
120
|
+
if (triggerEl?.contains(t) || panelEl?.contains(t)) return
|
|
121
|
+
dtp.closePanel()
|
|
122
|
+
}
|
|
123
|
+
document.addEventListener('pointerdown', onDown, true)
|
|
124
|
+
return () => {
|
|
125
|
+
window.removeEventListener('scroll', reposition, true)
|
|
126
|
+
window.removeEventListener('resize', reposition)
|
|
127
|
+
document.removeEventListener('pointerdown', onDown, true)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
function focusOpen(node: HTMLInputElement) {
|
|
132
|
+
if (!autoOpen) return
|
|
133
|
+
node.focus()
|
|
134
|
+
dtp.openPanel()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function onRootFocusOut(e: FocusEvent) {
|
|
138
|
+
const next = e.relatedTarget as Node | null
|
|
139
|
+
// Ignore focus moving within the field or into the portalled panel.
|
|
140
|
+
if (next && (triggerEl?.contains(next) || panelEl?.contains(next))) return
|
|
141
|
+
onCommit?.(dtp.current)
|
|
142
|
+
}
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<div class="sv-dtp" class:sv-dtp--disabled={disabled} onfocusout={onRootFocusOut}>
|
|
146
|
+
<div class="sv-dtp__field" bind:this={triggerEl}>
|
|
147
|
+
{#if spinButtons}
|
|
148
|
+
<div class="sv-dtp__spin">
|
|
149
|
+
<button {...dtp.spinProps(1)}>▲</button>
|
|
150
|
+
<button {...dtp.spinProps(-1)}>▼</button>
|
|
151
|
+
</div>
|
|
152
|
+
{/if}
|
|
153
|
+
<input
|
|
154
|
+
class="sv-dtp__input"
|
|
155
|
+
bind:value={dtp.text}
|
|
156
|
+
{placeholder}
|
|
157
|
+
{...dtp.inputProps()}
|
|
158
|
+
use:focusOpen
|
|
159
|
+
/>
|
|
160
|
+
{#if nullable && dtp.current && dtp.isInteractive}
|
|
161
|
+
<button class="sv-dtp__clear" {...dtp.clearProps()}>×</button>
|
|
162
|
+
{/if}
|
|
163
|
+
<button class="sv-dtp__toggle" {...dtp.toggleProps()}>
|
|
164
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{#if dtp.open}
|
|
169
|
+
<div
|
|
170
|
+
class="sv-dtp__panel"
|
|
171
|
+
bind:this={panelEl}
|
|
172
|
+
use:portalToBody
|
|
173
|
+
style:position="fixed"
|
|
174
|
+
style:top={`${panelRect.top}px`}
|
|
175
|
+
style:left={`${panelRect.left}px`}
|
|
176
|
+
role="dialog"
|
|
177
|
+
aria-label="Choose date and time"
|
|
178
|
+
>
|
|
179
|
+
{#if dtp.showDateTab && dtp.showTimeTab}
|
|
180
|
+
<div class="sv-dtp__tabs" role="tablist">
|
|
181
|
+
<button class:is-active={dtp.tab === 'date'} {...dtp.tabProps('date')}>DATE</button>
|
|
182
|
+
<button class:is-active={dtp.tab === 'time'} {...dtp.tabProps('time')}>TIME</button>
|
|
183
|
+
</div>
|
|
184
|
+
{/if}
|
|
185
|
+
<div class="sv-dtp__body">
|
|
186
|
+
{#if dtp.tab === 'date' && dtp.showDateTab}
|
|
187
|
+
<SvCalendar
|
|
188
|
+
value={dtp.current}
|
|
189
|
+
selectionMode="one"
|
|
190
|
+
min={min}
|
|
191
|
+
max={max}
|
|
192
|
+
{firstDayOfWeek}
|
|
193
|
+
{weekNumbers}
|
|
194
|
+
{locale}
|
|
195
|
+
{animate}
|
|
196
|
+
onChange={dtp.onCalendarChange}
|
|
197
|
+
/>
|
|
198
|
+
{:else if dtp.showTimeTab}
|
|
199
|
+
<SvTimePicker
|
|
200
|
+
value={dtp.current}
|
|
201
|
+
format={hourFormat}
|
|
202
|
+
{minuteInterval}
|
|
203
|
+
footer
|
|
204
|
+
onChange={dtp.onTimeChange}
|
|
205
|
+
/>
|
|
206
|
+
{/if}
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
{/if}
|
|
210
|
+
|
|
211
|
+
{#if name}<input type="hidden" {name} value={dtp.current ? dtp.current.toISOString() : ''} />{/if}
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<style>
|
|
215
|
+
.sv-dtp {
|
|
216
|
+
--_accent: var(--sg-accent, #2563eb);
|
|
217
|
+
--_bg: var(--sg-input-bg, var(--sg-bg, #fff));
|
|
218
|
+
--_fg: var(--sg-fg, #0f172a);
|
|
219
|
+
--_muted: var(--sg-muted, #64748b);
|
|
220
|
+
--_border: var(--sg-input-border, var(--sg-border, #cbd5e1));
|
|
221
|
+
--_radius: var(--sg-radius, 8px);
|
|
222
|
+
display: inline-block; position: relative; width: 240px; color: var(--_fg);
|
|
223
|
+
}
|
|
224
|
+
.sv-dtp--disabled { opacity: 0.6; }
|
|
225
|
+
.sv-dtp__field {
|
|
226
|
+
display: flex; align-items: center; gap: 2px;
|
|
227
|
+
background: var(--_bg); border: 1px solid var(--_border); border-radius: var(--_radius);
|
|
228
|
+
padding: 0 4px 0 0; height: 34px;
|
|
229
|
+
}
|
|
230
|
+
.sv-dtp__field:focus-within { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
|
|
231
|
+
.sv-dtp__input {
|
|
232
|
+
flex: 1; min-width: 0; border: 0; background: none; outline: none; color: inherit;
|
|
233
|
+
font: inherit; font-size: 13px; padding: 0 8px; height: 100%;
|
|
234
|
+
}
|
|
235
|
+
.sv-dtp__spin { display: flex; flex-direction: column; border-right: 1px solid var(--_border); }
|
|
236
|
+
.sv-dtp__spin button {
|
|
237
|
+
flex: 1; width: 20px; border: 0; background: none; color: var(--_muted); cursor: pointer;
|
|
238
|
+
font-size: 7px; line-height: 1; padding: 0;
|
|
239
|
+
}
|
|
240
|
+
.sv-dtp__spin button:hover:not(:disabled) { color: var(--_accent); }
|
|
241
|
+
.sv-dtp__clear, .sv-dtp__toggle {
|
|
242
|
+
display: grid; place-items: center; width: 26px; height: 26px; flex: none;
|
|
243
|
+
background: none; border: 0; color: var(--_muted); cursor: pointer; border-radius: 6px;
|
|
244
|
+
}
|
|
245
|
+
.sv-dtp__clear { font-size: 17px; line-height: 1; }
|
|
246
|
+
.sv-dtp__clear:hover, .sv-dtp__toggle:hover { color: var(--_accent); background: color-mix(in srgb, var(--_accent) 10%, transparent); }
|
|
247
|
+
|
|
248
|
+
/* Portalled panel - lives under <body>, styled globally. */
|
|
249
|
+
:global(.sv-dtp__panel) {
|
|
250
|
+
z-index: 2147483647;
|
|
251
|
+
background: var(--sg-bg, #fff);
|
|
252
|
+
border: 1px solid var(--sg-border, #e2e8f0);
|
|
253
|
+
border-radius: 12px;
|
|
254
|
+
box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35);
|
|
255
|
+
overflow: hidden;
|
|
256
|
+
}
|
|
257
|
+
:global(.sv-dtp__tabs) { display: flex; border-bottom: 1px solid var(--sg-border, #e2e8f0); }
|
|
258
|
+
:global(.sv-dtp__tabs button) {
|
|
259
|
+
flex: 1; padding: 9px; font: inherit; font-size: 11px; font-weight: 700; letter-spacing: 0.04em;
|
|
260
|
+
background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer;
|
|
261
|
+
border-bottom: 2px solid transparent;
|
|
262
|
+
}
|
|
263
|
+
:global(.sv-dtp__tabs button.is-active) { color: var(--sg-accent, #2563eb); border-bottom-color: var(--sg-accent, #2563eb); }
|
|
264
|
+
:global(.sv-dtp__body) { display: grid; place-items: center; padding: 8px; }
|
|
265
|
+
</style>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Component tests for SvDateTimePicker: the masked input round-trips through the
|
|
3
|
+
* token engine, the dropdown portals to <body> with DATE/TIME tabs, and picking
|
|
4
|
+
* a day preserves the time (and vice versa).
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it, afterEach } from 'vitest'
|
|
7
|
+
import { mount, unmount, flushSync } from 'svelte'
|
|
8
|
+
import SvDateTimePicker from './SvDateTimePicker.svelte'
|
|
9
|
+
|
|
10
|
+
function mountDtp(props: Record<string, unknown>) {
|
|
11
|
+
const target = document.createElement('div')
|
|
12
|
+
document.body.appendChild(target)
|
|
13
|
+
const app = mount(SvDateTimePicker, { target, props: props as any })
|
|
14
|
+
flushSync()
|
|
15
|
+
return { target, destroy: () => { unmount(app); target.remove() } }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The panel portals to <body>, so query the document, not the mount target.
|
|
19
|
+
const panel = () => document.querySelector<HTMLElement>('.sv-dtp__panel')
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
document.querySelectorAll('.sv-dtp__panel').forEach((n) => n.remove())
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('SvDateTimePicker input', () => {
|
|
26
|
+
it('formats the initial value with the mask', () => {
|
|
27
|
+
const { target, destroy } = mountDtp({ value: new Date(2026, 5, 7, 9, 4), formatString: 'yyyy-MM-dd HH:mm' })
|
|
28
|
+
try {
|
|
29
|
+
const input = target.querySelector<HTMLInputElement>('.sv-dtp__input')!
|
|
30
|
+
expect(input.value).toBe('2026-06-07 09:04')
|
|
31
|
+
} finally { destroy() }
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('parses typed text on blur and emits a Date', () => {
|
|
35
|
+
let got: Date | null | undefined
|
|
36
|
+
const { target, destroy } = mountDtp({
|
|
37
|
+
value: null, formatString: 'yyyy-MM-dd HH:mm', onChange: (d: Date | null) => (got = d),
|
|
38
|
+
})
|
|
39
|
+
try {
|
|
40
|
+
const input = target.querySelector<HTMLInputElement>('.sv-dtp__input')!
|
|
41
|
+
input.value = '2026-06-07 14:30'
|
|
42
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
43
|
+
input.dispatchEvent(new FocusEvent('blur'))
|
|
44
|
+
flushSync()
|
|
45
|
+
expect(got).toBeInstanceOf(Date)
|
|
46
|
+
expect(got!.getFullYear()).toBe(2026)
|
|
47
|
+
expect(got!.getHours()).toBe(14)
|
|
48
|
+
expect(got!.getMinutes()).toBe(30)
|
|
49
|
+
} finally { destroy() }
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('reverts invalid text back to the last good value', () => {
|
|
53
|
+
const { target, destroy } = mountDtp({ value: new Date(2026, 5, 7, 9, 0), formatString: 'yyyy-MM-dd HH:mm' })
|
|
54
|
+
try {
|
|
55
|
+
const input = target.querySelector<HTMLInputElement>('.sv-dtp__input')!
|
|
56
|
+
input.value = 'garbage'
|
|
57
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
58
|
+
input.dispatchEvent(new FocusEvent('blur'))
|
|
59
|
+
flushSync()
|
|
60
|
+
expect(input.value).toBe('2026-06-07 09:00')
|
|
61
|
+
} finally { destroy() }
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('clamps to max', () => {
|
|
65
|
+
let got: Date | null | undefined
|
|
66
|
+
const { target, destroy } = mountDtp({
|
|
67
|
+
value: null, formatString: 'yyyy-MM-dd HH:mm', max: new Date(2026, 5, 10, 0, 0),
|
|
68
|
+
onChange: (d: Date | null) => (got = d),
|
|
69
|
+
})
|
|
70
|
+
try {
|
|
71
|
+
const input = target.querySelector<HTMLInputElement>('.sv-dtp__input')!
|
|
72
|
+
input.value = '2026-12-31 23:59'
|
|
73
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
74
|
+
input.dispatchEvent(new FocusEvent('blur'))
|
|
75
|
+
flushSync()
|
|
76
|
+
expect(got!.getTime()).toBe(new Date(2026, 5, 10, 0, 0).getTime())
|
|
77
|
+
} finally { destroy() }
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('clear button nulls a nullable value', () => {
|
|
81
|
+
let got: Date | null | undefined = new Date()
|
|
82
|
+
const { target, destroy } = mountDtp({
|
|
83
|
+
value: new Date(2026, 5, 7), nullable: true, onChange: (d: Date | null) => (got = d),
|
|
84
|
+
})
|
|
85
|
+
try {
|
|
86
|
+
target.querySelector<HTMLButtonElement>('.sv-dtp__clear')!.click()
|
|
87
|
+
flushSync()
|
|
88
|
+
expect(got).toBeNull()
|
|
89
|
+
} finally { destroy() }
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('SvDateTimePicker dropdown', () => {
|
|
94
|
+
it('opens a portalled panel with DATE and TIME tabs', () => {
|
|
95
|
+
const { target, destroy } = mountDtp({ value: new Date(2026, 5, 7, 9, 0) })
|
|
96
|
+
try {
|
|
97
|
+
target.querySelector<HTMLButtonElement>('.sv-dtp__toggle')!.click()
|
|
98
|
+
flushSync()
|
|
99
|
+
expect(panel()).not.toBeNull()
|
|
100
|
+
const tabs = panel()!.querySelectorAll('[role="tab"]')
|
|
101
|
+
expect(tabs.length).toBe(2)
|
|
102
|
+
expect(tabs[0]!.textContent).toBe('DATE')
|
|
103
|
+
expect(tabs[1]!.textContent).toBe('TIME')
|
|
104
|
+
} finally { destroy() }
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('picking a calendar day preserves the time', () => {
|
|
108
|
+
let got: Date | null | undefined
|
|
109
|
+
const { target, destroy } = mountDtp({
|
|
110
|
+
value: new Date(2026, 5, 7, 9, 45), onChange: (d: Date | null) => (got = d),
|
|
111
|
+
})
|
|
112
|
+
try {
|
|
113
|
+
target.querySelector<HTMLButtonElement>('.sv-dtp__toggle')!.click()
|
|
114
|
+
flushSync()
|
|
115
|
+
const day20 = Array.from(panel()!.querySelectorAll<HTMLButtonElement>('.sv-cal__day')).find(
|
|
116
|
+
(b) => b.textContent?.trim() === '20' && !b.classList.contains('is-outside'),
|
|
117
|
+
)!
|
|
118
|
+
day20.click()
|
|
119
|
+
flushSync()
|
|
120
|
+
expect(got!.getDate()).toBe(20)
|
|
121
|
+
expect(got!.getHours()).toBe(9) // time preserved
|
|
122
|
+
expect(got!.getMinutes()).toBe(45)
|
|
123
|
+
} finally { destroy() }
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('honors dropDownDisplayMode=calendar (no tabs, calendar only)', () => {
|
|
127
|
+
const { target, destroy } = mountDtp({ value: new Date(2026, 5, 7), dropDownDisplayMode: 'calendar' })
|
|
128
|
+
try {
|
|
129
|
+
target.querySelector<HTMLButtonElement>('.sv-dtp__toggle')!.click()
|
|
130
|
+
flushSync()
|
|
131
|
+
expect(panel()!.querySelectorAll('[role="tab"]').length).toBe(0)
|
|
132
|
+
expect(panel()!.querySelector('.sv-cal')).not.toBeNull()
|
|
133
|
+
expect(panel()!.querySelector('.sv-tp')).toBeNull()
|
|
134
|
+
} finally { destroy() }
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvDropDownList - a single-select dropdown (trigger button + portalled list,
|
|
4
|
+
* no typing). Parity: Smart `smart-drop-down-list`. Controlled via `value` +
|
|
5
|
+
* `onChange`. Popover escapes scroll clipping via popover.ts.
|
|
6
|
+
*
|
|
7
|
+
* One styled renderer over the headless `createDropdownList` core (state +
|
|
8
|
+
* keyboard + ARIA); only portal/measure/scroll render concerns live here.
|
|
9
|
+
*/
|
|
10
|
+
import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
|
|
11
|
+
import type { ListOption } from './list-option'
|
|
12
|
+
import { createDropdownList } from './createDropdownList.svelte'
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
options: ReadonlyArray<ListOption>
|
|
16
|
+
value?: string | number | null
|
|
17
|
+
onChange?: (value: string | number) => void
|
|
18
|
+
placeholder?: string
|
|
19
|
+
disabled?: boolean
|
|
20
|
+
name?: string
|
|
21
|
+
size?: 'sm' | 'md' | 'lg'
|
|
22
|
+
ariaLabel?: string
|
|
23
|
+
autoOpen?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let { options, value = null, onChange, placeholder = 'Select…', disabled = false, name, size = 'md', ariaLabel, autoOpen = false }: Props = $props()
|
|
27
|
+
|
|
28
|
+
let triggerEl = $state<HTMLButtonElement | null>(null)
|
|
29
|
+
let panelEl = $state<HTMLDivElement | null>(null)
|
|
30
|
+
let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
|
|
31
|
+
|
|
32
|
+
const ddl = createDropdownList({
|
|
33
|
+
options: () => options,
|
|
34
|
+
value: () => value,
|
|
35
|
+
onChange: (v) => onChange?.(v),
|
|
36
|
+
disabled: () => disabled,
|
|
37
|
+
ariaLabel: () => ariaLabel,
|
|
38
|
+
focusTrigger: () => triggerEl?.focus(),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const selected = $derived(ddl.selected)
|
|
42
|
+
|
|
43
|
+
function updatePos() {
|
|
44
|
+
if (!triggerEl) return
|
|
45
|
+
rect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: Math.min(options.length, 8) * 34 + 8 })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
$effect(() => {
|
|
49
|
+
if (!ddl.open) return
|
|
50
|
+
updatePos()
|
|
51
|
+
const rp = () => updatePos()
|
|
52
|
+
window.addEventListener('scroll', rp, true); window.addEventListener('resize', rp)
|
|
53
|
+
const od = (e: PointerEvent) => { const t = e.target as Node | null; if (t && (triggerEl?.contains(t) || panelEl?.contains(t))) return; ddl.close() }
|
|
54
|
+
document.addEventListener('pointerdown', od, true)
|
|
55
|
+
return () => { window.removeEventListener('scroll', rp, true); window.removeEventListener('resize', rp); document.removeEventListener('pointerdown', od, true) }
|
|
56
|
+
})
|
|
57
|
+
// Keep the active option scrolled into view (render concern).
|
|
58
|
+
$effect(() => {
|
|
59
|
+
if (!ddl.open) return
|
|
60
|
+
const i = ddl.activeIndex
|
|
61
|
+
queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${i}"]`)?.scrollIntoView({ block: 'nearest' }))
|
|
62
|
+
})
|
|
63
|
+
function focusOpen(node: HTMLButtonElement) { if (autoOpen) { node.focus(); ddl.openPanel() } }
|
|
64
|
+
</script>
|
|
65
|
+
|
|
66
|
+
<button
|
|
67
|
+
bind:this={triggerEl}
|
|
68
|
+
class="sv-ddl sv-ddl--{size}"
|
|
69
|
+
class:is-open={ddl.open}
|
|
70
|
+
class:is-disabled={disabled}
|
|
71
|
+
{...ddl.triggerProps()}
|
|
72
|
+
use:focusOpen
|
|
73
|
+
>
|
|
74
|
+
<span class="sv-ddl__value" class:is-placeholder={!selected}>{selected?.label ?? placeholder}</span>
|
|
75
|
+
<svg class="sv-ddl__chev" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
|
|
76
|
+
</button>
|
|
77
|
+
|
|
78
|
+
{#if ddl.open}
|
|
79
|
+
<div bind:this={panelEl} class="sv-ddl__panel" use:portalToBody style:position="fixed" style:top={`${rect.top}px`} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} {...ddl.listboxProps()}>
|
|
80
|
+
{#each options as opt, i (opt.value)}
|
|
81
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
|
|
82
|
+
<div
|
|
83
|
+
class="sv-ddl__opt"
|
|
84
|
+
class:is-active={ddl.isActive(i)}
|
|
85
|
+
class:is-selected={ddl.isSelected(opt)}
|
|
86
|
+
class:is-disabled={opt.disabled}
|
|
87
|
+
{...ddl.optionProps(i)}
|
|
88
|
+
>{opt.label}</div>
|
|
89
|
+
{/each}
|
|
90
|
+
</div>
|
|
91
|
+
{/if}
|
|
92
|
+
{#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
|
|
93
|
+
|
|
94
|
+
<style>
|
|
95
|
+
.sv-ddl {
|
|
96
|
+
--_accent: var(--sg-accent, #2563eb);
|
|
97
|
+
display: inline-flex; align-items: center; justify-content: space-between; gap: 8px; width: 200px;
|
|
98
|
+
background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; text-align: left;
|
|
99
|
+
border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
|
|
100
|
+
padding: 0 10px; cursor: pointer;
|
|
101
|
+
}
|
|
102
|
+
.sv-ddl--sm { height: 28px; font-size: 12px; }
|
|
103
|
+
.sv-ddl--md { height: 34px; font-size: 13px; }
|
|
104
|
+
.sv-ddl--lg { height: 40px; font-size: 15px; }
|
|
105
|
+
.sv-ddl.is-open, .sv-ddl:focus-visible { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); outline: none; }
|
|
106
|
+
.sv-ddl.is-disabled { opacity: 0.6; cursor: not-allowed; }
|
|
107
|
+
.sv-ddl__value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
108
|
+
.sv-ddl__value.is-placeholder { color: var(--sg-muted, #94a3b8); }
|
|
109
|
+
.sv-ddl__chev { color: var(--sg-muted, #64748b); flex: none; }
|
|
110
|
+
|
|
111
|
+
:global(.sv-ddl__panel) {
|
|
112
|
+
z-index: 2147483647; max-height: 288px; overflow-y: auto; padding: 4px;
|
|
113
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
114
|
+
border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px; box-shadow: 0 16px 48px -12px rgba(15,23,42,0.35);
|
|
115
|
+
}
|
|
116
|
+
:global(.sv-ddl__opt) { padding: 7px 10px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
|
117
|
+
:global(.sv-ddl__opt.is-active) { background: var(--sg-row-hover-bg, #f1f5f9); }
|
|
118
|
+
:global(.sv-ddl__opt.is-selected) { color: var(--sg-accent, #2563eb); font-weight: 600; }
|
|
119
|
+
:global(.sv-ddl__opt.is-disabled) { opacity: 0.4; cursor: not-allowed; }
|
|
120
|
+
</style>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type FormFieldType =
|
|
3
|
+
| 'text' | 'email' | 'tel' | 'textarea' | 'number' | 'password'
|
|
4
|
+
| 'select' | 'checkbox' | 'switch' | 'date' | 'color' | 'rating'
|
|
5
|
+
|
|
6
|
+
export type FormField = {
|
|
7
|
+
name: string
|
|
8
|
+
label: string
|
|
9
|
+
type?: FormFieldType
|
|
10
|
+
required?: boolean
|
|
11
|
+
placeholder?: string
|
|
12
|
+
options?: Array<{ value: string | number; label: string }>
|
|
13
|
+
/** Return an error message, or null/undefined when valid. */
|
|
14
|
+
validate?: (value: any, values: Record<string, any>) => string | null | undefined
|
|
15
|
+
/** Span full width in the grid. */
|
|
16
|
+
full?: boolean
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
/**
|
|
22
|
+
* SvForm - a schema-driven form that renders the SvGrid UI-kit controls with
|
|
23
|
+
* labels, required + custom validation, and a submit handler. Parity: Smart
|
|
24
|
+
* `smart-form`. Emits `onSubmit(values)` only when valid; `onChange` on edits.
|
|
25
|
+
*/
|
|
26
|
+
import SvNumberInput from './SvNumberInput.svelte'
|
|
27
|
+
import SvPasswordInput from './SvPasswordInput.svelte'
|
|
28
|
+
import SvDropDownList from './SvDropDownList.svelte'
|
|
29
|
+
import SvCheckBox from './SvCheckBox.svelte'
|
|
30
|
+
import SvSwitchButton from './SvSwitchButton.svelte'
|
|
31
|
+
import SvDateTimePicker from './SvDateTimePicker.svelte'
|
|
32
|
+
import SvColorInput from './SvColorInput.svelte'
|
|
33
|
+
import SvRating from './SvRating.svelte'
|
|
34
|
+
import SvButton from './SvButton.svelte'
|
|
35
|
+
|
|
36
|
+
type Props = {
|
|
37
|
+
fields: ReadonlyArray<FormField>
|
|
38
|
+
initial?: Record<string, any>
|
|
39
|
+
onSubmit?: (values: Record<string, any>) => void
|
|
40
|
+
onChange?: (values: Record<string, any>) => void
|
|
41
|
+
submitLabel?: string
|
|
42
|
+
/** Columns in the responsive field grid. */
|
|
43
|
+
columns?: number
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let { fields, initial = {}, onSubmit, onChange, submitLabel = 'Submit', columns = 1, disabled = false }: Props = $props()
|
|
48
|
+
|
|
49
|
+
let values = $state<Record<string, any>>({})
|
|
50
|
+
let errors = $state<Record<string, string>>({})
|
|
51
|
+
let touched = $state<Record<string, boolean>>({})
|
|
52
|
+
let seededForm = false
|
|
53
|
+
$effect(() => { if (!seededForm) { seededForm = true; values = { ...initial } } })
|
|
54
|
+
|
|
55
|
+
function setValue(name: string, v: any) {
|
|
56
|
+
values = { ...values, [name]: v }
|
|
57
|
+
onChange?.(values)
|
|
58
|
+
if (touched[name]) validateField(name)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function validateField(name: string): boolean {
|
|
62
|
+
const field = fields.find((f) => f.name === name)
|
|
63
|
+
if (!field) return true
|
|
64
|
+
const v = values[name]
|
|
65
|
+
let err: string | null | undefined = null
|
|
66
|
+
if (field.required && (v == null || v === '' || (Array.isArray(v) && !v.length))) err = `${field.label} is required`
|
|
67
|
+
else if (field.validate) err = field.validate(v, values)
|
|
68
|
+
const next = { ...errors }
|
|
69
|
+
if (err) next[name] = err
|
|
70
|
+
else delete next[name]
|
|
71
|
+
errors = next
|
|
72
|
+
return !err
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function submit(e: Event) {
|
|
76
|
+
e.preventDefault()
|
|
77
|
+
let ok = true
|
|
78
|
+
const t: Record<string, boolean> = {}
|
|
79
|
+
for (const f of fields) { t[f.name] = true; if (!validateField(f.name)) ok = false }
|
|
80
|
+
touched = t
|
|
81
|
+
if (ok) onSubmit?.({ ...values })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const dateVal = (v: any): Date | null => (v instanceof Date ? v : v ? new Date(v) : null)
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<form class="sv-form" style:--cols={columns} onsubmit={submit} novalidate>
|
|
88
|
+
<div class="sv-form__grid">
|
|
89
|
+
{#each fields as field (field.name)}
|
|
90
|
+
{@const type = field.type ?? 'text'}
|
|
91
|
+
<div class="sv-form__field" class:is-full={field.full || columns === 1} class:has-error={errors[field.name]}>
|
|
92
|
+
{#if type !== 'checkbox' && type !== 'switch'}
|
|
93
|
+
<label class="sv-form__label" for={`f-${field.name}`}>
|
|
94
|
+
{field.label}{#if field.required}<span class="sv-form__req" aria-hidden="true"> *</span>{/if}
|
|
95
|
+
</label>
|
|
96
|
+
{/if}
|
|
97
|
+
|
|
98
|
+
{#if type === 'textarea'}
|
|
99
|
+
<textarea id={`f-${field.name}`} class="sv-form__control sv-form__textarea" rows="3" {disabled} placeholder={field.placeholder} value={values[field.name] ?? ''} oninput={(e) => setValue(field.name, e.currentTarget.value)} onblur={() => { touched[field.name] = true; validateField(field.name) }}></textarea>
|
|
100
|
+
{:else if type === 'number'}
|
|
101
|
+
<SvNumberInput value={values[field.name] ?? null} {disabled} onChange={(v) => setValue(field.name, v)} />
|
|
102
|
+
{:else if type === 'password'}
|
|
103
|
+
<SvPasswordInput value={values[field.name] ?? ''} {disabled} onChange={(v) => setValue(field.name, v)} />
|
|
104
|
+
{:else if type === 'select'}
|
|
105
|
+
<SvDropDownList options={field.options ?? []} value={values[field.name] ?? null} {disabled} placeholder={field.placeholder} onChange={(v) => setValue(field.name, v)} />
|
|
106
|
+
{:else if type === 'checkbox'}
|
|
107
|
+
<SvCheckBox checked={!!values[field.name]} {disabled} onChange={(v) => setValue(field.name, v)}>{field.label}</SvCheckBox>
|
|
108
|
+
{:else if type === 'switch'}
|
|
109
|
+
<div class="sv-form__inline"><SvSwitchButton checked={!!values[field.name]} {disabled} onChange={(v) => setValue(field.name, v)} ariaLabel={field.label} /><span>{field.label}</span></div>
|
|
110
|
+
{:else if type === 'date'}
|
|
111
|
+
<SvDateTimePicker value={dateVal(values[field.name])} dropDownDisplayMode="calendar" formatString="yyyy-MM-dd" {disabled} onChange={(v) => setValue(field.name, v)} />
|
|
112
|
+
{:else if type === 'color'}
|
|
113
|
+
<SvColorInput value={values[field.name] ?? '#3b82f6'} {disabled} onChange={(v) => setValue(field.name, v)} />
|
|
114
|
+
{:else if type === 'rating'}
|
|
115
|
+
<SvRating value={values[field.name] ?? 0} {disabled} onChange={(v) => setValue(field.name, v)} />
|
|
116
|
+
{:else}
|
|
117
|
+
<input id={`f-${field.name}`} class="sv-form__control" type={type} {disabled} placeholder={field.placeholder} value={values[field.name] ?? ''} oninput={(e) => setValue(field.name, e.currentTarget.value)} onblur={() => { touched[field.name] = true; validateField(field.name) }} />
|
|
118
|
+
{/if}
|
|
119
|
+
|
|
120
|
+
{#if errors[field.name]}<span class="sv-form__error" role="alert">{errors[field.name]}</span>{/if}
|
|
121
|
+
</div>
|
|
122
|
+
{/each}
|
|
123
|
+
</div>
|
|
124
|
+
<div class="sv-form__actions">
|
|
125
|
+
<SvButton type="submit" {disabled}>{submitLabel}</SvButton>
|
|
126
|
+
</div>
|
|
127
|
+
</form>
|
|
128
|
+
|
|
129
|
+
<style>
|
|
130
|
+
.sv-form { --_accent: var(--sg-accent, #2563eb); width: 100%; }
|
|
131
|
+
.sv-form__grid { display: grid; grid-template-columns: repeat(var(--cols, 1), minmax(0, 1fr)); gap: 14px 18px; }
|
|
132
|
+
.sv-form__field { display: flex; flex-direction: column; gap: 5px; }
|
|
133
|
+
.sv-form__field.is-full { grid-column: 1 / -1; }
|
|
134
|
+
.sv-form__label { font-size: 12.5px; font-weight: 600; color: var(--sg-fg, #0f172a); }
|
|
135
|
+
.sv-form__req { color: var(--sg-danger, #dc2626); }
|
|
136
|
+
.sv-form__control {
|
|
137
|
+
width: 100%; box-sizing: border-box; height: 34px; padding: 0 10px; font: inherit; font-size: 13px;
|
|
138
|
+
background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
139
|
+
border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px); outline: none;
|
|
140
|
+
}
|
|
141
|
+
.sv-form__textarea { height: auto; padding: 8px 10px; resize: vertical; }
|
|
142
|
+
.sv-form__control:focus { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
|
|
143
|
+
.sv-form__field.has-error .sv-form__control { border-color: var(--sg-danger, #dc2626); }
|
|
144
|
+
.sv-form__inline { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
|
145
|
+
.sv-form__error { font-size: 11.5px; color: var(--sg-danger, #dc2626); }
|
|
146
|
+
.sv-form__actions { margin-top: 16px; }
|
|
147
|
+
</style>
|