@svgrid/grid 2.2.16 → 2.2.17
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 +1 -1
- package/dist/GridFooter.svelte +12 -11
- package/dist/SvAnchor.svelte +65 -0
- package/dist/SvAnchor.svelte.d.ts +24 -0
- package/dist/SvAspectRatio.svelte +38 -0
- package/dist/SvAspectRatio.svelte.d.ts +16 -0
- package/dist/SvAutoComplete.svelte +52 -7
- package/dist/SvAutoComplete.svelte.d.ts +4 -1
- package/dist/SvBlockquote.svelte +43 -0
- package/dist/SvBlockquote.svelte.d.ts +16 -0
- package/dist/SvCalendar.svelte +33 -2
- package/dist/SvCalendar.svelte.d.ts +8 -1
- package/dist/SvCheckBox.svelte +6 -2
- package/dist/SvCode.svelte +52 -0
- package/dist/SvCode.svelte.d.ts +19 -0
- package/dist/SvCollapsible.svelte +75 -0
- package/dist/SvCollapsible.svelte.d.ts +27 -0
- package/dist/SvColorInput.svelte +11 -17
- package/dist/SvColorInput.svelte.d.ts +2 -0
- package/dist/SvComboBox.svelte +85 -8
- package/dist/SvComboBox.svelte.d.ts +14 -0
- package/dist/SvCountryInput.svelte +2 -2
- package/dist/SvCountryInput.svelte.d.ts +1 -1
- package/dist/SvDropDownList.svelte +154 -51
- package/dist/SvDropDownList.svelte.d.ts +17 -3
- package/dist/SvDurationInput.svelte +59 -41
- package/dist/SvDurationInput.svelte.d.ts +18 -0
- package/dist/SvField.svelte +203 -12
- package/dist/SvField.svelte.d.ts +45 -8
- package/dist/SvForm.svelte +195 -21
- package/dist/SvForm.svelte.d.ts +23 -0
- package/dist/SvGrid.controller.svelte.d.ts +12 -1
- package/dist/SvGrid.controller.svelte.js +87 -7
- package/dist/SvGrid.svelte +383 -181
- package/dist/SvGrid.types.d.ts +123 -2
- package/dist/SvGridChartView.svelte +70 -0
- package/dist/SvGridChartView.svelte.d.ts +11 -0
- package/dist/SvGridDropdown.svelte +9 -2
- package/dist/SvGridSelect.svelte +5 -2
- package/dist/SvGridSelect.svelte.d.ts +1 -1
- package/dist/SvGroup.svelte +41 -0
- package/dist/SvGroup.svelte.d.ts +27 -0
- package/dist/SvHoverCard.svelte +70 -0
- package/dist/SvHoverCard.svelte.d.ts +41 -0
- package/dist/SvKbd.svelte +51 -0
- package/dist/SvKbd.svelte.d.ts +18 -0
- package/dist/SvList.svelte +55 -0
- package/dist/SvList.svelte.d.ts +23 -0
- package/dist/SvListBox.svelte +166 -77
- package/dist/SvListBox.svelte.d.ts +17 -4
- package/dist/SvLoadingOverlay.svelte +53 -0
- package/dist/SvLoadingOverlay.svelte.d.ts +28 -0
- package/dist/SvMark.svelte +36 -0
- package/dist/SvMark.svelte.d.ts +14 -0
- package/dist/SvMaskedInput.svelte +50 -35
- package/dist/SvMaskedInput.svelte.d.ts +17 -2
- package/dist/SvMenuList.svelte +32 -3
- package/dist/SvMenubar.svelte +200 -0
- package/dist/SvMenubar.svelte.d.ts +18 -0
- package/dist/SvMultiSelect.svelte +7 -4
- package/dist/SvMultiSelect.svelte.d.ts +1 -1
- package/dist/SvNumberInput.svelte +79 -42
- package/dist/SvNumberInput.svelte.d.ts +25 -0
- package/dist/SvOtpInput.svelte +2 -2
- package/dist/SvPasswordInput.svelte +51 -31
- package/dist/SvPasswordInput.svelte.d.ts +17 -0
- package/dist/SvPhoneInput.svelte +39 -28
- package/dist/SvPhoneInput.svelte.d.ts +4 -0
- package/dist/SvPopconfirm.svelte +79 -0
- package/dist/SvPopconfirm.svelte.d.ts +42 -0
- package/dist/SvPopover.svelte +87 -29
- package/dist/SvPopover.svelte.d.ts +18 -4
- package/dist/SvRadioGroup.svelte +5 -1
- package/dist/SvResult.svelte +73 -0
- package/dist/SvResult.svelte.d.ts +28 -0
- package/dist/SvSegmented.svelte +114 -0
- package/dist/SvSegmented.svelte.d.ts +29 -0
- package/dist/SvSimpleGrid.svelte +41 -0
- package/dist/SvSimpleGrid.svelte.d.ts +25 -0
- package/dist/SvSlider.svelte +23 -10
- package/dist/SvSpinner.svelte +43 -0
- package/dist/SvSpinner.svelte.d.ts +20 -0
- package/dist/SvStack.svelte +37 -0
- package/dist/SvStack.svelte.d.ts +24 -0
- package/dist/SvSwitchButton.svelte +6 -1
- package/dist/SvTagsInput.svelte +9 -5
- package/dist/SvText.svelte +76 -0
- package/dist/SvText.svelte.d.ts +31 -0
- package/dist/SvTextInput.svelte +75 -43
- package/dist/SvTextInput.svelte.d.ts +28 -1
- package/dist/SvTimePicker.svelte +32 -2
- package/dist/SvTimePicker.svelte.d.ts +8 -9
- package/dist/SvTitle.svelte +57 -0
- package/dist/SvTitle.svelte.d.ts +24 -0
- package/dist/SvToaster.svelte +41 -6
- package/dist/SvToggleButton.svelte +4 -1
- package/dist/SvTooltip.svelte +68 -34
- package/dist/SvTooltip.svelte.d.ts +14 -2
- package/dist/SvTree.svelte +81 -21
- package/dist/SvTree.svelte.d.ts +3 -2
- package/dist/SvTreeSelect.svelte +5 -2
- package/dist/SvTreeSelect.svelte.d.ts +1 -1
- package/dist/SvVisuallyHidden.svelte +49 -0
- package/dist/SvVisuallyHidden.svelte.d.ts +19 -0
- package/dist/ai.d.ts +289 -0
- package/dist/ai.js +1013 -0
- package/dist/board-view.svelte.d.ts +22 -0
- package/dist/board-view.svelte.js +13 -0
- package/dist/build-api.js +36 -6
- package/dist/cdn/GridMenus-0AK9O7qG.js +420 -0
- package/dist/cdn/GridMenus-BjUoCXoD.js +416 -0
- package/dist/cdn/SvDateTimePicker-CYC7gRER.js +2176 -0
- package/dist/cdn/SvDateTimePicker-DtQKd4Ns.js +2198 -0
- package/dist/cdn/SvGridChart-BcLCYvw9.js +1195 -0
- package/dist/cdn/SvGridChart-BhnFdLd0.js +1203 -0
- package/dist/cdn/SvGridChartPanel-Cmx9DHAS.js +561 -0
- package/dist/cdn/SvGridChartPanel-DvjwxLvK.js +577 -0
- package/dist/cdn/SvGridChartView-B40DTJSX.js +54 -0
- package/dist/cdn/SvGridChartView-PwqI4s02.js +53 -0
- package/dist/cdn/SvGridDropdown-DjALc_4f.js +255 -0
- package/dist/cdn/SvGridDropdown-hpnHvOGt.js +254 -0
- package/dist/cdn/chart-T1usDN0o.js +1212 -0
- package/dist/cdn/disclose-version-DoD9AFD_.js +2958 -0
- package/dist/cdn/export-format-JT5pWVT8.js +561 -0
- package/dist/cdn/popover-P-9P2YYn.js +69 -0
- package/dist/cdn/rolldown-runtime-Dy4uBu1J.js +11 -0
- package/dist/cdn/src-BLhdLmoN.js +25707 -0
- package/dist/cdn/src-BwnUVj3T.js +25623 -0
- package/dist/cdn/svgrid.js +9 -32897
- package/dist/cdn/svgrid.svelte-external.js +9 -30052
- package/dist/cell-formatting.d.ts +2 -2
- package/dist/cell-render.js +4 -4
- package/dist/chart-view.svelte.d.ts +25 -0
- package/dist/chart-view.svelte.js +14 -0
- package/dist/createCombobox.svelte.d.ts +5 -0
- package/dist/createCombobox.svelte.js +21 -5
- package/dist/createDropdownList.svelte.d.ts +3 -0
- package/dist/createDropdownList.svelte.js +8 -2
- package/dist/createForm.svelte.d.ts +18 -0
- package/dist/createForm.svelte.js +51 -6
- package/dist/createMenu.svelte.js +27 -0
- package/dist/createPopoverSelect.svelte.d.ts +3 -0
- package/dist/createPopoverSelect.svelte.js +9 -1
- package/dist/createSlider.svelte.d.ts +8 -21
- package/dist/createSlider.svelte.js +34 -0
- package/dist/createTooltip.svelte.d.ts +12 -0
- package/dist/createTooltip.svelte.js +72 -9
- package/dist/editor-contract.d.ts +15 -0
- package/dist/export-provider.d.ts +27 -0
- package/dist/export-provider.js +9 -0
- package/dist/filter-operators.d.ts +6 -0
- package/dist/filter-operators.js +33 -0
- package/dist/form-field.d.ts +45 -2
- package/dist/grid-messages.d.ts +61 -0
- package/dist/grid-messages.js +72 -0
- package/dist/group-display.d.ts +62 -0
- package/dist/group-display.js +78 -0
- package/dist/index.d.ts +36 -4
- package/dist/index.js +43 -2
- package/dist/js-scroller.svelte.d.ts +35 -0
- package/dist/js-scroller.svelte.js +154 -0
- package/dist/list-option.d.ts +46 -0
- package/dist/list-option.js +53 -0
- package/dist/menus.js +25 -11
- package/dist/pivot-view.svelte.d.ts +72 -0
- package/dist/pivot-view.svelte.js +13 -0
- package/dist/positioning.d.ts +111 -0
- package/dist/positioning.js +191 -0
- package/dist/scheduler-model.d.ts +3 -0
- package/dist/scheduler-model.js +22 -5
- package/dist/svgrid-wrapper.types.d.ts +18 -0
- package/dist/toast-store.svelte.d.ts +54 -0
- package/dist/toast-store.svelte.js +65 -15
- package/package.json +9 -1
- package/src/GridFooter.svelte +12 -11
- package/src/SvAnchor.svelte +65 -0
- package/src/SvAspectRatio.svelte +38 -0
- package/src/SvAutoComplete.svelte +52 -7
- package/src/SvBlockquote.svelte +43 -0
- package/src/SvCalendar.svelte +33 -2
- package/src/SvCalendar.test.ts +15 -0
- package/src/SvCheckBox.svelte +6 -2
- package/src/SvCode.svelte +52 -0
- package/src/SvCollapsible.svelte +75 -0
- package/src/SvColorInput.svelte +11 -17
- package/src/SvComboBox.svelte +85 -8
- package/src/SvCountryInput.svelte +2 -2
- package/src/SvDropDownList.svelte +154 -51
- package/src/SvDurationInput.svelte +59 -41
- package/src/SvField.svelte +203 -12
- package/src/SvForm.svelte +195 -21
- package/src/SvForm.test.ts +112 -0
- package/src/SvGrid.controller.svelte.ts +88 -7
- package/src/SvGrid.svelte +383 -181
- package/src/SvGrid.types.ts +125 -1
- package/src/SvGridChartView.svelte +70 -0
- package/src/SvGridDropdown.svelte +9 -2
- package/src/SvGridSelect.svelte +5 -2
- package/src/SvGroup.svelte +41 -0
- package/src/SvHoverCard.svelte +70 -0
- package/src/SvKbd.svelte +51 -0
- package/src/SvList.svelte +55 -0
- package/src/SvListBox.svelte +166 -77
- package/src/SvLoadingOverlay.svelte +53 -0
- package/src/SvMark.svelte +36 -0
- package/src/SvMaskedInput.svelte +50 -35
- package/src/SvMenuList.svelte +32 -3
- package/src/SvMenubar.svelte +200 -0
- package/src/SvMultiSelect.svelte +7 -4
- package/src/SvNumberInput.svelte +79 -42
- package/src/SvOtpInput.svelte +2 -2
- package/src/SvPasswordInput.svelte +51 -31
- package/src/SvPhoneInput.svelte +39 -28
- package/src/SvPopconfirm.svelte +79 -0
- package/src/SvPopover.svelte +87 -29
- package/src/SvRadioGroup.svelte +5 -1
- package/src/SvResult.svelte +73 -0
- package/src/SvSegmented.svelte +114 -0
- package/src/SvSimpleGrid.svelte +41 -0
- package/src/SvSlider.svelte +23 -10
- package/src/SvSpinner.svelte +43 -0
- package/src/SvStack.svelte +37 -0
- package/src/SvSwitchButton.svelte +6 -1
- package/src/SvTagsInput.svelte +9 -5
- package/src/SvText.svelte +76 -0
- package/src/SvTextInput.svelte +75 -43
- package/src/SvTimePicker.svelte +32 -2
- package/src/SvTitle.svelte +57 -0
- package/src/SvToaster.svelte +41 -6
- package/src/SvToaster.test.ts +32 -0
- package/src/SvToggleButton.svelte +4 -1
- package/src/SvTooltip.svelte +68 -34
- package/src/SvTree.svelte +81 -21
- package/src/SvTreeSelect.svelte +5 -2
- package/src/SvVisuallyHidden.svelte +49 -0
- package/src/ai.test.ts +502 -0
- package/src/ai.ts +1391 -0
- package/src/board-view.svelte.ts +33 -0
- package/src/build-api.coverage.test.ts +101 -0
- package/src/build-api.ts +43 -18
- package/src/builtin-editors.grid.test.ts +1 -1
- package/src/cell-formatting.ts +5 -5
- package/src/cell-render.ts +4 -4
- package/src/chart-view.svelte.ts +36 -0
- package/src/createCombobox.svelte.ts +17 -5
- package/src/createDropdownList.svelte.ts +8 -2
- package/src/createForm.svelte.ts +59 -6
- package/src/createMenu.svelte.ts +23 -0
- package/src/createPopoverSelect.svelte.ts +8 -1
- package/src/createSlider.svelte.ts +20 -0
- package/src/createTooltip.svelte.ts +67 -9
- package/src/editor-contract.ts +16 -0
- package/src/export-provider.ts +40 -0
- package/src/filter-operators.ts +37 -0
- package/src/form-field.ts +42 -2
- package/src/grid-messages.test.ts +24 -0
- package/src/grid-messages.ts +137 -0
- package/src/group-display.test.ts +77 -0
- package/src/group-display.ts +117 -0
- package/src/i18n.grid.test.ts +73 -0
- package/src/index.ts +91 -2
- package/src/js-scroller.svelte.ts +173 -0
- package/src/list-option.test.ts +56 -0
- package/src/list-option.ts +81 -0
- package/src/menus.test.ts +32 -0
- package/src/menus.ts +25 -12
- package/src/pivot-view.svelte.ts +90 -0
- package/src/pivot-view.test.ts +38 -0
- package/src/pivot.grid.test.ts +102 -0
- package/src/positioning.test.ts +141 -0
- package/src/positioning.ts +266 -0
- package/src/scheduler-model.test.ts +16 -0
- package/src/scheduler-model.ts +24 -5
- package/src/svgrid-wrapper.types.ts +23 -0
- package/src/svgrid.charting.test.ts +8 -1
- package/src/svgrid.comments-autocomplete.test.ts +7 -2
- package/src/svgrid.context-menu.test.ts +8 -3
- package/src/toast-store.svelte.ts +98 -0
- package/src/toast-store.test.ts +62 -1
- package/src/ui-breadth.test.ts +138 -0
- package/src/ui-buttons.test.ts +39 -0
- package/src/ui-inputs.test.ts +2 -2
- package/src/ui-range.test.ts +9 -0
- package/src/ui-selection.test.ts +36 -0
- package/src/ui-tier1.test.ts +1 -1
- package/src/virtual.test.ts +20 -6
- package/dist/SvGridBoard.svelte +0 -2339
- package/dist/SvGridBoard.svelte.d.ts +0 -31
- package/src/SvGridBoard.svelte +0 -2339
- package/src/board.test.ts +0 -927
package/src/SvForm.svelte
CHANGED
|
@@ -12,18 +12,30 @@
|
|
|
12
12
|
* a flat `FormField[]` or mix in titled `FormSection`s; with `stepper` the
|
|
13
13
|
* sections become a validated wizard. Emits `onSubmit(values)` only when valid.
|
|
14
14
|
*/
|
|
15
|
+
import { untrack } from 'svelte'
|
|
15
16
|
import SvNumberInput from './SvNumberInput.svelte'
|
|
16
17
|
import SvPasswordInput from './SvPasswordInput.svelte'
|
|
17
18
|
import SvDropDownList from './SvDropDownList.svelte'
|
|
19
|
+
import SvMultiSelect from './SvMultiSelect.svelte'
|
|
18
20
|
import SvCheckBox from './SvCheckBox.svelte'
|
|
19
21
|
import SvSwitchButton from './SvSwitchButton.svelte'
|
|
20
22
|
import SvDateTimePicker from './SvDateTimePicker.svelte'
|
|
21
23
|
import SvColorInput from './SvColorInput.svelte'
|
|
22
24
|
import SvRating from './SvRating.svelte'
|
|
25
|
+
import SvRadioGroup from './SvRadioGroup.svelte'
|
|
26
|
+
import SvSlider from './SvSlider.svelte'
|
|
27
|
+
import SvTagsInput from './SvTagsInput.svelte'
|
|
28
|
+
import SvPhoneInput from './SvPhoneInput.svelte'
|
|
29
|
+
import SvCountryInput from './SvCountryInput.svelte'
|
|
30
|
+
import SvMaskedInput from './SvMaskedInput.svelte'
|
|
31
|
+
import SvComboBox from './SvComboBox.svelte'
|
|
32
|
+
import SvFileUpload from './SvFileUpload.svelte'
|
|
23
33
|
import SvButton from './SvButton.svelte'
|
|
24
34
|
import SvStepper from './SvStepper.svelte'
|
|
35
|
+
import SvTabs from './SvTabs.svelte'
|
|
25
36
|
import { isFormSection, type FormField, type FormEntry, type FormSection } from './form-field'
|
|
26
|
-
import { createForm } from './createForm.svelte'
|
|
37
|
+
import { createForm, type FormMessages } from './createForm.svelte'
|
|
38
|
+
import type { EditorDir } from './editor-contract'
|
|
27
39
|
|
|
28
40
|
type Props = {
|
|
29
41
|
fields: ReadonlyArray<FormEntry>
|
|
@@ -41,25 +53,97 @@
|
|
|
41
53
|
resetLabel?: string
|
|
42
54
|
/** Render titled sections as a validated multi-step wizard (requires sections). */
|
|
43
55
|
stepper?: boolean
|
|
56
|
+
/** Render titled sections as tabs (requires sections; ignored with `stepper`). */
|
|
57
|
+
tabs?: boolean
|
|
44
58
|
/** Columns in the responsive field grid (a section can override its own). */
|
|
45
59
|
columns?: number
|
|
46
60
|
disabled?: boolean
|
|
61
|
+
/** Inject server-side errors (`{ field: message }`); applied reactively. */
|
|
62
|
+
serverErrors?: Record<string, string>
|
|
63
|
+
/** A form-level error message shown in a banner above the fields. */
|
|
64
|
+
formError?: string
|
|
65
|
+
/** Show a summary of field errors (with focus links) above the form after a
|
|
66
|
+
* failed submit. */
|
|
67
|
+
errorSummary?: boolean
|
|
68
|
+
/** Heading for the error summary. Default "Please fix the following:". */
|
|
69
|
+
errorSummaryTitle?: string
|
|
70
|
+
/**
|
|
71
|
+
* Re-seed the form when the `initial` object identity changes (e.g. editing a
|
|
72
|
+
* different record). `off` (default) seeds once; `always` re-seeds on every
|
|
73
|
+
* change; `ifPristine` re-seeds only when the user has no unsaved edits.
|
|
74
|
+
*/
|
|
75
|
+
reinitialize?: 'off' | 'always' | 'ifPristine'
|
|
76
|
+
/** Text direction; `rtl` mirrors the form layout. */
|
|
77
|
+
dir?: EditorDir
|
|
78
|
+
/** Override the built-in generated strings (required / min-max items / checking). */
|
|
79
|
+
messages?: Partial<FormMessages>
|
|
47
80
|
}
|
|
48
81
|
|
|
49
82
|
let {
|
|
50
83
|
fields, initial = {}, onSubmit, onChange, onCancel,
|
|
51
84
|
submitLabel = 'Submit', cancelLabel = 'Cancel', showReset = false, resetLabel = 'Reset',
|
|
52
|
-
stepper = false, columns = 1, disabled = false,
|
|
85
|
+
stepper = false, tabs = false, columns = 1, disabled = false,
|
|
86
|
+
serverErrors, formError, errorSummary = false, errorSummaryTitle = 'Please fix the following:',
|
|
87
|
+
reinitialize = 'off', dir, messages,
|
|
53
88
|
}: Props = $props()
|
|
54
89
|
|
|
55
|
-
const
|
|
56
|
-
const
|
|
90
|
+
const resolvedDir = $derived(dir === 'ltr' || dir === 'rtl' ? dir : undefined)
|
|
91
|
+
const form = createForm({ fields: () => fields, initial, onSubmit, onChange, messages })
|
|
92
|
+
async function submit(e: Event) {
|
|
93
|
+
e.preventDefault()
|
|
94
|
+
const ok = await form.submit()
|
|
95
|
+
// In tabs mode, surface the first tab that has an error so it is not hidden.
|
|
96
|
+
if (!ok && tabbed) {
|
|
97
|
+
const first = form.firstErrorField()
|
|
98
|
+
const s = first ? sections.find((sec) => sec.fields.some((f) => f.name === first)) : undefined
|
|
99
|
+
if (s) activeTab = s.section
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let formEl = $state<HTMLFormElement | null>(null)
|
|
104
|
+
// Apply server-side errors reactively (e.g. after a failed API submit). The
|
|
105
|
+
// write is untracked so reading `errors` inside setErrors does not make this
|
|
106
|
+
// effect depend on (and re-run from) its own write.
|
|
107
|
+
$effect(() => {
|
|
108
|
+
const se = serverErrors
|
|
109
|
+
if (se) untrack(() => form.setErrors(se))
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// Opt-in re-seed when the `initial` identity changes. Only `initial` is tracked
|
|
113
|
+
// (read outside `untrack`); the isDirty check + reset run untracked so this
|
|
114
|
+
// never re-fires from its own writes.
|
|
115
|
+
let seeded = false
|
|
116
|
+
$effect(() => {
|
|
117
|
+
const init = initial
|
|
118
|
+
untrack(() => {
|
|
119
|
+
if (!seeded) { seeded = true; return } // the seed-once mount pass
|
|
120
|
+
if (reinitialize === 'off') return
|
|
121
|
+
if (reinitialize === 'ifPristine' && form.isDirty) return
|
|
122
|
+
form.reset(init)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const summaryItems = $derived(errorSummary ? form.errorList() : [])
|
|
127
|
+
function focusField(name: string) {
|
|
128
|
+
const wrap = formEl?.querySelector<HTMLElement>(`[data-field="${name}"]`)
|
|
129
|
+
wrap?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
|
130
|
+
wrap?.querySelector<HTMLElement>('input, select, textarea, button, [tabindex]')?.focus()
|
|
131
|
+
}
|
|
57
132
|
const dateVal = (v: any): Date | null => (v instanceof Date ? v : v ? new Date(v) : null)
|
|
133
|
+
// Resolve a field's options, running the function form (cascading) against the
|
|
134
|
+
// live values so a child list updates when its parent changes.
|
|
135
|
+
const resolveOptions = (field: FormField) =>
|
|
136
|
+
typeof field.options === 'function' ? field.options(form.values) : (field.options ?? [])
|
|
58
137
|
|
|
59
138
|
const sections = $derived(fields.filter(isFormSection) as FormSection[])
|
|
60
139
|
const wizard = $derived(stepper && sections.length > 0)
|
|
140
|
+
const tabbed = $derived(tabs && !stepper && sections.length > 0)
|
|
61
141
|
const stepItems = $derived(sections.map((s) => ({ label: s.section, description: s.description })))
|
|
62
142
|
|
|
143
|
+
let activeTab = $state<string | undefined>(undefined)
|
|
144
|
+
const sectionHasError = (s: FormSection) => s.fields.some((f) => form.error(f.name))
|
|
145
|
+
const tabItems = $derived(sections.map((s) => ({ id: s.section, label: sectionHasError(s) ? `${s.section} ⚠` : s.section })))
|
|
146
|
+
|
|
63
147
|
let step = $state(0)
|
|
64
148
|
async function next() {
|
|
65
149
|
const s = sections[step]
|
|
@@ -79,32 +163,57 @@
|
|
|
79
163
|
onChange: (v: any) => void
|
|
80
164
|
onBlur?: () => void
|
|
81
165
|
disabled: boolean
|
|
166
|
+
readonly?: boolean
|
|
167
|
+
invalid?: boolean
|
|
168
|
+
describedby?: string
|
|
82
169
|
placeholder?: string
|
|
83
170
|
options?: ReadonlyArray<{ value: string | number; label: string; color?: string }>
|
|
171
|
+
/** The source field, for type-specific extras (mask, min/max, loadOptions...). */
|
|
172
|
+
field?: FormField
|
|
84
173
|
}
|
|
85
174
|
</script>
|
|
86
175
|
|
|
87
176
|
{#snippet control(o: ControlOpts)}
|
|
88
177
|
{#if o.type === 'textarea'}
|
|
89
|
-
<textarea id={o.id} class="sv-form__control sv-form__textarea" rows="3" disabled={o.disabled} placeholder={o.placeholder} value={o.value ?? ''} oninput={(e) => o.onChange(e.currentTarget.value)} onblur={o.onBlur}></textarea>
|
|
178
|
+
<textarea id={o.id} class="sv-form__control sv-form__textarea" rows="3" disabled={o.disabled} readonly={o.readonly} placeholder={o.placeholder} value={o.value ?? ''} aria-invalid={o.invalid ? 'true' : undefined} aria-describedby={o.describedby} oninput={(e) => o.onChange(e.currentTarget.value)} onblur={o.onBlur}></textarea>
|
|
90
179
|
{:else if o.type === 'number'}
|
|
91
|
-
<SvNumberInput value={o.value ?? null} disabled={o.disabled} onChange={o.onChange} />
|
|
180
|
+
<SvNumberInput id={o.id} value={o.value ?? null} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} block min={o.field?.min ?? -Infinity} max={o.field?.max ?? Infinity} step={o.field?.step ?? 1} precision={o.field?.precision} prefix={o.field?.prefix ?? ''} suffix={o.field?.suffix ?? ''} onChange={o.onChange} />
|
|
92
181
|
{:else if o.type === 'password'}
|
|
93
|
-
<SvPasswordInput value={o.value ?? ''} disabled={o.disabled} onChange={o.onChange} />
|
|
182
|
+
<SvPasswordInput id={o.id} value={o.value ?? ''} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} block onChange={o.onChange} />
|
|
183
|
+
{:else if o.type === 'mask'}
|
|
184
|
+
<SvMaskedInput id={o.id} value={o.value ?? ''} mask={o.field?.mask ?? ''} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} block placeholder={o.placeholder} onChange={(m) => o.onChange(m)} />
|
|
185
|
+
{:else if o.type === 'phone'}
|
|
186
|
+
<SvPhoneInput id={o.id} value={o.value ?? ''} disabled={o.disabled} invalid={o.invalid} block placeholder={o.placeholder} onChange={(v) => o.onChange(v)} />
|
|
187
|
+
{:else if o.type === 'country'}
|
|
188
|
+
<SvCountryInput id={o.id} value={o.value ?? null} disabled={o.disabled} invalid={o.invalid} onChange={o.onChange} />
|
|
94
189
|
{:else if o.type === 'select'}
|
|
95
|
-
<SvDropDownList options={o.options ?? []} value={o.value ?? null} disabled={o.disabled} placeholder={o.placeholder} onChange={o.onChange} />
|
|
190
|
+
<SvDropDownList id={o.id} options={o.options ?? []} value={o.value ?? null} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} placeholder={o.placeholder} onChange={o.onChange} />
|
|
191
|
+
{:else if o.type === 'combobox'}
|
|
192
|
+
<SvComboBox id={o.id} options={o.options ?? []} value={o.value ?? null} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} clearable placeholder={o.placeholder} loadOptions={o.field?.loadOptions ? (q) => o.field!.loadOptions!(q, form.values) : undefined} onChange={o.onChange} />
|
|
193
|
+
{:else if o.type === 'multiselect'}
|
|
194
|
+
<SvMultiSelect id={o.id} options={o.options ?? []} value={Array.isArray(o.value) ? o.value : []} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} placeholder={o.placeholder} onChange={o.onChange} />
|
|
195
|
+
{:else if o.type === 'radio'}
|
|
196
|
+
<SvRadioGroup id={o.id} options={o.options ?? []} value={o.value ?? null} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} orientation="horizontal" onChange={o.onChange} />
|
|
197
|
+
{:else if o.type === 'tags'}
|
|
198
|
+
<SvTagsInput id={o.id} value={Array.isArray(o.value) ? o.value : []} disabled={o.disabled} invalid={o.invalid} placeholder={o.placeholder} onChange={o.onChange} />
|
|
199
|
+
{:else if o.type === 'slider'}
|
|
200
|
+
<SvSlider id={o.id} value={o.value ?? o.field?.min ?? 0} min={o.field?.min ?? 0} max={o.field?.max ?? 100} step={o.field?.step ?? 1} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} showValue onChange={o.onChange} />
|
|
96
201
|
{:else if o.type === 'checkbox'}
|
|
97
|
-
<SvCheckBox checked={!!o.value} disabled={o.disabled} onChange={o.onChange}>{o.label}</SvCheckBox>
|
|
202
|
+
<SvCheckBox checked={!!o.value} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} onChange={o.onChange}>{o.label}</SvCheckBox>
|
|
98
203
|
{:else if o.type === 'switch'}
|
|
99
|
-
<div class="sv-form__inline"><SvSwitchButton checked={!!o.value} disabled={o.disabled} onChange={o.onChange} ariaLabel={o.label} /><span>{o.label}</span></div>
|
|
204
|
+
<div class="sv-form__inline"><SvSwitchButton checked={!!o.value} disabled={o.disabled} readonly={o.readonly} onChange={o.onChange} ariaLabel={o.label} /><span>{o.label}</span></div>
|
|
100
205
|
{:else if o.type === 'date'}
|
|
101
206
|
<SvDateTimePicker value={dateVal(o.value)} dropDownDisplayMode="calendar" formatString="yyyy-MM-dd" disabled={o.disabled} onChange={o.onChange} />
|
|
207
|
+
{:else if o.type === 'datetime'}
|
|
208
|
+
<SvDateTimePicker value={dateVal(o.value)} dropDownDisplayMode="both" disabled={o.disabled} onChange={o.onChange} />
|
|
102
209
|
{:else if o.type === 'color'}
|
|
103
|
-
<SvColorInput value={o.value ?? '#3b82f6'} disabled={o.disabled} onChange={o.onChange} />
|
|
210
|
+
<SvColorInput id={o.id} value={o.value ?? '#3b82f6'} disabled={o.disabled} invalid={o.invalid} onChange={o.onChange} />
|
|
211
|
+
{:else if o.type === 'file'}
|
|
212
|
+
<SvFileUpload accept={o.field?.accept} multiple={o.field?.multiple ?? false} onChange={o.onChange} />
|
|
104
213
|
{:else if o.type === 'rating'}
|
|
105
|
-
<SvRating value={o.value ?? 0} disabled={o.disabled} onChange={o.onChange} />
|
|
214
|
+
<SvRating id={o.id} value={o.value ?? 0} disabled={o.disabled} readonly={o.readonly} invalid={o.invalid} onChange={o.onChange} />
|
|
106
215
|
{:else}
|
|
107
|
-
<input id={o.id} class="sv-form__control" type={o.type} disabled={o.disabled} placeholder={o.placeholder} value={o.value ?? ''} oninput={(e) => o.onChange(e.currentTarget.value)} onblur={o.onBlur} />
|
|
216
|
+
<input id={o.id} class="sv-form__control" type={o.type} disabled={o.disabled} readonly={o.readonly} placeholder={o.placeholder} value={o.value ?? ''} aria-invalid={o.invalid ? 'true' : undefined} aria-describedby={o.describedby} oninput={(e) => o.onChange(e.currentTarget.value)} onblur={o.onBlur} />
|
|
108
217
|
{/if}
|
|
109
218
|
{/snippet}
|
|
110
219
|
|
|
@@ -116,12 +225,13 @@
|
|
|
116
225
|
{#if type !== 'checkbox' && type !== 'switch'}
|
|
117
226
|
<label class="sv-form__label" for={id}>{itf.label}{#if itf.required}<span class="sv-form__req" aria-hidden="true"> *</span>{/if}</label>
|
|
118
227
|
{/if}
|
|
119
|
-
{@render control({ id, type, label: itf.label, value: form.itemValue(arrayName, i, itf.name), onChange: (v) => form.setItemValue(arrayName, i, itf.name, v), onBlur: () => form.handleItemBlur(arrayName, i, itf.name), disabled, placeholder: itf.placeholder, options: itf
|
|
120
|
-
{#if err}<span class="sv-form__error" role="alert">{err}</span>{/if}
|
|
228
|
+
{@render control({ id, type, label: itf.label, value: form.itemValue(arrayName, i, itf.name), onChange: (v) => form.setItemValue(arrayName, i, itf.name, v), onBlur: () => form.handleItemBlur(arrayName, i, itf.name), disabled, readonly: itf.readonly, invalid: !!err, describedby: err ? `${id}__error` : undefined, placeholder: itf.placeholder, options: resolveOptions(itf), field: itf })}
|
|
229
|
+
{#if err}<span class="sv-form__error" id={`${id}__error`} role="alert">{err}</span>{/if}
|
|
121
230
|
</div>
|
|
122
231
|
{/snippet}
|
|
123
232
|
|
|
124
233
|
{#snippet renderArray(field: FormField)}
|
|
234
|
+
{@const count = form.arrayItems(field.name).length}
|
|
125
235
|
<div class="sv-form__field is-full sv-form__array" class:has-error={form.error(field.name)}>
|
|
126
236
|
<label class="sv-form__label">{field.label}{#if field.required}<span class="sv-form__req" aria-hidden="true"> *</span>{/if}</label>
|
|
127
237
|
{#each form.arrayItems(field.name) as _item, i (i)}
|
|
@@ -129,7 +239,15 @@
|
|
|
129
239
|
<div class="sv-form__grid" style:--cols={field.itemFields?.length ?? 1}>
|
|
130
240
|
{#each field.itemFields ?? [] as itf (itf.name)}{@render renderItemField(field.name, i, itf)}{/each}
|
|
131
241
|
</div>
|
|
132
|
-
<
|
|
242
|
+
<div class="sv-form__arow-tools">
|
|
243
|
+
{#if count > 1}
|
|
244
|
+
<div class="sv-form__amove">
|
|
245
|
+
<button type="button" class="sv-form__amove-btn" disabled={disabled || i === 0} onclick={() => form.moveItem(field.name, i, i - 1)} aria-label="Move up" title="Move up">↑</button>
|
|
246
|
+
<button type="button" class="sv-form__amove-btn" disabled={disabled || i === count - 1} onclick={() => form.moveItem(field.name, i, i + 1)} aria-label="Move down" title="Move down">↓</button>
|
|
247
|
+
</div>
|
|
248
|
+
{/if}
|
|
249
|
+
<button type="button" class="sv-form__aremove" disabled={disabled} onclick={() => form.removeItem(field.name, i)} aria-label="Remove item">×</button>
|
|
250
|
+
</div>
|
|
133
251
|
</div>
|
|
134
252
|
{/each}
|
|
135
253
|
<div><SvButton type="button" variant="outline" size="sm" disabled={disabled} onclick={() => form.addItem(field.name)}>{field.addLabel ?? '+ Add'}</SvButton></div>
|
|
@@ -144,17 +262,19 @@
|
|
|
144
262
|
{@render renderArray(field)}
|
|
145
263
|
{:else}
|
|
146
264
|
{@const fieldDisabled = disabled || form.isDisabled(field.name)}
|
|
147
|
-
<div class="sv-form__field" class:is-full={field.full || cols === 1} class:has-error={form.error(field.name)}>
|
|
265
|
+
<div class="sv-form__field" data-field={field.name} class:is-full={(field.full || (field.span ?? 1) >= cols) || cols === 1} class:has-error={form.error(field.name)} style:grid-column={field.span && field.span > 1 && field.span < cols ? `span ${field.span}` : undefined}>
|
|
148
266
|
{#if type !== 'checkbox' && type !== 'switch'}
|
|
149
267
|
<label class="sv-form__label" for={`f-${field.name}`}>
|
|
150
268
|
{field.label}{#if field.required}<span class="sv-form__req" aria-hidden="true"> *</span>{/if}
|
|
151
269
|
</label>
|
|
152
270
|
{/if}
|
|
153
|
-
{@render control({ id: `f-${field.name}`, type, label: field.label, value: form.value(field.name), onChange: (v) => form.setValue(field.name, v), onBlur: () => form.handleBlur(field.name), disabled: fieldDisabled, placeholder: field.placeholder, options: field
|
|
271
|
+
{@render control({ id: `f-${field.name}`, type, label: field.label, value: form.value(field.name), onChange: (v) => form.setValue(field.name, v), onBlur: () => form.handleBlur(field.name), disabled: fieldDisabled, readonly: field.readonly || !!field.computed, invalid: !!form.error(field.name), describedby: form.error(field.name) ? `f-${field.name}__error` : undefined, placeholder: field.placeholder, options: resolveOptions(field), field })}
|
|
154
272
|
{#if form.error(field.name)}
|
|
155
|
-
<span class="sv-form__error" role="alert">{form.error(field.name)}</span>
|
|
273
|
+
<span class="sv-form__error" id={`f-${field.name}__error`} role="alert">{form.error(field.name)}</span>
|
|
156
274
|
{:else if form.isValidating(field.name)}
|
|
157
|
-
<span class="sv-form__checking" role="status"><span class="sv-form__spinner" aria-hidden="true"></span>
|
|
275
|
+
<span class="sv-form__checking" role="status"><span class="sv-form__spinner" aria-hidden="true"></span>{form.messages.checking}</span>
|
|
276
|
+
{:else if field.help}
|
|
277
|
+
<span class="sv-form__help">{field.help}</span>
|
|
158
278
|
{/if}
|
|
159
279
|
</div>
|
|
160
280
|
{/if}
|
|
@@ -179,7 +299,20 @@
|
|
|
179
299
|
</div>
|
|
180
300
|
{/snippet}
|
|
181
301
|
|
|
182
|
-
<form class="sv-form" onsubmit={submit} novalidate>
|
|
302
|
+
<form class="sv-form" bind:this={formEl} dir={resolvedDir} onsubmit={submit} novalidate>
|
|
303
|
+
{#if formError}
|
|
304
|
+
<div class="sv-form__banner" role="alert">{formError}</div>
|
|
305
|
+
{/if}
|
|
306
|
+
{#if errorSummary && summaryItems.length}
|
|
307
|
+
<div class="sv-form__summary" role="alert">
|
|
308
|
+
<p class="sv-form__summary-title">{errorSummaryTitle}</p>
|
|
309
|
+
<ul>
|
|
310
|
+
{#each summaryItems as it (it.name)}
|
|
311
|
+
<li><button type="button" class="sv-form__summary-link" onclick={() => focusField(it.name)}>{it.label}: {it.message}</button></li>
|
|
312
|
+
{/each}
|
|
313
|
+
</ul>
|
|
314
|
+
</div>
|
|
315
|
+
{/if}
|
|
183
316
|
{#if wizard}
|
|
184
317
|
{@const s = sections[Math.min(step, sections.length - 1)]!}
|
|
185
318
|
<SvStepper steps={stepItems} current={step} onChange={(n) => (step = n)} linear />
|
|
@@ -201,6 +334,17 @@
|
|
|
201
334
|
<SvButton type="submit" loading={form.submitting} disabled={disabled || form.submitting}>{submitLabel}</SvButton>
|
|
202
335
|
{/if}
|
|
203
336
|
</div>
|
|
337
|
+
{:else if tabbed}
|
|
338
|
+
<SvTabs tabs={tabItems} bind:value={activeTab} ariaLabel="Form sections">
|
|
339
|
+
{#snippet panel(id)}
|
|
340
|
+
{@const s = sections.find((sec) => sec.section === id)}
|
|
341
|
+
{#if s}
|
|
342
|
+
{#if s.description}<p class="sv-form__section-desc">{s.description}</p>{/if}
|
|
343
|
+
{@render grid(s.fields, s.columns ?? columns)}
|
|
344
|
+
{/if}
|
|
345
|
+
{/snippet}
|
|
346
|
+
</SvTabs>
|
|
347
|
+
{@render actions()}
|
|
204
348
|
{:else if sections.length > 0}
|
|
205
349
|
{#each fields as entry (isFormSection(entry) ? entry.section : entry.name)}
|
|
206
350
|
{#if isFormSection(entry)}
|
|
@@ -240,6 +384,24 @@
|
|
|
240
384
|
.sv-form__field.has-error .sv-form__control { border-color: var(--sg-danger, #dc2626); }
|
|
241
385
|
.sv-form__inline { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
|
242
386
|
.sv-form__error { font-size: 11.5px; color: var(--sg-danger, #dc2626); }
|
|
387
|
+
.sv-form__help { font-size: 11.5px; color: var(--sg-muted, #64748b); }
|
|
388
|
+
.sv-form__banner {
|
|
389
|
+
padding: 10px 12px; border-radius: 8px; font-size: 13px;
|
|
390
|
+
background: color-mix(in srgb, var(--sg-danger, #dc2626) 10%, transparent);
|
|
391
|
+
color: var(--sg-danger, #dc2626); border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 35%, transparent);
|
|
392
|
+
}
|
|
393
|
+
.sv-form__summary {
|
|
394
|
+
padding: 12px 14px; border-radius: 8px;
|
|
395
|
+
background: color-mix(in srgb, var(--sg-danger, #dc2626) 7%, transparent);
|
|
396
|
+
border: 1px solid color-mix(in srgb, var(--sg-danger, #dc2626) 30%, transparent);
|
|
397
|
+
}
|
|
398
|
+
.sv-form__summary-title { margin: 0 0 6px; font-size: 13px; font-weight: 700; color: var(--sg-danger, #dc2626); }
|
|
399
|
+
.sv-form__summary ul { margin: 0; padding-inline-start: 18px; display: flex; flex-direction: column; gap: 3px; }
|
|
400
|
+
.sv-form__summary-link {
|
|
401
|
+
background: none; border: 0; padding: 0; font: inherit; font-size: 12.5px; text-align: start;
|
|
402
|
+
color: var(--sg-danger, #dc2626); cursor: pointer; text-decoration: underline;
|
|
403
|
+
}
|
|
404
|
+
.sv-form__summary-link:hover { color: var(--sg-fg, #0f172a); }
|
|
243
405
|
.sv-form__checking { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--sg-muted, #64748b); }
|
|
244
406
|
.sv-form__spinner {
|
|
245
407
|
width: 11px; height: 11px; border-radius: 50%;
|
|
@@ -260,4 +422,16 @@
|
|
|
260
422
|
}
|
|
261
423
|
.sv-form__aremove:hover:not(:disabled) { color: var(--sg-danger, #dc2626); border-color: var(--sg-danger, #dc2626); }
|
|
262
424
|
.sv-form__aremove:disabled { opacity: 0.5; cursor: default; }
|
|
425
|
+
.sv-form__arow-tools { display: flex; align-items: center; gap: 4px; margin-top: 10px; }
|
|
426
|
+
.sv-form__amove { display: inline-flex; flex-direction: column; }
|
|
427
|
+
.sv-form__amove-btn {
|
|
428
|
+
width: 22px; height: 14px; display: grid; place-items: center; padding: 0; cursor: pointer;
|
|
429
|
+
border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff); color: var(--sg-muted, #64748b);
|
|
430
|
+
font-size: 10px; line-height: 1;
|
|
431
|
+
}
|
|
432
|
+
.sv-form__amove-btn:first-child { border-radius: 6px 6px 0 0; border-bottom: 0; }
|
|
433
|
+
.sv-form__amove-btn:last-child { border-radius: 0 0 6px 6px; }
|
|
434
|
+
.sv-form__amove-btn:hover:not(:disabled) { color: var(--sg-accent, #2563eb); }
|
|
435
|
+
.sv-form__amove-btn:disabled { opacity: 0.4; cursor: default; }
|
|
436
|
+
.sv-form__arow-tools .sv-form__aremove { margin-top: 0; }
|
|
263
437
|
</style>
|
package/src/SvForm.test.ts
CHANGED
|
@@ -78,6 +78,79 @@ describe('SvForm', () => {
|
|
|
78
78
|
expect(onChange.mock.calls.at(-1)![0]).toMatchObject({ name: 'A' })
|
|
79
79
|
} finally { destroy() }
|
|
80
80
|
})
|
|
81
|
+
|
|
82
|
+
it('renders an error summary after a failed submit', () => {
|
|
83
|
+
const { target, destroy } = mountForm({ fields, errorSummary: true })
|
|
84
|
+
try {
|
|
85
|
+
submitForm(target)
|
|
86
|
+
const summary = target.querySelector('.sv-form__summary[role="alert"]')
|
|
87
|
+
expect(summary).not.toBeNull()
|
|
88
|
+
const links = target.querySelectorAll('.sv-form__summary-link')
|
|
89
|
+
expect(links.length).toBeGreaterThan(0)
|
|
90
|
+
expect(links[0]!.textContent).toContain('is required')
|
|
91
|
+
} finally { destroy() }
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('applies serverErrors reactively', () => {
|
|
95
|
+
const { target, destroy } = mountForm({ fields, serverErrors: { name: 'Name already taken' } })
|
|
96
|
+
try {
|
|
97
|
+
expect([...target.querySelectorAll('.sv-form__error')].some((e) => e.textContent?.includes('already taken'))).toBe(true)
|
|
98
|
+
} finally { destroy() }
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('overrides the built-in required message and links the error via aria-describedby', () => {
|
|
102
|
+
const { target, destroy } = mountForm({ fields, messages: { required: (l: string) => `${l} manquant` } })
|
|
103
|
+
try {
|
|
104
|
+
submitForm(target)
|
|
105
|
+
const err = target.querySelector('#f-name__error')
|
|
106
|
+
expect(err?.textContent).toBe('Name manquant')
|
|
107
|
+
// aria wiring: the input points at the error id
|
|
108
|
+
expect(target.querySelector('#f-name')?.getAttribute('aria-describedby')).toBe('f-name__error')
|
|
109
|
+
expect(target.querySelector('#f-name')?.getAttribute('aria-invalid')).toBe('true')
|
|
110
|
+
} finally { destroy() }
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('mirrors dir onto the form element', () => {
|
|
114
|
+
const { target, destroy } = mountForm({ fields, dir: 'rtl' })
|
|
115
|
+
try {
|
|
116
|
+
expect(target.querySelector('form.sv-form')?.getAttribute('dir')).toBe('rtl')
|
|
117
|
+
} finally { destroy() }
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('computed fields derive their value, render read-only, and are in the payload', async () => {
|
|
121
|
+
const onSubmit = vi.fn()
|
|
122
|
+
const cf = [
|
|
123
|
+
{ name: 'qty', label: 'Qty', type: 'number' as const },
|
|
124
|
+
{ name: 'price', label: 'Price', type: 'number' as const },
|
|
125
|
+
{ name: 'total', label: 'Total', type: 'text' as const, computed: (v: any) => (v.qty ?? 0) * (v.price ?? 0) },
|
|
126
|
+
]
|
|
127
|
+
const { target, destroy } = mountForm({ fields: cf, initial: { qty: 3, price: 4 }, onSubmit })
|
|
128
|
+
try {
|
|
129
|
+
const totalInput = target.querySelector<HTMLInputElement>('#f-total')!
|
|
130
|
+
expect(totalInput.value).toBe('12') // derived
|
|
131
|
+
expect(totalInput.readOnly).toBe(true) // read-only
|
|
132
|
+
submitForm(target); await tick()
|
|
133
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
134
|
+
expect(onSubmit.mock.calls[0]![0].total).toBe(12) // computed value in payload
|
|
135
|
+
} finally { destroy() }
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('cascading: changing a parent clears fields that dependOn it (onChange payload)', () => {
|
|
139
|
+
const onChange = vi.fn()
|
|
140
|
+
const cf = [
|
|
141
|
+
{ name: 'country', label: 'Country', options: [{ value: 'us', label: 'US' }, { value: 'ca', label: 'CA' }] },
|
|
142
|
+
{ name: 'state', label: 'State', dependsOn: 'country', options: (v: any) => (v.country === 'us' ? [{ value: 'ny', label: 'NY' }] : []) },
|
|
143
|
+
]
|
|
144
|
+
// country + state are plain text inputs here (default type) so we can drive them via the DOM.
|
|
145
|
+
const { target, destroy } = mountForm({ fields: cf, initial: { country: 'us', state: 'ny' }, onChange })
|
|
146
|
+
try {
|
|
147
|
+
setInput(target, 'f-country', 'ca')
|
|
148
|
+
// The last onChange payload has the parent updated AND the child cleared.
|
|
149
|
+
const last = onChange.mock.calls.at(-1)![0]
|
|
150
|
+
expect(last.country).toBe('ca')
|
|
151
|
+
expect(last.state).toBeNull()
|
|
152
|
+
} finally { destroy() }
|
|
153
|
+
})
|
|
81
154
|
})
|
|
82
155
|
|
|
83
156
|
describe('SvForm - conditional fields', () => {
|
|
@@ -251,6 +324,33 @@ describe('SvForm - stepper wizard', () => {
|
|
|
251
324
|
})
|
|
252
325
|
})
|
|
253
326
|
|
|
327
|
+
describe('SvForm - tabs layout', () => {
|
|
328
|
+
const schema = [
|
|
329
|
+
{ section: 'Profile', fields: [{ name: 'user', label: 'User', required: true }] },
|
|
330
|
+
{ section: 'Bio', fields: [{ name: 'bio', label: 'Bio' }] },
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
it('renders sections as tabs, one panel at a time; failed submit jumps to the erroring tab', async () => {
|
|
334
|
+
const onSubmit = vi.fn()
|
|
335
|
+
const { target, destroy } = mountForm({ fields: schema, tabs: true, onSubmit })
|
|
336
|
+
try {
|
|
337
|
+
const tabEls = target.querySelectorAll('[role="tab"]')
|
|
338
|
+
expect(tabEls.length).toBe(2)
|
|
339
|
+
// First tab active -> only its field is in the DOM
|
|
340
|
+
expect(target.querySelector('#f-user')).not.toBeNull()
|
|
341
|
+
expect(target.querySelector('#f-bio')).toBeNull()
|
|
342
|
+
// Switch to the Bio tab
|
|
343
|
+
;[...target.querySelectorAll<HTMLElement>('[role="tab"]')].find((t) => t.textContent?.includes('Bio'))!.click()
|
|
344
|
+
flushSync()
|
|
345
|
+
expect(target.querySelector('#f-bio')).not.toBeNull()
|
|
346
|
+
// Submit with the required User empty -> blocked and jumps back to Profile
|
|
347
|
+
submitForm(target); await tick()
|
|
348
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
349
|
+
expect(target.querySelector('#f-user')).not.toBeNull() // Profile tab is active again
|
|
350
|
+
} finally { destroy() }
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
254
354
|
describe('SvForm - field arrays', () => {
|
|
255
355
|
const schema = [
|
|
256
356
|
{ name: 'contacts', label: 'Contacts', type: 'array' as const, required: true, itemFields: [
|
|
@@ -296,4 +396,16 @@ describe('SvForm - field arrays', () => {
|
|
|
296
396
|
expect([...target.querySelectorAll('.sv-form__error')].some((e) => e.textContent?.includes('Contacts is required'))).toBe(true)
|
|
297
397
|
} finally { destroy() }
|
|
298
398
|
})
|
|
399
|
+
|
|
400
|
+
it('reorder buttons move an item up/down', () => {
|
|
401
|
+
const { target, destroy } = mountForm({ fields: schema, initial: { contacts: [{ name: 'A', email: '' }, { name: 'B', email: '' }] } })
|
|
402
|
+
try {
|
|
403
|
+
const nameVals = () => [...target.querySelectorAll<HTMLInputElement>('.sv-form__array [id$="-name"]')].map((i) => i.value)
|
|
404
|
+
expect(nameVals()).toEqual(['A', 'B'])
|
|
405
|
+
;(target.querySelectorAll<HTMLButtonElement>('button[aria-label="Move down"]')[0]!).click(); flushSync()
|
|
406
|
+
expect(nameVals()).toEqual(['B', 'A'])
|
|
407
|
+
;(target.querySelectorAll<HTMLButtonElement>('button[aria-label="Move up"]')[1]!).click(); flushSync()
|
|
408
|
+
expect(nameVals()).toEqual(['A', 'B'])
|
|
409
|
+
} finally { destroy() }
|
|
410
|
+
})
|
|
299
411
|
})
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
createSortedRowModel,
|
|
11
11
|
createSvGrid,
|
|
12
12
|
getGridCellDomId,
|
|
13
|
-
rowsToChartSpec,
|
|
14
13
|
sortFns,
|
|
15
14
|
tableFeatures,
|
|
16
15
|
rowSortingFeature,
|
|
@@ -94,6 +93,8 @@ import {
|
|
|
94
93
|
import {
|
|
95
94
|
createClipboard,
|
|
96
95
|
} from "./clipboard";
|
|
96
|
+
import { resolveGridMessages } from "./grid-messages";
|
|
97
|
+
import { getPivotEngine, hasPivotEngine } from "./pivot-view.svelte";
|
|
97
98
|
import {
|
|
98
99
|
filterOperatorOptions,
|
|
99
100
|
fallbackOperatorOption,
|
|
@@ -269,7 +270,28 @@ export type SvGridController<
|
|
|
269
270
|
export function createSvGridController<
|
|
270
271
|
TFeatures extends TableFeatures = TableFeatures,
|
|
271
272
|
TData extends RowData = RowData,
|
|
272
|
-
>(
|
|
273
|
+
>(rawProps: Props<TFeatures, TData>) {
|
|
274
|
+
|
|
275
|
+
// Runtime option overrides set via the imperative api (`api.setOption(key, value)`).
|
|
276
|
+
// Every controller read of a prop goes through the `props` proxy below (and the view
|
|
277
|
+
// reads controller-resolved gates like `_features` / `editingEnabled`, not the raw
|
|
278
|
+
// prop), so an override wins over the incoming prop and re-renders reactively -
|
|
279
|
+
// identical to the parent flipping that prop. A get-only Proxy (not a fresh spread)
|
|
280
|
+
// preserves reference identity for non-overridden keys, which the columns-identity
|
|
281
|
+
// column cache and the `data`/`columns` sync effects rely on.
|
|
282
|
+
let optionOverrides = $state<Record<string, unknown>>({});
|
|
283
|
+
const props = new Proxy(rawProps, {
|
|
284
|
+
// Read the override as a PROPERTY (tracked by `$state`), not via `in` (the `has`
|
|
285
|
+
// trap is not tracked) - so a reactive read of `props.X` re-runs when an override
|
|
286
|
+
// is set. `undefined` means "no override" and falls back to the incoming prop;
|
|
287
|
+
// `setOption(key, undefined)` clears by removing the key, which lands here as
|
|
288
|
+
// undefined too. Whole-object reassignment on write makes the signal fire.
|
|
289
|
+
get: (t, k) => {
|
|
290
|
+
const v =
|
|
291
|
+
typeof k === "string" ? optionOverrides[k] : undefined;
|
|
292
|
+
return v !== undefined ? v : Reflect.get(t, k);
|
|
293
|
+
},
|
|
294
|
+
}) as Props<TFeatures, TData>;
|
|
273
295
|
|
|
274
296
|
// Resolved capability gates. Capabilities are OFF by default - a bare
|
|
275
297
|
// grid is a plain read-only table, and each power feature is opted into
|
|
@@ -670,7 +692,8 @@ export function createSvGridController<
|
|
|
670
692
|
state: {
|
|
671
693
|
columnFilters: [],
|
|
672
694
|
grouping: [],
|
|
673
|
-
|
|
695
|
+
// svelte-ignore state_referenced_locally
|
|
696
|
+
sorting: props.initialSorting ?? [],
|
|
674
697
|
// svelte-ignore state_referenced_locally
|
|
675
698
|
pagination: { pageIndex: 0, pageSize: props.pageSize ?? 10 },
|
|
676
699
|
rowSelection: {},
|
|
@@ -1092,12 +1115,12 @@ export function createSvGridController<
|
|
|
1092
1115
|
|
|
1093
1116
|
let rows = rawRows;
|
|
1094
1117
|
if (globalFilter.trim()) {
|
|
1095
|
-
const needle = normalizeForFilter(globalFilter, props.filterLocale);
|
|
1118
|
+
const needle = normalizeForFilter(globalFilter, (props.filterLocale ?? props.localization?.locale));
|
|
1096
1119
|
rows = rows.filter((row) =>
|
|
1097
1120
|
row
|
|
1098
1121
|
.getAllCells()
|
|
1099
1122
|
.some((cell) =>
|
|
1100
|
-
normalizeForFilter(String(cell.getValue() ?? ""), props.filterLocale)
|
|
1123
|
+
normalizeForFilter(String(cell.getValue() ?? ""), (props.filterLocale ?? props.localization?.locale))
|
|
1101
1124
|
.includes(needle),
|
|
1102
1125
|
),
|
|
1103
1126
|
);
|
|
@@ -1119,7 +1142,7 @@ export function createSvGridController<
|
|
|
1119
1142
|
applyExcelFilter(
|
|
1120
1143
|
cellValue,
|
|
1121
1144
|
{ id: columnId, operator: op, value, valueTo: op === "between" ? valueTo : undefined },
|
|
1122
|
-
{ locale: props.filterLocale },
|
|
1145
|
+
{ locale: (props.filterLocale ?? props.localization?.locale) },
|
|
1123
1146
|
);
|
|
1124
1147
|
// A column filter is active if either of its (up to two) conditions is.
|
|
1125
1148
|
const menuFilters = Object.entries(filterMenuValues).filter(([_, f]) => {
|
|
@@ -1348,6 +1371,39 @@ export function createSvGridController<
|
|
|
1348
1371
|
return c === true ? ({} as ChartingConfig<TData>) : (c as ChartingConfig<TData>);
|
|
1349
1372
|
});
|
|
1350
1373
|
const chartingEnabled = $derived(!!chartCfg);
|
|
1374
|
+
// Resolved chrome messages (English defaults merged with `localeText`). One
|
|
1375
|
+
// map every consumer (SvGrid.svelte, GridFooter, menus, filter labels) reads.
|
|
1376
|
+
const gridMessages = $derived(resolveGridMessages(props.localization?.text));
|
|
1377
|
+
|
|
1378
|
+
// ---- In-grid pivot mode ----
|
|
1379
|
+
// The pivot ENGINE ships in @svgrid/enterprise (registered via the pivot-view
|
|
1380
|
+
// seam). When `pivot` is set and the engine is present, the grid runs it over
|
|
1381
|
+
// the already filtered + sorted leaf rows and renders the pivot result in
|
|
1382
|
+
// place. `pivotModeOn` is uncontrolled (seeded from the prop) so a toolbar
|
|
1383
|
+
// toggle can flip between the pivot and the flat table over the same data.
|
|
1384
|
+
const pivotConfig = $derived(props.pivot ?? null);
|
|
1385
|
+
// Controlled when `pivotMode` prop is provided; otherwise internal state
|
|
1386
|
+
// (seeded to on when `pivot` is set) drives it, flipped by the toolbar toggle.
|
|
1387
|
+
let pivotModeInternal = $state(props.pivotMode ?? (props.pivot != null));
|
|
1388
|
+
const pivotModeOn = $derived(props.pivotMode ?? pivotModeInternal);
|
|
1389
|
+
function togglePivotMode(): void {
|
|
1390
|
+
const next = !pivotModeOn;
|
|
1391
|
+
pivotModeInternal = next;
|
|
1392
|
+
props.onPivotModeChange?.(next);
|
|
1393
|
+
}
|
|
1394
|
+
const pivotActive = $derived(!!pivotConfig && pivotModeOn && hasPivotEngine());
|
|
1395
|
+
const pivotResult = $derived.by(() => {
|
|
1396
|
+
if (!pivotActive || !pivotConfig) return null;
|
|
1397
|
+
const engine = getPivotEngine();
|
|
1398
|
+
if (!engine) return null;
|
|
1399
|
+
// Feed the pivot the filtered + sorted LEAF rows (drop any group rows); its
|
|
1400
|
+
// output must not be re-run through the column-filter pipeline.
|
|
1401
|
+
const leaves = allRowsBeforePagination
|
|
1402
|
+
.filter((r) => !r.subRows)
|
|
1403
|
+
.map((r) => r.original);
|
|
1404
|
+
return engine(leaves, pivotConfig as never);
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1351
1407
|
const chartIsCustom = $derived(typeof chartCfg?.buildSpec === "function");
|
|
1352
1408
|
const chartingConfig = $derived(
|
|
1353
1409
|
chartCfg
|
|
@@ -1609,6 +1665,18 @@ export function createSvGridController<
|
|
|
1609
1665
|
};
|
|
1610
1666
|
};
|
|
1611
1667
|
|
|
1668
|
+
// The chart derivation engine (rowsToChartSpec) is loaded lazily the first time
|
|
1669
|
+
// charting is enabled, keeping chart.ts (~11 KB gzip) out of the base <SvGrid>
|
|
1670
|
+
// bundle - it rides in the same lazy chunk as the chart panel. `chartSpec` stays
|
|
1671
|
+
// null until it resolves, a tick after the panel itself starts loading (which it
|
|
1672
|
+
// already does). The `getAggregate` server path uses the local bucketsToSpec and
|
|
1673
|
+
// does not need the engine.
|
|
1674
|
+
let chartEngine = $state<typeof import("./chart").rowsToChartSpec | null>(null);
|
|
1675
|
+
$effect(() => {
|
|
1676
|
+
if (chartingEnabled && !chartEngine)
|
|
1677
|
+
import("./chart").then((m) => (chartEngine = m.rowsToChartSpec));
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1612
1680
|
const chartSpec = $derived.by<ChartSpec | null>(() => {
|
|
1613
1681
|
if (!chartingEnabled || !chartCfg) return null;
|
|
1614
1682
|
if (chartCfg.getAggregate) {
|
|
@@ -1627,9 +1695,11 @@ export function createSvGridController<
|
|
|
1627
1695
|
const category = fieldOf(effectiveChartDimensionId);
|
|
1628
1696
|
const values = effectiveChartMeasureIds.map((id) => fieldOf(id)).filter((f): f is string => !!f);
|
|
1629
1697
|
if (!category || !values.length) return null;
|
|
1698
|
+
// Engine not loaded yet (lazy): render nothing until it resolves.
|
|
1699
|
+
if (!chartEngine) return null;
|
|
1630
1700
|
const seriesField = fieldOf(effectiveChartSeriesId);
|
|
1631
1701
|
|
|
1632
|
-
const spec =
|
|
1702
|
+
const spec = chartEngine<Record<string, unknown>>(chartRows as Array<Record<string, unknown>>, {
|
|
1633
1703
|
type: chartType,
|
|
1634
1704
|
category: category as string,
|
|
1635
1705
|
value: values.length === 1 ? values[0]! : values,
|
|
@@ -2576,6 +2646,8 @@ export function createSvGridController<
|
|
|
2576
2646
|
|
|
2577
2647
|
const ctx = {
|
|
2578
2648
|
get props() { return props; },
|
|
2649
|
+
get optionOverrides() { return optionOverrides; },
|
|
2650
|
+
set optionOverrides(v) { optionOverrides = v as never; },
|
|
2579
2651
|
get editingEnabled() { return editingEnabled; },
|
|
2580
2652
|
get paginationEnabled() { return paginationEnabled; },
|
|
2581
2653
|
get groupingControlsEnabled() { return groupingControlsEnabled; },
|
|
@@ -2813,6 +2885,15 @@ export function createSvGridController<
|
|
|
2813
2885
|
get toolPanelHeaderLabel() { return toolPanelHeaderLabel; },
|
|
2814
2886
|
get toggleColumnVisibleInPanel() { return toggleColumnVisibleInPanel; },
|
|
2815
2887
|
get moveColumnInPanel() { return moveColumnInPanel; },
|
|
2888
|
+
// ---- Localized chrome strings ----
|
|
2889
|
+
get messages() { return gridMessages; },
|
|
2890
|
+
// ---- In-grid pivot mode ----
|
|
2891
|
+
get pivotConfig() { return pivotConfig; },
|
|
2892
|
+
get pivotActive() { return pivotActive; },
|
|
2893
|
+
get pivotResult() { return pivotResult; },
|
|
2894
|
+
get pivotModeOn() { return pivotModeOn; },
|
|
2895
|
+
get hasPivotEngine() { return hasPivotEngine(); },
|
|
2896
|
+
togglePivotMode,
|
|
2816
2897
|
// ---- Integrated charting ----
|
|
2817
2898
|
get chartingEnabled() { return chartingEnabled; },
|
|
2818
2899
|
get chartingConfig() { return chartingConfig; },
|