@svgrid/grid 1.2.21 → 1.2.23

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.
Files changed (190) hide show
  1. package/dist/GridFooter.svelte +34 -20
  2. package/dist/GridFooter.svelte.d.ts +8 -0
  3. package/dist/SvAutoComplete.svelte +100 -0
  4. package/dist/SvAutoComplete.svelte.d.ts +16 -0
  5. package/dist/SvButton.svelte +97 -0
  6. package/dist/SvButton.svelte.d.ts +23 -0
  7. package/dist/SvCalendar.svelte +669 -0
  8. package/dist/SvCalendar.svelte.d.ts +70 -0
  9. package/dist/SvCheckBox.svelte +81 -0
  10. package/dist/SvCheckBox.svelte.d.ts +19 -0
  11. package/dist/SvColorInput.svelte +187 -0
  12. package/dist/SvColorInput.svelte.d.ts +15 -0
  13. package/dist/SvComboBox.svelte +130 -0
  14. package/dist/SvComboBox.svelte.d.ts +15 -0
  15. package/dist/SvCountryInput.svelte +123 -0
  16. package/dist/SvCountryInput.svelte.d.ts +14 -0
  17. package/dist/SvDateTimePicker.svelte +322 -0
  18. package/dist/SvDateTimePicker.svelte.d.ts +37 -0
  19. package/dist/SvDropDownList.svelte +143 -0
  20. package/dist/SvDropDownList.svelte.d.ts +15 -0
  21. package/dist/SvForm.svelte +147 -0
  22. package/dist/SvForm.svelte.d.ts +29 -0
  23. package/dist/SvGauge.svelte +99 -0
  24. package/dist/SvGauge.svelte.d.ts +31 -0
  25. package/dist/SvGrid.controller.svelte.d.ts +16 -2
  26. package/dist/SvGrid.controller.svelte.js +63 -8
  27. package/dist/SvGrid.css +84 -31
  28. package/dist/SvGrid.helpers.d.ts +4 -0
  29. package/dist/SvGrid.helpers.js +40 -12
  30. package/dist/SvGrid.svelte +84 -5
  31. package/dist/SvGrid.types.d.ts +36 -0
  32. package/dist/SvGridDropdown.svelte +16 -30
  33. package/dist/SvGridDropdown.svelte.d.ts +4 -0
  34. package/dist/SvListBox.svelte +122 -0
  35. package/dist/SvListBox.svelte.d.ts +19 -0
  36. package/dist/SvMaskedInput.svelte +0 -0
  37. package/dist/SvMaskedInput.svelte.d.ts +15 -0
  38. package/dist/SvNumberInput.svelte +0 -0
  39. package/dist/SvNumberInput.svelte.d.ts +22 -0
  40. package/dist/SvPasswordInput.svelte +110 -0
  41. package/dist/SvPasswordInput.svelte.d.ts +22 -0
  42. package/dist/SvPhoneInput.svelte +0 -0
  43. package/dist/SvPhoneInput.svelte.d.ts +19 -0
  44. package/dist/SvRadioGroup.svelte +108 -0
  45. package/dist/SvRadioGroup.svelte.d.ts +23 -0
  46. package/dist/SvRating.svelte +111 -0
  47. package/dist/SvRating.svelte.d.ts +20 -0
  48. package/dist/SvRepeatButton.svelte +89 -0
  49. package/dist/SvRepeatButton.svelte.d.ts +21 -0
  50. package/dist/SvSlider.svelte +182 -0
  51. package/dist/SvSlider.svelte.d.ts +25 -0
  52. package/dist/SvSwitchButton.svelte +94 -0
  53. package/dist/SvSwitchButton.svelte.d.ts +19 -0
  54. package/dist/SvTabs.svelte +122 -0
  55. package/dist/SvTabs.svelte.d.ts +26 -0
  56. package/dist/SvTagsInput.svelte +81 -0
  57. package/dist/SvTagsInput.svelte.d.ts +19 -0
  58. package/dist/SvTimePicker.svelte +359 -0
  59. package/dist/SvTimePicker.svelte.d.ts +27 -0
  60. package/dist/SvToggleButton.svelte +57 -0
  61. package/dist/SvToggleButton.svelte.d.ts +16 -0
  62. package/dist/SvTree.svelte +210 -0
  63. package/dist/SvTree.svelte.d.ts +29 -0
  64. package/dist/build-api.js +78 -1
  65. package/dist/cdn/svgrid.js +10022 -5904
  66. package/dist/cdn/svgrid.svelte-external.js +8531 -4429
  67. package/dist/cell-render.d.ts +4 -0
  68. package/dist/cell-render.js +35 -1
  69. package/dist/clipboard.js +19 -1
  70. package/dist/collaboration.d.ts +2 -0
  71. package/dist/collaboration.js +4 -0
  72. package/dist/core.d.ts +22 -1
  73. package/dist/countries.d.ts +15 -0
  74. package/dist/countries.js +61 -0
  75. package/dist/datetime/date-core.d.ts +88 -0
  76. package/dist/datetime/date-core.js +165 -0
  77. package/dist/datetime/date-format.d.ts +39 -0
  78. package/dist/datetime/date-format.js +371 -0
  79. package/dist/datetime/date-restrict.d.ts +23 -0
  80. package/dist/datetime/date-restrict.js +50 -0
  81. package/dist/datetime/date-selection.d.ts +25 -0
  82. package/dist/datetime/date-selection.js +94 -0
  83. package/dist/datetime/mask.d.ts +21 -0
  84. package/dist/datetime/mask.js +90 -0
  85. package/dist/editing.d.ts +1 -1
  86. package/dist/editing.js +7 -0
  87. package/dist/editors/cell-editors.d.ts +2 -2
  88. package/dist/editors/cell-editors.js +3 -1
  89. package/dist/export-format.d.ts +178 -0
  90. package/dist/export-format.js +419 -0
  91. package/dist/hyperformula-adapter.d.ts +4 -0
  92. package/dist/hyperformula-adapter.js +15 -6
  93. package/dist/index.d.ts +36 -0
  94. package/dist/index.js +41 -0
  95. package/dist/list-option.d.ts +10 -0
  96. package/dist/list-option.js +7 -0
  97. package/dist/menus.js +18 -2
  98. package/dist/popover.d.ts +48 -0
  99. package/dist/popover.js +65 -0
  100. package/dist/row-resize.d.ts +4 -4
  101. package/dist/row-resize.js +8 -5
  102. package/dist/selection.d.ts +6 -0
  103. package/dist/selection.js +31 -5
  104. package/dist/server-data-source.d.ts +43 -0
  105. package/dist/server-data-source.js +100 -0
  106. package/dist/sparkline.js +8 -4
  107. package/dist/svgrid-wrapper.types.d.ts +26 -1
  108. package/package.json +6 -1
  109. package/src/GridFooter.svelte +34 -20
  110. package/src/SvAutoComplete.svelte +100 -0
  111. package/src/SvButton.svelte +97 -0
  112. package/src/SvCalendar.svelte +669 -0
  113. package/src/SvCalendar.test.ts +211 -0
  114. package/src/SvCheckBox.svelte +81 -0
  115. package/src/SvColorInput.svelte +187 -0
  116. package/src/SvComboBox.svelte +130 -0
  117. package/src/SvCountryInput.svelte +123 -0
  118. package/src/SvDateTimePicker.svelte +322 -0
  119. package/src/SvDateTimePicker.test.ts +136 -0
  120. package/src/SvDropDownList.svelte +143 -0
  121. package/src/SvForm.svelte +147 -0
  122. package/src/SvGauge.svelte +99 -0
  123. package/src/SvGrid.controller.svelte.ts +74 -8
  124. package/src/SvGrid.css +84 -31
  125. package/src/SvGrid.helpers.ts +41 -12
  126. package/src/SvGrid.svelte +84 -5
  127. package/src/SvGrid.types.ts +33 -0
  128. package/src/SvGridDropdown.svelte +16 -30
  129. package/src/SvListBox.svelte +122 -0
  130. package/src/SvMaskedInput.svelte +0 -0
  131. package/src/SvNumberInput.svelte +0 -0
  132. package/src/SvPasswordInput.svelte +110 -0
  133. package/src/SvPhoneInput.svelte +0 -0
  134. package/src/SvRadioGroup.svelte +108 -0
  135. package/src/SvRating.svelte +111 -0
  136. package/src/SvRepeatButton.svelte +89 -0
  137. package/src/SvSlider.svelte +182 -0
  138. package/src/SvSwitchButton.svelte +94 -0
  139. package/src/SvTabs.svelte +122 -0
  140. package/src/SvTagsInput.svelte +81 -0
  141. package/src/SvTimePicker.svelte +359 -0
  142. package/src/SvTimePicker.test.ts +98 -0
  143. package/src/SvToggleButton.svelte +57 -0
  144. package/src/SvTree.svelte +210 -0
  145. package/src/build-api.ts +115 -0
  146. package/src/cell-render.test.ts +38 -0
  147. package/src/cell-render.ts +43 -0
  148. package/src/clipboard.test.ts +18 -0
  149. package/src/clipboard.ts +24 -1
  150. package/src/collaboration.test.ts +30 -0
  151. package/src/collaboration.ts +6 -0
  152. package/src/core.ts +27 -3
  153. package/src/countries.ts +71 -0
  154. package/src/datetime/date-core.test.ts +217 -0
  155. package/src/datetime/date-core.ts +204 -0
  156. package/src/datetime/date-format.test.ts +121 -0
  157. package/src/datetime/date-format.ts +317 -0
  158. package/src/datetime/date-restrict.ts +60 -0
  159. package/src/datetime/date-selection.test.ts +129 -0
  160. package/src/datetime/date-selection.ts +137 -0
  161. package/src/datetime/mask.test.ts +36 -0
  162. package/src/datetime/mask.ts +84 -0
  163. package/src/editing.test.ts +22 -0
  164. package/src/editing.ts +7 -1
  165. package/src/editors/cell-editors.ts +7 -1
  166. package/src/export-data-api.test.ts +126 -0
  167. package/src/export-format.test.ts +107 -0
  168. package/src/export-format.ts +598 -0
  169. package/src/hyperformula-adapter.test.ts +35 -1
  170. package/src/hyperformula-adapter.ts +18 -6
  171. package/src/index.ts +71 -0
  172. package/src/list-option.ts +15 -0
  173. package/src/menus.ts +21 -2
  174. package/src/popover.ts +79 -0
  175. package/src/row-resize.test.ts +25 -0
  176. package/src/row-resize.ts +8 -5
  177. package/src/selection.test.ts +36 -4
  178. package/src/selection.ts +30 -9
  179. package/src/server-data-source.test.ts +201 -0
  180. package/src/server-data-source.ts +148 -0
  181. package/src/sparkline.test.ts +9 -0
  182. package/src/sparkline.ts +9 -4
  183. package/src/svgrid-wrapper.types.ts +28 -1
  184. package/src/svgrid.behavior.test.ts +27 -0
  185. package/src/svgrid.new-features.wrapper.test.ts +27 -1
  186. package/src/ui-buttons.test.ts +105 -0
  187. package/src/ui-composite.test.ts +89 -0
  188. package/src/ui-inputs.test.ts +92 -0
  189. package/src/ui-range.test.ts +61 -0
  190. package/src/ui-selection.test.ts +106 -0
@@ -0,0 +1,123 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvCountryInput - a searchable country picker (flag + name + dial code).
4
+ * Emits the ISO 3166-1 alpha-2 code. Parity: Smart country-input pattern.
5
+ * Data from countries.ts; popover via popover.ts.
6
+ */
7
+ import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
8
+ import { COUNTRIES, COUNTRY_BY_CODE, flagEmoji } from './countries'
9
+
10
+ type Props = {
11
+ value?: string | null
12
+ onChange?: (code: string) => void
13
+ /** Show the dial code beside each country. */
14
+ showDial?: boolean
15
+ placeholder?: string
16
+ disabled?: boolean
17
+ name?: string
18
+ size?: 'sm' | 'md' | 'lg'
19
+ ariaLabel?: string
20
+ }
21
+
22
+ let { value = null, onChange, showDial = false, placeholder = 'Select country…', disabled = false, name, size = 'md', ariaLabel }: Props = $props()
23
+
24
+ let open = $state(false)
25
+ let query = $state('')
26
+ let active = $state(0)
27
+ let triggerEl = $state<HTMLButtonElement | null>(null)
28
+ let searchEl = $state<HTMLInputElement | 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 selected = $derived(value ? COUNTRY_BY_CODE.get(value) ?? null : null)
33
+ const filtered = $derived(
34
+ query.trim() === ''
35
+ ? COUNTRIES
36
+ : COUNTRIES.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()) || c.dial.includes(query) || c.code.toLowerCase() === query.toLowerCase()),
37
+ )
38
+
39
+ function updatePos() { if (triggerEl) rect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: 320, minWidth: 240 }) }
40
+ function openPanel() { if (disabled || open) return; open = true; query = ''; active = 0; updatePos(); queueMicrotask(() => searchEl?.focus()) }
41
+ function close() { open = false; triggerEl?.focus() }
42
+ function pick(code: string) { onChange?.(code); open = false; triggerEl?.focus() }
43
+ function onKeydown(e: KeyboardEvent) {
44
+ if (!open) return
45
+ if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) }
46
+ else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
47
+ else if (e.key === 'Enter') { e.preventDefault(); const c = filtered[active]; if (c) pick(c.code) }
48
+ else if (e.key === 'Escape') { e.preventDefault(); close() }
49
+ queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${active}"]`)?.scrollIntoView({ block: 'nearest' }))
50
+ }
51
+
52
+ $effect(() => {
53
+ if (!open) return
54
+ const rp = () => updatePos()
55
+ window.addEventListener('scroll', rp, true); window.addEventListener('resize', rp)
56
+ const od = (e: PointerEvent) => { const t = e.target as Node | null; if (t && (triggerEl?.contains(t) || panelEl?.contains(t))) return; open = false }
57
+ document.addEventListener('pointerdown', od, true)
58
+ return () => { window.removeEventListener('scroll', rp, true); window.removeEventListener('resize', rp); document.removeEventListener('pointerdown', od, true) }
59
+ })
60
+ </script>
61
+
62
+ <button bind:this={triggerEl} type="button" class="sv-country sv-country--{size}" class:is-open={open} class:is-disabled={disabled} aria-haspopup="listbox" aria-expanded={open} aria-label={ariaLabel} {disabled} onclick={() => (open ? (open = false) : openPanel())}>
63
+ {#if selected}
64
+ <span class="sv-country__flag" aria-hidden="true">{flagEmoji(selected.code)}</span>
65
+ <span class="sv-country__name">{selected.name}</span>
66
+ {#if showDial}<span class="sv-country__dial">{selected.dial}</span>{/if}
67
+ {:else}
68
+ <span class="sv-country__ph">{placeholder}</span>
69
+ {/if}
70
+ <svg class="sv-country__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>
71
+ </button>
72
+
73
+ {#if open}
74
+ <div bind:this={panelEl} class="sv-country__panel" use:portalToBody style:position="fixed" style:top={`${rect.top}px`} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} role="dialog">
75
+ <input bind:this={searchEl} class="sv-country__search" type="text" bind:value={query} placeholder="Search countries…" aria-label="Search countries" onkeydown={onKeydown} oninput={() => { active = 0; updatePos() }} />
76
+ <div class="sv-country__list" role="listbox">
77
+ {#each filtered as c, i (c.code)}
78
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
79
+ <div class="sv-country__opt" class:is-active={i === active} class:is-selected={c.code === value} role="option" tabindex="-1" aria-selected={c.code === value} data-idx={i} onclick={() => pick(c.code)} onpointermove={() => (active = i)}>
80
+ <span class="sv-country__flag" aria-hidden="true">{flagEmoji(c.code)}</span>
81
+ <span class="sv-country__name">{c.name}</span>
82
+ <span class="sv-country__dial">{c.dial}</span>
83
+ </div>
84
+ {:else}
85
+ <div class="sv-ddl__empty">No matches</div>
86
+ {/each}
87
+ </div>
88
+ </div>
89
+ {/if}
90
+ {#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
91
+
92
+ <style>
93
+ .sv-country {
94
+ --_accent: var(--sg-accent, #2563eb);
95
+ display: inline-flex; align-items: center; gap: 8px; width: 220px;
96
+ background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; text-align: left;
97
+ border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px); padding: 0 10px; cursor: pointer;
98
+ }
99
+ .sv-country--sm { height: 28px; font-size: 12px; }
100
+ .sv-country--md { height: 34px; font-size: 13px; }
101
+ .sv-country--lg { height: 40px; font-size: 15px; }
102
+ .sv-country.is-open, .sv-country:focus-visible { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); outline: none; }
103
+ .sv-country.is-disabled { opacity: 0.6; cursor: not-allowed; }
104
+ .sv-country__flag { font-size: 16px; }
105
+ .sv-country__name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
106
+ .sv-country__ph { flex: 1; color: var(--sg-muted, #94a3b8); }
107
+ .sv-country__dial { color: var(--sg-muted, #64748b); font-size: 12px; }
108
+ .sv-country__chev { color: var(--sg-muted, #64748b); flex: none; }
109
+
110
+ :global(.sv-country__panel) {
111
+ z-index: 2147483647; display: flex; flex-direction: column; max-height: 340px; padding: 6px;
112
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
113
+ border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px; box-shadow: 0 16px 48px -12px rgba(15,23,42,0.35);
114
+ }
115
+ :global(.sv-country__search) {
116
+ height: 32px; margin-bottom: 4px; padding: 0 10px; font: inherit; font-size: 13px;
117
+ border: 1px solid var(--sg-input-border, #cbd5e1); border-radius: 7px; background: var(--sg-input-bg, #fff); color: inherit; outline: none;
118
+ }
119
+ :global(.sv-country__list) { overflow-y: auto; }
120
+ :global(.sv-country__opt) { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: 6px; cursor: pointer; font-size: 13px; }
121
+ :global(.sv-country__opt.is-active) { background: var(--sg-row-hover-bg, #f1f5f9); }
122
+ :global(.sv-country__opt.is-selected) { color: var(--sg-accent, #2563eb); font-weight: 600; }
123
+ </style>
@@ -0,0 +1,322 @@
1
+ <script lang="ts" module>
2
+ export type DateTimeValue = Date | string | number | null
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`. Works standalone and as
10
+ * the SvGrid `datetime` cell editor. The dropdown portals to <body> (via
11
+ * popover.ts) so it is never clipped by the grid's scroll container.
12
+ */
13
+ import SvCalendar from './SvCalendar.svelte'
14
+ import SvTimePicker from './SvTimePicker.svelte'
15
+ import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
16
+ import { formatDate, parseDate } from './datetime/date-format'
17
+ import { toDate, clampDate, withTime, startOfDay, type DateLike } from './datetime/date-core'
18
+
19
+ type Props = {
20
+ value?: DateTimeValue
21
+ onChange?: (value: Date | null) => void
22
+ /** Fired when the value is finalized (Enter, blur, or a single-date pick).
23
+ * Used by the grid to save the cell. */
24
+ onCommit?: (value: Date | null) => void
25
+ /** Fired on Escape / dismiss without committing (grid cancels the edit). */
26
+ onCancel?: () => void
27
+ /** Display / parse mask (token engine). */
28
+ formatString?: string
29
+ min?: DateLike | null
30
+ max?: DateLike | null
31
+ nullable?: boolean
32
+ placeholder?: string
33
+ disabled?: boolean
34
+ readonly?: boolean
35
+ name?: string
36
+ locale?: string
37
+ firstDayOfWeek?: number
38
+ weekNumbers?: boolean
39
+ hourFormat?: '12-hour' | '24-hour'
40
+ minuteInterval?: number
41
+ /** Which tabs the dropdown shows. */
42
+ dropDownDisplayMode?: 'both' | 'calendar' | 'time'
43
+ /** Up/down spinner buttons that bump the value by `stepMinutes`. */
44
+ spinButtons?: boolean
45
+ stepMinutes?: number
46
+ /** Open the dropdown as soon as the field is focused (grid in-cell editing). */
47
+ autoOpen?: boolean
48
+ /** Animate the calendar's month/drill navigation (honors reduced-motion). */
49
+ animate?: boolean | 'slide' | 'fade'
50
+ }
51
+
52
+ let {
53
+ value = null,
54
+ onChange,
55
+ onCommit,
56
+ onCancel,
57
+ formatString = 'yyyy-MM-dd HH:mm',
58
+ min = null,
59
+ max = null,
60
+ nullable = true,
61
+ placeholder = 'Select date & time',
62
+ disabled = false,
63
+ readonly = false,
64
+ name,
65
+ locale = typeof navigator !== 'undefined' ? navigator.language : 'en-US',
66
+ firstDayOfWeek = 0,
67
+ weekNumbers = false,
68
+ hourFormat = '24-hour',
69
+ minuteInterval = 1,
70
+ dropDownDisplayMode = 'both',
71
+ spinButtons = false,
72
+ stepMinutes = 1,
73
+ autoOpen = false,
74
+ animate = false,
75
+ }: Props = $props()
76
+
77
+ const isInteractive = $derived(!disabled && !readonly)
78
+ const minD = $derived(toDate(min ?? null))
79
+ const maxD = $derived(toDate(max ?? null))
80
+
81
+ let current = $state<Date | null>(null)
82
+ let text = $state('')
83
+ let lastKey = '￿'
84
+ $effect(() => {
85
+ const d = toDate(value as DateLike)
86
+ const key = d ? String(d.getTime()) : 'null'
87
+ if (key !== lastKey) {
88
+ lastKey = key
89
+ current = d
90
+ text = d ? formatDate(d, formatString, locale) : ''
91
+ }
92
+ })
93
+
94
+ // --- Commit helpers ---
95
+ function commit(next: Date | null) {
96
+ const clamped = next ? clampDate(next, minD, maxD) : null
97
+ current = clamped
98
+ lastKey = clamped ? String(clamped.getTime()) : 'null'
99
+ text = clamped ? formatDate(clamped, formatString, locale) : ''
100
+ onChange?.(clamped)
101
+ }
102
+
103
+ function commitFromText() {
104
+ const raw = text.trim()
105
+ if (!raw) {
106
+ if (nullable) commit(null)
107
+ else text = current ? formatDate(current, formatString, locale) : ''
108
+ return
109
+ }
110
+ const parsed = parseDate(raw, formatString, locale, 2029, current ?? new Date())
111
+ if (parsed) commit(parsed)
112
+ else text = current ? formatDate(current, formatString, locale) : '' // revert on bad input
113
+ }
114
+
115
+ function bump(deltaMin: number) {
116
+ const base = current ?? new Date()
117
+ commit(new Date(base.getTime() + deltaMin * 60000))
118
+ }
119
+
120
+ // --- Sub-picker wiring ---
121
+ function onCalendarChange(dates: Date[]) {
122
+ const day = dates[0]
123
+ if (!day) return
124
+ commit(withTime(day, current ?? startOfDay(day)))
125
+ // Date-only picker: a single pick finalizes the value.
126
+ if (dropDownDisplayMode === 'calendar') { open = false; onCommit?.(current) }
127
+ else if (dropDownDisplayMode === 'both') tab = 'time'
128
+ }
129
+ function onTimeChange(t: Date) {
130
+ const base = current ?? new Date()
131
+ commit(withTime(base, t))
132
+ }
133
+
134
+ // --- Dropdown (portalled) ---
135
+ let open = $state(false)
136
+ let tab = $state<'date' | 'time'>('date')
137
+ let triggerEl = $state<HTMLDivElement | null>(null)
138
+ let panelEl = $state<HTMLDivElement | null>(null)
139
+ let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
140
+
141
+ function updatePos() {
142
+ if (!triggerEl) return
143
+ panelRect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: 360, minWidth: 300 })
144
+ }
145
+ function openPanel() {
146
+ if (!isInteractive || open) return
147
+ tab = dropDownDisplayMode === 'time' ? 'time' : 'date'
148
+ open = true
149
+ updatePos()
150
+ }
151
+ function closePanel() { open = false }
152
+ function toggle() { (open ? closePanel : openPanel)() }
153
+
154
+ $effect(() => {
155
+ if (!open) return
156
+ const reposition = () => updatePos()
157
+ window.addEventListener('scroll', reposition, true)
158
+ window.addEventListener('resize', reposition)
159
+ const onDown = (e: PointerEvent) => {
160
+ const t = e.target as Node | null
161
+ if (!t) return
162
+ if (triggerEl?.contains(t) || panelEl?.contains(t)) return
163
+ closePanel()
164
+ }
165
+ document.addEventListener('pointerdown', onDown, true)
166
+ return () => {
167
+ window.removeEventListener('scroll', reposition, true)
168
+ window.removeEventListener('resize', reposition)
169
+ document.removeEventListener('pointerdown', onDown, true)
170
+ }
171
+ })
172
+
173
+ function onInputKeydown(e: KeyboardEvent) {
174
+ if (e.key === 'Enter') { e.preventDefault(); commitFromText(); closePanel(); onCommit?.(current) }
175
+ else if (e.key === 'Escape') { closePanel(); onCancel?.() }
176
+ else if (e.key === 'ArrowDown' && (e.altKey || !text)) { e.preventDefault(); openPanel() }
177
+ else if (spinButtons && e.key === 'ArrowUp') { e.preventDefault(); bump(stepMinutes) }
178
+ else if (spinButtons && e.key === 'ArrowDown') { e.preventDefault(); bump(-stepMinutes) }
179
+ }
180
+
181
+ function focusOpen(node: HTMLInputElement) {
182
+ if (!autoOpen) return
183
+ node.focus()
184
+ openPanel()
185
+ }
186
+
187
+ function onRootFocusOut(e: FocusEvent) {
188
+ const next = e.relatedTarget as Node | null
189
+ // Ignore focus moving within the field or into the portalled panel.
190
+ if (next && (triggerEl?.contains(next) || panelEl?.contains(next))) return
191
+ onCommit?.(current)
192
+ }
193
+
194
+ const showDateTab = $derived(dropDownDisplayMode !== 'time')
195
+ const showTimeTab = $derived(dropDownDisplayMode !== 'calendar')
196
+ </script>
197
+
198
+ <div class="sv-dtp" class:sv-dtp--disabled={disabled} onfocusout={onRootFocusOut}>
199
+ <div class="sv-dtp__field" bind:this={triggerEl}>
200
+ {#if spinButtons}
201
+ <div class="sv-dtp__spin">
202
+ <button type="button" tabindex="-1" aria-label="Increment" onclick={() => bump(stepMinutes)} disabled={!isInteractive}>▲</button>
203
+ <button type="button" tabindex="-1" aria-label="Decrement" onclick={() => bump(-stepMinutes)} disabled={!isInteractive}>▼</button>
204
+ </div>
205
+ {/if}
206
+ <input
207
+ class="sv-dtp__input"
208
+ type="text"
209
+ bind:value={text}
210
+ {placeholder}
211
+ {disabled}
212
+ {readonly}
213
+ onblur={commitFromText}
214
+ onkeydown={onInputKeydown}
215
+ use:focusOpen
216
+ />
217
+ {#if nullable && current && isInteractive}
218
+ <button type="button" class="sv-dtp__clear" aria-label="Clear" onclick={() => commit(null)}>&times;</button>
219
+ {/if}
220
+ <button type="button" class="sv-dtp__toggle" aria-label="Open picker" aria-haspopup="dialog" aria-expanded={open} onclick={toggle} disabled={!isInteractive} tabindex="-1">
221
+ <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>
222
+ </button>
223
+ </div>
224
+
225
+ {#if open}
226
+ <div
227
+ class="sv-dtp__panel"
228
+ bind:this={panelEl}
229
+ use:portalToBody
230
+ style:position="fixed"
231
+ style:top={`${panelRect.top}px`}
232
+ style:left={`${panelRect.left}px`}
233
+ role="dialog"
234
+ aria-label="Choose date and time"
235
+ >
236
+ {#if showDateTab && showTimeTab}
237
+ <div class="sv-dtp__tabs" role="tablist">
238
+ <button type="button" role="tab" aria-selected={tab === 'date'} class:is-active={tab === 'date'} onclick={() => (tab = 'date')}>DATE</button>
239
+ <button type="button" role="tab" aria-selected={tab === 'time'} class:is-active={tab === 'time'} onclick={() => (tab = 'time')}>TIME</button>
240
+ </div>
241
+ {/if}
242
+ <div class="sv-dtp__body">
243
+ {#if tab === 'date' && showDateTab}
244
+ <SvCalendar
245
+ value={current}
246
+ selectionMode="one"
247
+ min={min}
248
+ max={max}
249
+ {firstDayOfWeek}
250
+ {weekNumbers}
251
+ {locale}
252
+ {animate}
253
+ onChange={onCalendarChange}
254
+ />
255
+ {:else if showTimeTab}
256
+ <SvTimePicker
257
+ value={current}
258
+ format={hourFormat}
259
+ {minuteInterval}
260
+ footer
261
+ onChange={onTimeChange}
262
+ />
263
+ {/if}
264
+ </div>
265
+ </div>
266
+ {/if}
267
+
268
+ {#if name}<input type="hidden" {name} value={current ? current.toISOString() : ''} />{/if}
269
+ </div>
270
+
271
+ <style>
272
+ .sv-dtp {
273
+ --_accent: var(--sg-accent, #2563eb);
274
+ --_bg: var(--sg-input-bg, var(--sg-bg, #fff));
275
+ --_fg: var(--sg-fg, #0f172a);
276
+ --_muted: var(--sg-muted, #64748b);
277
+ --_border: var(--sg-input-border, var(--sg-border, #cbd5e1));
278
+ --_radius: var(--sg-radius, 8px);
279
+ display: inline-block; position: relative; width: 240px; color: var(--_fg);
280
+ }
281
+ .sv-dtp--disabled { opacity: 0.6; }
282
+ .sv-dtp__field {
283
+ display: flex; align-items: center; gap: 2px;
284
+ background: var(--_bg); border: 1px solid var(--_border); border-radius: var(--_radius);
285
+ padding: 0 4px 0 0; height: 34px;
286
+ }
287
+ .sv-dtp__field:focus-within { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
288
+ .sv-dtp__input {
289
+ flex: 1; min-width: 0; border: 0; background: none; outline: none; color: inherit;
290
+ font: inherit; font-size: 13px; padding: 0 8px; height: 100%;
291
+ }
292
+ .sv-dtp__spin { display: flex; flex-direction: column; border-right: 1px solid var(--_border); }
293
+ .sv-dtp__spin button {
294
+ flex: 1; width: 20px; border: 0; background: none; color: var(--_muted); cursor: pointer;
295
+ font-size: 7px; line-height: 1; padding: 0;
296
+ }
297
+ .sv-dtp__spin button:hover:not(:disabled) { color: var(--_accent); }
298
+ .sv-dtp__clear, .sv-dtp__toggle {
299
+ display: grid; place-items: center; width: 26px; height: 26px; flex: none;
300
+ background: none; border: 0; color: var(--_muted); cursor: pointer; border-radius: 6px;
301
+ }
302
+ .sv-dtp__clear { font-size: 17px; line-height: 1; }
303
+ .sv-dtp__clear:hover, .sv-dtp__toggle:hover { color: var(--_accent); background: color-mix(in srgb, var(--_accent) 10%, transparent); }
304
+
305
+ /* Portalled panel - lives under <body>, styled globally. */
306
+ :global(.sv-dtp__panel) {
307
+ z-index: 2147483647;
308
+ background: var(--sg-bg, #fff);
309
+ border: 1px solid var(--sg-border, #e2e8f0);
310
+ border-radius: 12px;
311
+ box-shadow: 0 16px 48px -12px rgba(15, 23, 42, 0.35);
312
+ overflow: hidden;
313
+ }
314
+ :global(.sv-dtp__tabs) { display: flex; border-bottom: 1px solid var(--sg-border, #e2e8f0); }
315
+ :global(.sv-dtp__tabs button) {
316
+ flex: 1; padding: 9px; font: inherit; font-size: 11px; font-weight: 700; letter-spacing: 0.04em;
317
+ background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer;
318
+ border-bottom: 2px solid transparent;
319
+ }
320
+ :global(.sv-dtp__tabs button.is-active) { color: var(--sg-accent, #2563eb); border-bottom-color: var(--sg-accent, #2563eb); }
321
+ :global(.sv-dtp__body) { display: grid; place-items: center; padding: 8px; }
322
+ </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
+ })