@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,211 @@
1
+ /**
2
+ * Component tests for SvCalendar. Mounts in jsdom and drives real DOM clicks /
3
+ * keydowns, asserting the emitted selection + navigation. Discriminating: each
4
+ * checks the actual selected value(s), not just that something rendered.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { mount, unmount, flushSync } from 'svelte'
8
+ import SvCalendar from './SvCalendar.svelte'
9
+
10
+ type Mounted = { target: HTMLElement; destroy: () => void }
11
+
12
+ function mountCal(props: Record<string, unknown>): Mounted {
13
+ const target = document.createElement('div')
14
+ document.body.appendChild(target)
15
+ const app = mount(SvCalendar, { target, props: props as any })
16
+ flushSync()
17
+ return { target, destroy: () => { unmount(app); target.remove() } }
18
+ }
19
+
20
+ const dayButton = (target: HTMLElement, label: string) =>
21
+ Array.from(target.querySelectorAll<HTMLButtonElement>('.sv-cal__day')).find(
22
+ (b) => b.textContent?.trim() === label && !b.classList.contains('is-outside'),
23
+ )
24
+
25
+ describe('SvCalendar render', () => {
26
+ it('renders the seeded month title and a 6x7 grid', () => {
27
+ const { target, destroy } = mountCal({ value: new Date(2026, 5, 15) })
28
+ try {
29
+ expect(target.querySelector('.sv-cal__title')?.textContent).toContain('2026')
30
+ const days = target.querySelectorAll('.sv-cal__day')
31
+ expect(days.length).toBe(42) // 6 weeks x 7
32
+ } finally {
33
+ destroy()
34
+ }
35
+ })
36
+
37
+ it('marks the selected day', () => {
38
+ const { target, destroy } = mountCal({ value: new Date(2026, 5, 15) })
39
+ try {
40
+ const sel = target.querySelector('.sv-cal__day.is-selected')
41
+ expect(sel?.textContent?.trim()).toBe('15')
42
+ } finally {
43
+ destroy()
44
+ }
45
+ })
46
+
47
+ it('shows week numbers when enabled', () => {
48
+ const { target, destroy } = mountCal({ value: new Date(2026, 5, 15), weekNumbers: true, firstDayOfWeek: 1 })
49
+ try {
50
+ expect(target.querySelectorAll('.sv-cal__wk').length).toBe(6)
51
+ } finally {
52
+ destroy()
53
+ }
54
+ })
55
+ })
56
+
57
+ describe('SvCalendar selection', () => {
58
+ it('emits the clicked day (mode one)', () => {
59
+ let got: Date[] = []
60
+ const { target, destroy } = mountCal({ value: new Date(2026, 5, 15), onChange: (d: Date[]) => (got = d) })
61
+ try {
62
+ dayButton(target, '20')!.click()
63
+ flushSync()
64
+ expect(got).toHaveLength(1)
65
+ expect(got[0]!.getDate()).toBe(20)
66
+ expect(got[0]!.getMonth()).toBe(5)
67
+ } finally {
68
+ destroy()
69
+ }
70
+ })
71
+
72
+ it('range mode: two clicks emit the inclusive span', () => {
73
+ let got: Date[] = []
74
+ const { target, destroy } = mountCal({
75
+ value: null,
76
+ selectionMode: 'range',
77
+ displayMode: 'month',
78
+ onChange: (d: Date[]) => (got = d),
79
+ })
80
+ try {
81
+ // Seed view to a known month by selecting from it.
82
+ const cal = mountCal({ value: new Date(2026, 5, 1), selectionMode: 'range', onChange: (d: Date[]) => (got = d) })
83
+ dayButton(cal.target, '3')!.click()
84
+ flushSync()
85
+ dayButton(cal.target, '6')!.click()
86
+ flushSync()
87
+ expect(got.map((d) => d.getDate())).toEqual([3, 4, 5, 6])
88
+ cal.destroy()
89
+ } finally {
90
+ destroy()
91
+ }
92
+ })
93
+
94
+ it('does not select a disabled (out-of-range) day', () => {
95
+ let calls = 0
96
+ const { target, destroy } = mountCal({
97
+ value: new Date(2026, 5, 15),
98
+ min: new Date(2026, 5, 10),
99
+ onChange: () => (calls += 1),
100
+ })
101
+ try {
102
+ const five = dayButton(target, '5')
103
+ expect(five?.disabled).toBe(true)
104
+ five?.click()
105
+ flushSync()
106
+ expect(calls).toBe(0)
107
+ } finally {
108
+ destroy()
109
+ }
110
+ })
111
+ })
112
+
113
+ describe('SvCalendar navigation', () => {
114
+ it('prev/next steps the month', () => {
115
+ const { target, destroy } = mountCal({ value: new Date(2026, 5, 15) })
116
+ try {
117
+ const [prev, , next] = target.querySelectorAll<HTMLButtonElement>('.sv-cal__nav, .sv-cal__title')
118
+ // next button is the 2nd .sv-cal__nav; grab by aria-label
119
+ target.querySelector<HTMLButtonElement>('[aria-label="Next"]')!.click()
120
+ flushSync()
121
+ expect(target.querySelector('.sv-cal__title')?.textContent?.toLowerCase()).toContain('july')
122
+ target.querySelector<HTMLButtonElement>('[aria-label="Previous"]')!.click()
123
+ target.querySelector<HTMLButtonElement>('[aria-label="Previous"]')!.click()
124
+ flushSync()
125
+ expect(target.querySelector('.sv-cal__title')?.textContent?.toLowerCase()).toContain('may')
126
+ void prev; void next
127
+ } finally {
128
+ destroy()
129
+ }
130
+ })
131
+
132
+ it('title drills month -> year -> decade', () => {
133
+ const { target, destroy } = mountCal({ value: new Date(2026, 5, 15) })
134
+ try {
135
+ const title = () => target.querySelector<HTMLButtonElement>('.sv-cal__title')!
136
+ title().click() // -> year
137
+ flushSync()
138
+ expect(target.querySelectorAll('.sv-cal__cell').length).toBe(12) // 12 months
139
+ title().click() // -> decade
140
+ flushSync()
141
+ expect(title().textContent).toContain('2020 - 2029')
142
+ } finally {
143
+ destroy()
144
+ }
145
+ })
146
+
147
+ it('year mode: clicking a month drills back to that month', () => {
148
+ const { target, destroy } = mountCal({ value: new Date(2026, 5, 15) })
149
+ try {
150
+ target.querySelector<HTMLButtonElement>('.sv-cal__title')!.click() // year mode
151
+ flushSync()
152
+ const jan = Array.from(target.querySelectorAll<HTMLButtonElement>('.sv-cal__cell'))[0]!
153
+ jan.click()
154
+ flushSync()
155
+ expect(target.querySelector('.sv-cal__title')?.textContent?.toLowerCase()).toContain('january')
156
+ } finally {
157
+ destroy()
158
+ }
159
+ })
160
+
161
+ it('steps the month with animation enabled (animation never blocks navigation)', () => {
162
+ const { target, destroy } = mountCal({ value: new Date(2026, 5, 15), animate: 'slide' })
163
+ try {
164
+ target.querySelector<HTMLButtonElement>('[aria-label="Next"]')!.click()
165
+ flushSync()
166
+ expect(target.querySelector('.sv-cal__title')?.textContent?.toLowerCase()).toContain('july')
167
+ } finally {
168
+ destroy()
169
+ }
170
+ })
171
+ })
172
+
173
+ describe('SvCalendar presets + tooltips', () => {
174
+ it('renders preset buttons and applies a range preset', () => {
175
+ let got: Date[] = []
176
+ const { target, destroy } = mountCal({
177
+ value: null,
178
+ selectionMode: 'range',
179
+ presets: [
180
+ { label: 'First five', value: [new Date(2026, 5, 1), new Date(2026, 5, 5)] },
181
+ { label: 'Just the 10th', value: new Date(2026, 5, 10) },
182
+ ],
183
+ onChange: (d: Date[]) => (got = d),
184
+ })
185
+ try {
186
+ const buttons = Array.from(target.querySelectorAll<HTMLButtonElement>('.sv-cal__preset'))
187
+ expect(buttons.map((b) => b.textContent?.trim())).toEqual(['First five', 'Just the 10th'])
188
+ buttons[0]!.click()
189
+ flushSync()
190
+ expect(got.map((d) => d.getDate())).toEqual([1, 2, 3, 4, 5])
191
+ buttons[1]!.click()
192
+ flushSync()
193
+ expect(got.map((d) => d.getDate())).toEqual([10])
194
+ } finally {
195
+ destroy()
196
+ }
197
+ })
198
+
199
+ it('sets a native title tooltip only on matching days', () => {
200
+ const { target, destroy } = mountCal({
201
+ value: new Date(2026, 5, 15),
202
+ dateTooltip: (d: Date) => (d.getDate() === 20 && d.getMonth() === 5 ? 'Release day' : undefined),
203
+ })
204
+ try {
205
+ expect(dayButton(target, '20')?.title).toBe('Release day')
206
+ expect(dayButton(target, '10')?.title).toBe('')
207
+ } finally {
208
+ destroy()
209
+ }
210
+ })
211
+ })
@@ -0,0 +1,81 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvCheckBox - a themed checkbox with an indeterminate state and optional
4
+ * label. Parity: Smart `smart-check-box`. Controlled via `checked` + `onChange`.
5
+ */
6
+ import type { Snippet } from 'svelte'
7
+
8
+ type Props = {
9
+ checked?: boolean
10
+ indeterminate?: boolean
11
+ onChange?: (checked: boolean) => void
12
+ disabled?: boolean
13
+ size?: 'sm' | 'md' | 'lg'
14
+ name?: string
15
+ value?: string
16
+ ariaLabel?: string
17
+ children?: Snippet
18
+ }
19
+
20
+ let {
21
+ checked = false,
22
+ indeterminate = false,
23
+ onChange,
24
+ disabled = false,
25
+ size = 'md',
26
+ name,
27
+ value,
28
+ ariaLabel,
29
+ children,
30
+ }: Props = $props()
31
+
32
+ function toggle() {
33
+ if (disabled) return
34
+ onChange?.(indeterminate ? true : !checked)
35
+ }
36
+ </script>
37
+
38
+ <label class="sv-check sv-check--{size}" class:is-disabled={disabled}>
39
+ <button
40
+ type="button"
41
+ role="checkbox"
42
+ class="sv-check__box"
43
+ class:is-checked={checked && !indeterminate}
44
+ class:is-indeterminate={indeterminate}
45
+ aria-checked={indeterminate ? 'mixed' : checked}
46
+ aria-label={ariaLabel ?? (children ? undefined : 'checkbox')}
47
+ {disabled}
48
+ onclick={toggle}
49
+ >
50
+ {#if indeterminate}
51
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 8h8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" /></svg>
52
+ {:else if checked}
53
+ <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3.5 8.5l3 3 6-6.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" /></svg>
54
+ {/if}
55
+ </button>
56
+ {#if children}<span class="sv-check__label">{@render children()}</span>{/if}
57
+ {#if name}<input type="hidden" {name} value={checked ? (value ?? 'true') : ''} />{/if}
58
+ </label>
59
+
60
+ <style>
61
+ .sv-check {
62
+ --_accent: var(--sg-accent, #2563eb);
63
+ --_sz: 18px;
64
+ display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none;
65
+ font: inherit; font-size: 13px; color: var(--sg-fg, #0f172a);
66
+ }
67
+ .sv-check--sm { --_sz: 15px; font-size: 12px; }
68
+ .sv-check--lg { --_sz: 22px; font-size: 15px; }
69
+ .sv-check.is-disabled { opacity: 0.55; cursor: not-allowed; }
70
+ .sv-check__box {
71
+ width: var(--_sz); height: var(--_sz); flex: none; padding: 0; cursor: inherit;
72
+ display: grid; place-items: center;
73
+ background: var(--sg-input-bg, #fff); color: var(--sg-on-accent, #fff);
74
+ border: 1.5px solid var(--sg-border, #cbd5e1); border-radius: 5px; transition: background 0.12s, border-color 0.12s;
75
+ }
76
+ .sv-check__box svg { width: 100%; height: 100%; }
77
+ .sv-check__box.is-checked, .sv-check__box.is-indeterminate {
78
+ background: var(--_accent); border-color: var(--_accent);
79
+ }
80
+ .sv-check__box:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--_accent)); outline-offset: 2px; }
81
+ </style>
@@ -0,0 +1,187 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvColorInput - a color swatch that opens a portalled popover with a hex
4
+ * field, a native picker, and a preset palette. Parity: Smart `smart-color-input`.
5
+ * Emits a hex string. Popover escapes the grid scroll container via popover.ts.
6
+ */
7
+ import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
8
+
9
+ type Props = {
10
+ value?: string
11
+ onChange?: (hex: string) => void
12
+ disabled?: boolean
13
+ readonly?: boolean
14
+ /** Preset swatches. */
15
+ palette?: string[]
16
+ name?: string
17
+ size?: 'sm' | 'md' | 'lg'
18
+ ariaLabel?: string
19
+ autoOpen?: boolean
20
+ }
21
+
22
+ const DEFAULT_PALETTE = [
23
+ '#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e', '#10b981', '#14b8a6',
24
+ '#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899',
25
+ '#f43f5e', '#64748b', '#0f172a', '#ffffff',
26
+ ]
27
+
28
+ let {
29
+ value = '#3b82f6',
30
+ onChange,
31
+ disabled = false,
32
+ readonly = false,
33
+ palette = DEFAULT_PALETTE,
34
+ name,
35
+ size = 'md',
36
+ ariaLabel,
37
+ autoOpen = false,
38
+ }: Props = $props()
39
+
40
+ const isInteractive = $derived(!disabled && !readonly)
41
+ let open = $state(false)
42
+ let hexDraft = $state('')
43
+ let triggerEl = $state<HTMLButtonElement | null>(null)
44
+ let panelEl = $state<HTMLDivElement | null>(null)
45
+ let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
46
+
47
+ const normalized = $derived(normalizeHex(value) ?? '#000000')
48
+
49
+ function normalizeHex(h: string): string | null {
50
+ let s = h.trim()
51
+ if (!s.startsWith('#')) s = '#' + s
52
+ if (/^#[0-9a-fA-F]{3}$/.test(s)) s = '#' + s.slice(1).split('').map((c) => c + c).join('')
53
+ return /^#[0-9a-fA-F]{6}$/.test(s) ? s.toLowerCase() : null
54
+ }
55
+
56
+ function updatePos() {
57
+ if (!triggerEl) return
58
+ panelRect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: 210, minWidth: 200 })
59
+ }
60
+ function openPanel() {
61
+ if (!isInteractive || open) return
62
+ hexDraft = normalized
63
+ open = true
64
+ updatePos()
65
+ }
66
+ function toggle() { open ? (open = false) : openPanel() }
67
+
68
+ function pick(hex: string) {
69
+ const n = normalizeHex(hex)
70
+ if (n) onChange?.(n)
71
+ }
72
+ function commitHex() {
73
+ const n = normalizeHex(hexDraft)
74
+ if (n) onChange?.(n)
75
+ else hexDraft = normalized
76
+ }
77
+
78
+ $effect(() => {
79
+ if (!open) return
80
+ const reposition = () => updatePos()
81
+ window.addEventListener('scroll', reposition, true)
82
+ window.addEventListener('resize', reposition)
83
+ const onDown = (e: PointerEvent) => {
84
+ const t = e.target as Node | null
85
+ if (t && (triggerEl?.contains(t) || panelEl?.contains(t))) return
86
+ open = false
87
+ }
88
+ document.addEventListener('pointerdown', onDown, true)
89
+ return () => {
90
+ window.removeEventListener('scroll', reposition, true)
91
+ window.removeEventListener('resize', reposition)
92
+ document.removeEventListener('pointerdown', onDown, true)
93
+ }
94
+ })
95
+
96
+ function focusOpen(node: HTMLButtonElement) {
97
+ if (!autoOpen) return
98
+ node.focus(); openPanel()
99
+ }
100
+ </script>
101
+
102
+ <button
103
+ type="button"
104
+ bind:this={triggerEl}
105
+ class="sv-color sv-color--{size}"
106
+ class:is-disabled={disabled}
107
+ aria-haspopup="dialog"
108
+ aria-expanded={open}
109
+ aria-label={ariaLabel ?? `Color ${normalized}`}
110
+ disabled={!isInteractive}
111
+ onclick={toggle}
112
+ use:focusOpen
113
+ >
114
+ <span class="sv-color__swatch" style:background={normalized}></span>
115
+ <span class="sv-color__hex">{normalized}</span>
116
+ </button>
117
+
118
+ {#if open}
119
+ <div
120
+ class="sv-color__panel"
121
+ bind:this={panelEl}
122
+ use:portalToBody
123
+ style:position="fixed"
124
+ style:top={`${panelRect.top}px`}
125
+ style:left={`${panelRect.left}px`}
126
+ role="dialog"
127
+ aria-label="Choose color"
128
+ >
129
+ <div class="sv-color__top">
130
+ <input class="sv-color__native" type="color" value={normalized} oninput={(e) => pick((e.currentTarget as HTMLInputElement).value)} aria-label="Color picker" />
131
+ <input
132
+ class="sv-color__field"
133
+ type="text"
134
+ bind:value={hexDraft}
135
+ spellcheck="false"
136
+ aria-label="Hex value"
137
+ onblur={commitHex}
138
+ onkeydown={(e) => { if (e.key === 'Enter') commitHex() }}
139
+ />
140
+ </div>
141
+ <div class="sv-color__palette">
142
+ {#each palette as c (c)}
143
+ <button
144
+ type="button"
145
+ class="sv-color__chip"
146
+ class:is-active={normalizeHex(c) === normalized}
147
+ style:background={c}
148
+ aria-label={c}
149
+ onclick={() => { pick(c); open = false }}
150
+ ></button>
151
+ {/each}
152
+ </div>
153
+ </div>
154
+ {/if}
155
+ {#if name}<input type="hidden" {name} value={normalized} />{/if}
156
+
157
+ <style>
158
+ .sv-color {
159
+ --_accent: var(--sg-accent, #2563eb);
160
+ display: inline-flex; align-items: center; gap: 8px;
161
+ background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
162
+ border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
163
+ padding: 0 10px; cursor: pointer; font: inherit;
164
+ }
165
+ .sv-color--sm { height: 28px; font-size: 12px; }
166
+ .sv-color--md { height: 34px; font-size: 13px; }
167
+ .sv-color--lg { height: 40px; font-size: 15px; }
168
+ .sv-color.is-disabled { opacity: 0.6; cursor: not-allowed; }
169
+ .sv-color:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--_accent)); outline-offset: 2px; }
170
+ .sv-color__swatch { width: 18px; height: 18px; border-radius: 4px; border: 1px solid rgba(0,0,0,0.15); flex: none; }
171
+ .sv-color__hex { font-variant-numeric: tabular-nums; text-transform: lowercase; }
172
+
173
+ :global(.sv-color__panel) {
174
+ z-index: 2147483647; width: 208px; padding: 12px;
175
+ background: var(--sg-bg, #fff); border: 1px solid var(--sg-border, #e2e8f0);
176
+ border-radius: 12px; box-shadow: 0 16px 48px -12px rgba(15,23,42,0.35);
177
+ }
178
+ :global(.sv-color__top) { display: flex; gap: 8px; margin-bottom: 10px; }
179
+ :global(.sv-color__native) { width: 36px; height: 34px; padding: 0; border: 1px solid var(--sg-border, #e2e8f0); border-radius: 8px; background: none; cursor: pointer; }
180
+ :global(.sv-color__field) {
181
+ flex: 1; min-width: 0; height: 34px; padding: 0 10px; font: inherit; font-size: 13px;
182
+ border: 1px solid var(--sg-input-border, #cbd5e1); border-radius: 8px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); outline: none;
183
+ }
184
+ :global(.sv-color__palette) { display: grid; grid-template-columns: repeat(10, 1fr); gap: 4px; }
185
+ :global(.sv-color__chip) { width: 100%; aspect-ratio: 1; border-radius: 5px; border: 1px solid rgba(0,0,0,0.12); cursor: pointer; padding: 0; }
186
+ :global(.sv-color__chip.is-active) { outline: 2px solid var(--sg-accent, #2563eb); outline-offset: 1px; }
187
+ </style>
@@ -0,0 +1,130 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvComboBox - an editable combobox: type to filter a portalled list, pick an
4
+ * option (value must come from the list). Parity: Smart `smart-combo-box`.
5
+ * Controlled via `value` + `onChange`. On blur, unmatched text reverts.
6
+ */
7
+ import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
8
+ import { filterOptions, type ListOption } from './list-option'
9
+
10
+ type Props = {
11
+ options: ReadonlyArray<ListOption>
12
+ value?: string | number | null
13
+ onChange?: (value: string | number | null) => void
14
+ placeholder?: string
15
+ disabled?: boolean
16
+ name?: string
17
+ size?: 'sm' | 'md' | 'lg'
18
+ ariaLabel?: string
19
+ autoOpen?: boolean
20
+ }
21
+
22
+ let { options, value = null, onChange, placeholder = 'Select…', disabled = false, name, size = 'md', ariaLabel, autoOpen = false }: Props = $props()
23
+
24
+ let open = $state(false)
25
+ let query = $state('')
26
+ let active = $state(0)
27
+ let editing = $state(false)
28
+ let inputEl = $state<HTMLInputElement | null>(null)
29
+ let fieldEl = $state<HTMLDivElement | null>(null)
30
+ let panelEl = $state<HTMLDivElement | null>(null)
31
+ let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
32
+
33
+ const selected = $derived(options.find((o) => o.value === value) ?? null)
34
+ const filtered = $derived(editing ? filterOptions(options, query) : [...options])
35
+ const shownText = $derived(editing ? query : selected?.label ?? '')
36
+
37
+ function updatePos() {
38
+ if (!fieldEl) return
39
+ rect = anchoredRect(fieldEl.getBoundingClientRect(), { estimatedHeight: Math.min(filtered.length, 8) * 34 + 8 })
40
+ }
41
+ function openPanel() { if (disabled || open) return; open = true; active = 0; updatePos() }
42
+ function close(revert = true) {
43
+ open = false; editing = false
44
+ if (revert) query = selected?.label ?? ''
45
+ }
46
+ function pick(o: ListOption | undefined) {
47
+ if (!o || o.disabled) return
48
+ onChange?.(o.value); query = o.label; editing = false; open = false; inputEl?.blur()
49
+ }
50
+ function onInput(e: Event) {
51
+ query = (e.currentTarget as HTMLInputElement).value
52
+ editing = true; active = 0
53
+ if (!open) openPanel(); else updatePos()
54
+ }
55
+ function onKeydown(e: KeyboardEvent) {
56
+ if (disabled) return
57
+ if (!open && (e.key === 'ArrowDown')) { e.preventDefault(); editing = true; openPanel(); return }
58
+ if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) }
59
+ else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
60
+ else if (e.key === 'Enter') { e.preventDefault(); pick(filtered[active]) }
61
+ else if (e.key === 'Escape') { e.preventDefault(); close() }
62
+ queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${active}"]`)?.scrollIntoView({ block: 'nearest' }))
63
+ }
64
+ function onFocus() { editing = true; query = selected?.label ?? ''; openPanel() }
65
+ function onBlur() { /* outside-click effect handles close+revert */ }
66
+
67
+ $effect(() => {
68
+ if (!open) return
69
+ const rp = () => updatePos()
70
+ window.addEventListener('scroll', rp, true); window.addEventListener('resize', rp)
71
+ const od = (e: PointerEvent) => { const t = e.target as Node | null; if (t && (fieldEl?.contains(t) || panelEl?.contains(t))) return; close() }
72
+ document.addEventListener('pointerdown', od, true)
73
+ return () => { window.removeEventListener('scroll', rp, true); window.removeEventListener('resize', rp); document.removeEventListener('pointerdown', od, true) }
74
+ })
75
+ $effect(() => { if (!editing) query = selected?.label ?? '' })
76
+ function focusOpen(node: HTMLInputElement) { if (autoOpen) { node.focus() } }
77
+ </script>
78
+
79
+ <div bind:this={fieldEl} class="sv-combo sv-combo--{size}" class:is-open={open} class:is-disabled={disabled}>
80
+ <input
81
+ bind:this={inputEl}
82
+ class="sv-combo__input"
83
+ type="text"
84
+ role="combobox"
85
+ aria-expanded={open}
86
+ aria-controls="combo-list"
87
+ aria-autocomplete="list"
88
+ value={shownText}
89
+ {placeholder}
90
+ {disabled}
91
+ aria-label={ariaLabel}
92
+ oninput={onInput}
93
+ onfocus={onFocus}
94
+ onblur={onBlur}
95
+ onkeydown={onKeydown}
96
+ use:focusOpen
97
+ />
98
+ <button type="button" class="sv-combo__chev" tabindex="-1" aria-label="Toggle" onclick={() => (open ? close() : (inputEl?.focus(), openPanel()))} {disabled}>
99
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6" /></svg>
100
+ </button>
101
+ </div>
102
+
103
+ {#if open}
104
+ <div bind:this={panelEl} id="combo-list" 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`} role="listbox">
105
+ {#each filtered as opt, i (opt.value)}
106
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
107
+ <div class="sv-ddl__opt" class:is-active={i === active} class:is-selected={opt.value === value} class:is-disabled={opt.disabled} role="option" tabindex="-1" aria-selected={opt.value === value} data-idx={i} onclick={() => pick(opt)} onpointermove={() => { if (!opt.disabled) active = i }}>{opt.label}</div>
108
+ {:else}
109
+ <div class="sv-ddl__empty">No matches</div>
110
+ {/each}
111
+ </div>
112
+ {/if}
113
+ {#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
114
+
115
+ <style>
116
+ .sv-combo {
117
+ --_accent: var(--sg-accent, #2563eb);
118
+ display: inline-flex; align-items: center; width: 200px;
119
+ background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
120
+ border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
121
+ }
122
+ .sv-combo--sm { height: 28px; font-size: 12px; }
123
+ .sv-combo--md { height: 34px; font-size: 13px; }
124
+ .sv-combo--lg { height: 40px; font-size: 15px; }
125
+ .sv-combo.is-open, .sv-combo:focus-within { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
126
+ .sv-combo.is-disabled { opacity: 0.6; }
127
+ .sv-combo__input { flex: 1; min-width: 0; border: 0; background: none; outline: none; color: inherit; font: inherit; padding: 0 4px 0 10px; }
128
+ .sv-combo__chev { display: grid; place-items: center; width: 28px; align-self: stretch; background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; }
129
+ :global(.sv-ddl__empty) { padding: 8px 10px; color: var(--sg-muted, #94a3b8); font-size: 13px; }
130
+ </style>