@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,122 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvListBox - an inline single/multi-select list (WAI-ARIA listbox) with
4
+ * roving highlight + full keyboard. Parity: Smart `smart-list-box`. Controlled
5
+ * via `value` (scalar or array) + `onChange`.
6
+ */
7
+ import type { ListOption } from './list-option'
8
+
9
+ type Props = {
10
+ options: ReadonlyArray<ListOption>
11
+ value?: string | number | Array<string | number> | null
12
+ onChange?: (value: any) => void
13
+ multiple?: boolean
14
+ disabled?: boolean
15
+ /** Visible height in rows before scrolling. */
16
+ rows?: number
17
+ ariaLabel?: string
18
+ }
19
+
20
+ let { options, value = null, onChange, multiple = false, disabled = false, rows = 7, ariaLabel }: Props = $props()
21
+
22
+ const selected = $derived<Array<string | number>>(
23
+ multiple ? (Array.isArray(value) ? value : value == null ? [] : [value]) : value == null ? [] : [value as string | number],
24
+ )
25
+ const enabledIdx = $derived(options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
26
+ let active = $state(0)
27
+ $effect(() => {
28
+ // Keep the active index on an enabled option.
29
+ if (!options[active] || options[active]!.disabled) active = enabledIdx[0] ?? 0
30
+ })
31
+
32
+ function isSel(o: ListOption) { return selected.includes(o.value) }
33
+
34
+ function pick(i: number) {
35
+ const o = options[i]
36
+ if (!o || o.disabled || disabled) return
37
+ active = i
38
+ if (multiple) {
39
+ const set = new Set(selected)
40
+ set.has(o.value) ? set.delete(o.value) : set.add(o.value)
41
+ onChange?.([...set])
42
+ } else {
43
+ onChange?.(o.value)
44
+ }
45
+ }
46
+
47
+ function move(delta: number) {
48
+ const pos = enabledIdx.indexOf(active)
49
+ const next = enabledIdx[(pos + delta + enabledIdx.length) % enabledIdx.length]
50
+ if (next != null) { active = next; scrollActive() }
51
+ }
52
+
53
+ let listEl: HTMLUListElement | null = null
54
+ function scrollActive() {
55
+ queueMicrotask(() => listEl?.querySelector<HTMLElement>(`[data-idx="${active}"]`)?.scrollIntoView({ block: 'nearest' }))
56
+ }
57
+
58
+ function onKeydown(e: KeyboardEvent) {
59
+ if (disabled) return
60
+ switch (e.key) {
61
+ case 'ArrowDown': e.preventDefault(); move(1); break
62
+ case 'ArrowUp': e.preventDefault(); move(-1); break
63
+ case 'Home': e.preventDefault(); active = enabledIdx[0] ?? 0; scrollActive(); break
64
+ case 'End': e.preventDefault(); active = enabledIdx.at(-1) ?? 0; scrollActive(); break
65
+ case ' ':
66
+ case 'Enter': e.preventDefault(); pick(active); break
67
+ }
68
+ }
69
+ </script>
70
+
71
+ <ul
72
+ bind:this={listEl}
73
+ class="sv-listbox"
74
+ role="listbox"
75
+ aria-multiselectable={multiple}
76
+ aria-label={ariaLabel}
77
+ aria-disabled={disabled}
78
+ tabindex={disabled ? -1 : 0}
79
+ style:--sv-rows={rows}
80
+ onkeydown={onKeydown}
81
+ >
82
+ {#each options as opt, i (opt.value)}
83
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
84
+ <li
85
+ class="sv-listbox__opt"
86
+ class:is-selected={isSel(opt)}
87
+ class:is-active={i === active}
88
+ class:is-disabled={opt.disabled}
89
+ role="option"
90
+ aria-selected={isSel(opt)}
91
+ aria-disabled={opt.disabled}
92
+ data-idx={i}
93
+ onclick={() => pick(i)}
94
+ onpointermove={() => { if (!opt.disabled) active = i }}
95
+ >
96
+ {#if multiple}<span class="sv-listbox__check" aria-hidden="true">{isSel(opt) ? '✓' : ''}</span>{/if}
97
+ <span class="sv-listbox__label">{opt.label}</span>
98
+ </li>
99
+ {/each}
100
+ </ul>
101
+
102
+ <style>
103
+ .sv-listbox {
104
+ --_accent: var(--sg-accent, #2563eb);
105
+ margin: 0; padding: 4px; list-style: none;
106
+ max-height: calc(var(--sv-rows, 7) * 32px + 8px); overflow-y: auto;
107
+ background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
108
+ border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
109
+ width: 220px; outline: none;
110
+ }
111
+ .sv-listbox:focus-visible { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
112
+ .sv-listbox__opt {
113
+ display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px;
114
+ border-radius: 6px; cursor: pointer; font-size: 13px;
115
+ }
116
+ .sv-listbox__opt.is-active { background: var(--sg-row-hover-bg, #f1f5f9); }
117
+ .sv-listbox__opt.is-selected { background: color-mix(in srgb, var(--_accent) 14%, transparent); color: var(--_accent); font-weight: 600; }
118
+ .sv-listbox__opt.is-selected.is-active { background: color-mix(in srgb, var(--_accent) 22%, transparent); }
119
+ .sv-listbox__opt.is-disabled { opacity: 0.4; cursor: not-allowed; }
120
+ .sv-listbox__check { width: 14px; text-align: center; color: var(--_accent); }
121
+ .sv-listbox__label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
122
+ </style>
Binary file
Binary file
@@ -0,0 +1,110 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvPasswordInput - a password field with a reveal toggle and an optional
4
+ * strength meter. Parity: Smart `smart-password-input`. Emits the string value.
5
+ */
6
+ type Props = {
7
+ value?: string
8
+ onChange?: (value: string) => void
9
+ placeholder?: string
10
+ disabled?: boolean
11
+ readonly?: boolean
12
+ /** Show the show/hide eye toggle. */
13
+ revealable?: boolean
14
+ /** Show a 4-level strength meter. */
15
+ showStrength?: boolean
16
+ name?: string
17
+ size?: 'sm' | 'md' | 'lg'
18
+ ariaLabel?: string
19
+ autocomplete?: string
20
+ }
21
+
22
+ let {
23
+ value = '',
24
+ onChange,
25
+ placeholder,
26
+ disabled = false,
27
+ readonly = false,
28
+ revealable = true,
29
+ showStrength = false,
30
+ name,
31
+ size = 'md',
32
+ ariaLabel,
33
+ autocomplete = 'current-password',
34
+ }: Props = $props()
35
+
36
+ let revealed = $state(false)
37
+
38
+ // Simple strength heuristic: length + character-class variety (0-4).
39
+ const strength = $derived.by(() => {
40
+ const v = value
41
+ if (!v) return 0
42
+ let score = 0
43
+ if (v.length >= 8) score++
44
+ if (/[a-z]/.test(v) && /[A-Z]/.test(v)) score++
45
+ if (/\d/.test(v)) score++
46
+ if (/[^a-zA-Z0-9]/.test(v)) score++
47
+ return score
48
+ })
49
+ const strengthLabel = ['', 'Weak', 'Fair', 'Good', 'Strong']
50
+ </script>
51
+
52
+ <div class="sv-pw sv-pw--{size}" class:is-disabled={disabled}>
53
+ <div class="sv-pw__field">
54
+ <input
55
+ class="sv-pw__input"
56
+ type={revealed ? 'text' : 'password'}
57
+ value={value}
58
+ {placeholder}
59
+ {disabled}
60
+ {readonly}
61
+ autocomplete={autocomplete as AutoFill}
62
+ aria-label={ariaLabel}
63
+ oninput={(e) => onChange?.((e.currentTarget as HTMLInputElement).value)}
64
+ />
65
+ {#if revealable}
66
+ <button type="button" class="sv-pw__eye" aria-label={revealed ? 'Hide password' : 'Show password'} aria-pressed={revealed} onclick={() => (revealed = !revealed)} disabled={disabled} tabindex="-1">
67
+ {#if revealed}
68
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" /><path d="M1 1l22 22" /></svg>
69
+ {:else}
70
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></svg>
71
+ {/if}
72
+ </button>
73
+ {/if}
74
+ </div>
75
+ {#if showStrength}
76
+ <div class="sv-pw__meter" aria-hidden="true">
77
+ {#each Array(4) as _, i (i)}
78
+ <span class="sv-pw__bar is-lvl{strength}" class:on={i < strength}></span>
79
+ {/each}
80
+ </div>
81
+ <span class="sv-pw__strength is-lvl{strength}">{strengthLabel[strength]}</span>
82
+ {/if}
83
+ {#if name}<input type="hidden" {name} value={value} />{/if}
84
+ </div>
85
+
86
+ <style>
87
+ .sv-pw { --_accent: var(--sg-accent, #2563eb); display: inline-flex; flex-direction: column; gap: 5px; width: 220px; }
88
+ .sv-pw.is-disabled { opacity: 0.6; }
89
+ .sv-pw__field {
90
+ display: flex; align-items: center;
91
+ background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
92
+ border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
93
+ }
94
+ .sv-pw__field:focus-within { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
95
+ .sv-pw__input { flex: 1; min-width: 0; border: 0; background: none; outline: none; color: inherit; font: inherit; padding: 0 10px; }
96
+ .sv-pw--sm .sv-pw__field { height: 28px; font-size: 12px; }
97
+ .sv-pw--md .sv-pw__field { height: 34px; font-size: 13px; }
98
+ .sv-pw--lg .sv-pw__field { height: 40px; font-size: 15px; }
99
+ .sv-pw__eye { display: grid; place-items: center; width: 32px; align-self: stretch; background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; }
100
+ .sv-pw__eye:hover { color: var(--_accent); }
101
+ .sv-pw__meter { display: flex; gap: 4px; }
102
+ .sv-pw__bar { flex: 1; height: 4px; border-radius: 2px; background: var(--sg-border, #e2e8f0); transition: background 0.15s; }
103
+ .sv-pw__bar.on.is-lvl1 { background: #dc2626; }
104
+ .sv-pw__bar.on.is-lvl2 { background: #f59e0b; }
105
+ .sv-pw__bar.on.is-lvl3 { background: #eab308; }
106
+ .sv-pw__bar.on.is-lvl4 { background: #16a34a; }
107
+ .sv-pw__strength { font-size: 11px; font-weight: 600; color: var(--sg-muted, #64748b); }
108
+ .sv-pw__strength.is-lvl1 { color: #dc2626; }
109
+ .sv-pw__strength.is-lvl4 { color: #16a34a; }
110
+ </style>
Binary file
@@ -0,0 +1,108 @@
1
+ <script lang="ts" module>
2
+ export type RadioOption = { value: string | number; label: string; disabled?: boolean }
3
+ </script>
4
+
5
+ <script lang="ts">
6
+ /**
7
+ * SvRadioGroup - an accessible radio group (WAI-ARIA radiogroup pattern) with
8
+ * roving tabindex + arrow-key navigation. Parity: Smart `smart-radio-button`
9
+ * (as a managed group). Controlled via `value` + `onChange`.
10
+ */
11
+ type Props = {
12
+ options: ReadonlyArray<RadioOption>
13
+ value?: string | number | null
14
+ onChange?: (value: string | number) => void
15
+ name?: string
16
+ disabled?: boolean
17
+ orientation?: 'vertical' | 'horizontal'
18
+ size?: 'sm' | 'md' | 'lg'
19
+ ariaLabel?: string
20
+ }
21
+
22
+ let {
23
+ options,
24
+ value = null,
25
+ onChange,
26
+ name,
27
+ disabled = false,
28
+ orientation = 'vertical',
29
+ size = 'md',
30
+ ariaLabel,
31
+ }: Props = $props()
32
+
33
+ const enabledIdx = $derived(options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
34
+ const selectedIdx = $derived(options.findIndex((o) => o.value === value))
35
+ // The roving-tabindex target: the selected option, else the first enabled.
36
+ const focusIdx = $derived(selectedIdx >= 0 ? selectedIdx : enabledIdx[0] ?? -1)
37
+
38
+ function pick(i: number) {
39
+ const o = options[i]
40
+ if (!o || o.disabled || disabled) return
41
+ onChange?.(o.value)
42
+ }
43
+
44
+ function onKeydown(e: KeyboardEvent, i: number) {
45
+ if (disabled) return
46
+ const pos = enabledIdx.indexOf(i)
47
+ if (pos < 0) return
48
+ let nextPos = pos
49
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') nextPos = (pos + 1) % enabledIdx.length
50
+ else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') nextPos = (pos - 1 + enabledIdx.length) % enabledIdx.length
51
+ else if (e.key === ' ') { e.preventDefault(); pick(i); return }
52
+ else return
53
+ e.preventDefault()
54
+ const target = enabledIdx[nextPos]!
55
+ pick(target)
56
+ ;(e.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLElement>('.sv-radio')[target]?.focus()
57
+ }
58
+ </script>
59
+
60
+ <div
61
+ class="sv-radiogroup sv-radiogroup--{orientation} sv-radiogroup--{size}"
62
+ role="radiogroup"
63
+ aria-label={ariaLabel}
64
+ aria-disabled={disabled}
65
+ >
66
+ {#each options as opt, i (opt.value)}
67
+ <button
68
+ type="button"
69
+ role="radio"
70
+ class="sv-radio"
71
+ class:is-checked={opt.value === value}
72
+ aria-checked={opt.value === value}
73
+ disabled={disabled || opt.disabled}
74
+ tabindex={i === focusIdx ? 0 : -1}
75
+ onclick={() => pick(i)}
76
+ onkeydown={(e) => onKeydown(e, i)}
77
+ >
78
+ <span class="sv-radio__dot"></span>
79
+ <span class="sv-radio__label">{opt.label}</span>
80
+ </button>
81
+ {/each}
82
+ {#if name}<input type="hidden" {name} value={value ?? ''} />{/if}
83
+ </div>
84
+
85
+ <style>
86
+ .sv-radiogroup { --_accent: var(--sg-accent, #2563eb); display: flex; gap: 10px; }
87
+ .sv-radiogroup--vertical { flex-direction: column; }
88
+ .sv-radiogroup--horizontal { flex-direction: row; flex-wrap: wrap; }
89
+ .sv-radio {
90
+ display: inline-flex; align-items: center; gap: 8px; background: none; border: 0; padding: 2px;
91
+ font: inherit; color: var(--sg-fg, #0f172a); cursor: pointer; user-select: none;
92
+ }
93
+ .sv-radiogroup--sm .sv-radio { font-size: 12px; }
94
+ .sv-radiogroup--md .sv-radio { font-size: 13px; }
95
+ .sv-radiogroup--lg .sv-radio { font-size: 15px; }
96
+ .sv-radio[disabled] { opacity: 0.5; cursor: not-allowed; }
97
+ .sv-radio__dot {
98
+ width: 17px; height: 17px; flex: none; border-radius: 50%;
99
+ border: 1.5px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff);
100
+ display: grid; place-items: center; transition: border-color 0.12s;
101
+ }
102
+ .sv-radio.is-checked .sv-radio__dot { border-color: var(--_accent); }
103
+ .sv-radio.is-checked .sv-radio__dot::after {
104
+ content: ''; width: 9px; height: 9px; border-radius: 50%; background: var(--_accent);
105
+ }
106
+ .sv-radio:focus-visible { outline: none; }
107
+ .sv-radio:focus-visible .sv-radio__dot { outline: 2px solid var(--sg-focus-ring, var(--_accent)); outline-offset: 2px; }
108
+ </style>
@@ -0,0 +1,111 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvRating - a star rating control (WAI-ARIA slider). Parity: Smart
4
+ * `smart-rating`. Hover preview, keyboard, optional half steps, readOnly.
5
+ * Uses the grid's `--sg-rating-*` tokens so it matches the in-grid editor.
6
+ */
7
+ type Props = {
8
+ value?: number
9
+ onChange?: (value: number) => void
10
+ max?: number
11
+ /** Allow half-star precision. */
12
+ allowHalf?: boolean
13
+ readOnly?: boolean
14
+ disabled?: boolean
15
+ size?: 'sm' | 'md' | 'lg'
16
+ name?: string
17
+ ariaLabel?: string
18
+ }
19
+
20
+ let {
21
+ value = 0,
22
+ onChange,
23
+ max = 5,
24
+ allowHalf = false,
25
+ readOnly = false,
26
+ disabled = false,
27
+ size = 'md',
28
+ name,
29
+ ariaLabel,
30
+ }: Props = $props()
31
+
32
+ let hover = $state<number | null>(null)
33
+ const isInteractive = $derived(!readOnly && !disabled)
34
+ const shown = $derived(hover ?? value)
35
+
36
+ function valueAt(e: MouseEvent, index: number): number {
37
+ if (!allowHalf) return index + 1
38
+ const el = e.currentTarget as HTMLElement
39
+ const rect = el.getBoundingClientRect()
40
+ return e.clientX - rect.left < rect.width / 2 ? index + 0.5 : index + 1
41
+ }
42
+
43
+ function fillOf(index: number): 'full' | 'half' | 'empty' {
44
+ if (shown >= index + 1) return 'full'
45
+ if (shown >= index + 0.5) return 'half'
46
+ return 'empty'
47
+ }
48
+
49
+ function onKeydown(e: KeyboardEvent) {
50
+ if (!isInteractive) return
51
+ const step = allowHalf ? 0.5 : 1
52
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); onChange?.(Math.min(max, value + step)) }
53
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); onChange?.(Math.max(0, value - step)) }
54
+ else if (e.key === 'Home') { e.preventDefault(); onChange?.(0) }
55
+ else if (e.key === 'End') { e.preventDefault(); onChange?.(max) }
56
+ }
57
+ </script>
58
+
59
+ <div
60
+ class="sv-rating sv-rating--{size}"
61
+ class:is-readonly={readOnly}
62
+ class:is-disabled={disabled}
63
+ role="slider"
64
+ tabindex={isInteractive ? 0 : -1}
65
+ aria-label={ariaLabel ?? 'Rating'}
66
+ aria-valuenow={value}
67
+ aria-valuemin="0"
68
+ aria-valuemax={max}
69
+ aria-readonly={readOnly}
70
+ onkeydown={onKeydown}
71
+ onpointerleave={() => (hover = null)}
72
+ >
73
+ {#each Array(max) as _, i (i)}
74
+ <button
75
+ type="button"
76
+ class="sv-rating__star is-{fillOf(i)}"
77
+ tabindex="-1"
78
+ disabled={!isInteractive}
79
+ aria-label={`${i + 1} star${i ? 's' : ''}`}
80
+ onpointermove={(e) => { if (isInteractive) hover = valueAt(e, i) }}
81
+ onclick={(e) => { if (isInteractive) onChange?.(valueAt(e, i)) }}
82
+ >
83
+ <svg viewBox="0 0 24 24" aria-hidden="true">
84
+ <defs>
85
+ <linearGradient id="half-{i}"><stop offset="50%" stop-color="var(--_on)" /><stop offset="50%" stop-color="var(--_off)" /></linearGradient>
86
+ </defs>
87
+ <path d="M12 2.5l2.9 6.1 6.6.9-4.8 4.6 1.2 6.6L12 18.6 6.1 21.7l1.2-6.6L2.5 9.5l6.6-.9z"
88
+ fill={fillOf(i) === 'full' ? 'var(--_on)' : fillOf(i) === 'half' ? `url(#half-${i})` : 'var(--_off)'} />
89
+ </svg>
90
+ </button>
91
+ {/each}
92
+ {#if name}<input type="hidden" {name} value={value} />{/if}
93
+ </div>
94
+
95
+ <style>
96
+ .sv-rating {
97
+ --_on: var(--sg-rating-on, #f59e0b);
98
+ --_off: var(--sg-rating-empty, #d1d5db);
99
+ --_sz: 22px;
100
+ display: inline-flex; gap: 2px; align-items: center;
101
+ }
102
+ .sv-rating--sm { --_sz: 16px; }
103
+ .sv-rating--lg { --_sz: 30px; }
104
+ .sv-rating.is-disabled { opacity: 0.5; }
105
+ .sv-rating__star {
106
+ width: var(--_sz); height: var(--_sz); padding: 0; border: 0; background: none; cursor: pointer; line-height: 0;
107
+ }
108
+ .sv-rating.is-readonly .sv-rating__star, .sv-rating.is-disabled .sv-rating__star { cursor: default; }
109
+ .sv-rating__star svg { width: 100%; height: 100%; }
110
+ .sv-rating:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--sg-accent, #2563eb)); outline-offset: 2px; border-radius: 4px; }
111
+ </style>
@@ -0,0 +1,89 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvRepeatButton - fires `onclick` repeatedly while held down (after an
4
+ * initial delay, then at a steady interval). Parity: Smart `smart-repeat-button`.
5
+ * Used for spinners / steppers.
6
+ */
7
+ import type { Snippet } from 'svelte'
8
+
9
+ type Props = {
10
+ onclick?: () => void
11
+ /** Delay before repeats begin (ms). */
12
+ delay?: number
13
+ /** Interval between repeats (ms). */
14
+ interval?: number
15
+ disabled?: boolean
16
+ size?: 'sm' | 'md' | 'lg'
17
+ variant?: 'primary' | 'secondary' | 'outline' | 'ghost'
18
+ ariaLabel?: string
19
+ children?: Snippet
20
+ }
21
+
22
+ let {
23
+ onclick,
24
+ delay = 300,
25
+ interval = 60,
26
+ disabled = false,
27
+ size = 'md',
28
+ variant = 'secondary',
29
+ ariaLabel,
30
+ children,
31
+ }: Props = $props()
32
+
33
+ let delayTimer: ReturnType<typeof setTimeout> | null = null
34
+ let repeatTimer: ReturnType<typeof setInterval> | null = null
35
+
36
+ function stop() {
37
+ if (delayTimer) { clearTimeout(delayTimer); delayTimer = null }
38
+ if (repeatTimer) { clearInterval(repeatTimer); repeatTimer = null }
39
+ }
40
+
41
+ function start(e: PointerEvent) {
42
+ if (disabled || e.button !== 0) return
43
+ onclick?.() // fire once immediately
44
+ delayTimer = setTimeout(() => {
45
+ repeatTimer = setInterval(() => onclick?.(), Math.max(16, interval))
46
+ }, Math.max(0, delay))
47
+ }
48
+
49
+ function onKeydown(e: KeyboardEvent) {
50
+ if (disabled) return
51
+ if ((e.key === 'Enter' || e.key === ' ') && !e.repeat) { e.preventDefault(); onclick?.() }
52
+ }
53
+ </script>
54
+
55
+ <svelte:window onpointerup={stop} onpointercancel={stop} />
56
+
57
+ <button
58
+ type="button"
59
+ class="sv-btn sv-btn--{variant} sv-btn--{size} sv-repeat"
60
+ {disabled}
61
+ aria-label={ariaLabel}
62
+ onpointerdown={start}
63
+ onpointerleave={stop}
64
+ onkeydown={onKeydown}
65
+ >
66
+ {#if children}{@render children()}{/if}
67
+ </button>
68
+
69
+ <style>
70
+ /* Reuses the SvButton visual language (kept local so it works standalone). */
71
+ .sv-btn {
72
+ --_accent: var(--sg-accent, #2563eb);
73
+ --_border: var(--sg-border, #cbd5e1);
74
+ --_radius: var(--sg-radius, 8px);
75
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
76
+ font: inherit; font-weight: 600; line-height: 1; border: 1px solid transparent;
77
+ border-radius: var(--_radius); cursor: pointer; user-select: none; touch-action: none;
78
+ }
79
+ .sv-btn--sm { padding: 5px 9px; font-size: 12px; }
80
+ .sv-btn--md { padding: 8px 12px; font-size: 13px; }
81
+ .sv-btn--lg { padding: 11px 16px; font-size: 15px; }
82
+ .sv-btn[disabled] { opacity: 0.55; cursor: not-allowed; }
83
+ .sv-btn--primary { background: var(--_accent); color: var(--sg-on-accent, #fff); }
84
+ .sv-btn--secondary { background: var(--sg-header-bg, #f1f5f9); color: var(--sg-fg, #0f172a); border-color: var(--_border); }
85
+ .sv-btn--outline { background: transparent; color: var(--_accent); border-color: var(--_accent); }
86
+ .sv-btn--ghost { background: transparent; color: var(--sg-fg, #0f172a); }
87
+ .sv-btn:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--_accent)); outline-offset: 2px; }
88
+ .sv-btn:active { transform: translateY(0.5px); }
89
+ </style>