@svgrid/grid 1.2.23 → 1.2.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/SvAutoComplete.svelte +23 -34
  2. package/dist/SvCalendar.svelte +74 -334
  3. package/dist/SvCalendar.svelte.d.ts +5 -23
  4. package/dist/SvCheckBox.svelte +11 -10
  5. package/dist/SvColorInput.svelte +31 -58
  6. package/dist/SvComboBox.svelte +32 -54
  7. package/dist/SvCountryInput.svelte +31 -29
  8. package/dist/SvDateTimePicker.svelte +53 -110
  9. package/dist/SvDateTimePicker.svelte.d.ts +4 -3
  10. package/dist/SvDropDownList.svelte +31 -54
  11. package/dist/SvGauge.svelte +21 -23
  12. package/dist/SvGauge.svelte.d.ts +0 -5
  13. package/dist/SvListBox.svelte +25 -58
  14. package/dist/SvListBox.svelte.d.ts +3 -0
  15. package/dist/SvMaskedInput.svelte +0 -0
  16. package/dist/SvNumberInput.svelte +0 -0
  17. package/dist/SvNumberInput.svelte.d.ts +2 -6
  18. package/dist/SvPasswordInput.svelte +18 -28
  19. package/dist/SvPasswordInput.svelte.d.ts +0 -4
  20. package/dist/SvPhoneInput.svelte +0 -0
  21. package/dist/SvRadioGroup.svelte +14 -37
  22. package/dist/SvRadioGroup.svelte.d.ts +6 -9
  23. package/dist/SvRating.svelte +22 -42
  24. package/dist/SvRating.svelte.d.ts +3 -5
  25. package/dist/SvSlider.svelte +30 -57
  26. package/dist/SvSlider.svelte.d.ts +0 -5
  27. package/dist/SvSwitchButton.svelte +10 -17
  28. package/dist/SvSwitchButton.svelte.d.ts +0 -5
  29. package/dist/SvTabs.svelte +26 -39
  30. package/dist/SvTabs.svelte.d.ts +5 -5
  31. package/dist/SvTagsInput.svelte +17 -28
  32. package/dist/SvTagsInput.svelte.d.ts +0 -5
  33. package/dist/SvTimePicker.svelte +52 -210
  34. package/dist/SvTimePicker.svelte.d.ts +11 -8
  35. package/dist/SvToggleButton.svelte +9 -9
  36. package/dist/SvTree.svelte +30 -109
  37. package/dist/SvTree.svelte.d.ts +9 -11
  38. package/dist/cdn/svgrid.js +7488 -5839
  39. package/dist/cdn/svgrid.svelte-external.js +4691 -3028
  40. package/dist/createAutocomplete.svelte.d.ts +77 -0
  41. package/dist/createAutocomplete.svelte.js +100 -0
  42. package/dist/createCalendar.svelte.d.ts +192 -0
  43. package/dist/createCalendar.svelte.js +402 -0
  44. package/dist/createCheckbox.svelte.d.ts +45 -0
  45. package/dist/createCheckbox.svelte.js +26 -0
  46. package/dist/createColorInput.svelte.d.ts +67 -0
  47. package/dist/createColorInput.svelte.js +100 -0
  48. package/dist/createCombobox.svelte.d.ts +102 -0
  49. package/dist/createCombobox.svelte.js +171 -0
  50. package/dist/createCountryInput.svelte.d.ts +86 -0
  51. package/dist/createCountryInput.svelte.js +126 -0
  52. package/dist/createDateTimePicker.svelte.d.ts +83 -0
  53. package/dist/createDateTimePicker.svelte.js +202 -0
  54. package/dist/createDropdownList.svelte.d.ts +78 -0
  55. package/dist/createDropdownList.svelte.js +117 -0
  56. package/dist/createGauge.svelte.d.ts +56 -0
  57. package/dist/createGauge.svelte.js +49 -0
  58. package/dist/createListbox.svelte.d.ts +79 -0
  59. package/dist/createListbox.svelte.js +117 -0
  60. package/dist/createMaskedInput.svelte.d.ts +30 -0
  61. package/dist/createMaskedInput.svelte.js +62 -0
  62. package/dist/createNumberInput.svelte.d.ts +72 -0
  63. package/dist/createNumberInput.svelte.js +167 -0
  64. package/dist/createPasswordInput.svelte.d.ts +60 -0
  65. package/dist/createPasswordInput.svelte.js +72 -0
  66. package/dist/createPhoneInput.svelte.d.ts +46 -0
  67. package/dist/createPhoneInput.svelte.js +105 -0
  68. package/dist/createRadioGroup.svelte.d.ts +64 -0
  69. package/dist/createRadioGroup.svelte.js +73 -0
  70. package/dist/createRating.svelte.d.ts +70 -0
  71. package/dist/createRating.svelte.js +85 -0
  72. package/dist/createSlider.svelte.d.ts +71 -0
  73. package/dist/createSlider.svelte.js +123 -0
  74. package/dist/createSwitch.svelte.d.ts +43 -0
  75. package/dist/createSwitch.svelte.js +41 -0
  76. package/dist/createTabs.svelte.d.ts +78 -0
  77. package/dist/createTabs.svelte.js +92 -0
  78. package/dist/createTagsInput.svelte.d.ts +65 -0
  79. package/dist/createTagsInput.svelte.js +93 -0
  80. package/dist/createTimePicker.svelte.d.ts +106 -0
  81. package/dist/createTimePicker.svelte.js +248 -0
  82. package/dist/createToggle.svelte.d.ts +43 -0
  83. package/dist/createToggle.svelte.js +39 -0
  84. package/dist/createTree.svelte.d.ts +96 -0
  85. package/dist/createTree.svelte.js +194 -0
  86. package/dist/editor-contract.d.ts +48 -0
  87. package/dist/editor-contract.js +26 -0
  88. package/dist/index.d.ts +24 -0
  89. package/dist/index.js +31 -0
  90. package/package.json +1 -1
  91. package/src/SvAutoComplete.svelte +23 -34
  92. package/src/SvCalendar.svelte +74 -334
  93. package/src/SvCheckBox.svelte +11 -10
  94. package/src/SvColorInput.svelte +31 -58
  95. package/src/SvComboBox.svelte +32 -54
  96. package/src/SvCountryInput.svelte +31 -29
  97. package/src/SvDateTimePicker.svelte +53 -110
  98. package/src/SvDropDownList.svelte +31 -54
  99. package/src/SvGauge.svelte +21 -23
  100. package/src/SvListBox.svelte +25 -58
  101. package/src/SvMaskedInput.svelte +0 -0
  102. package/src/SvNumberInput.svelte +0 -0
  103. package/src/SvPasswordInput.svelte +18 -28
  104. package/src/SvPhoneInput.svelte +0 -0
  105. package/src/SvRadioGroup.svelte +14 -37
  106. package/src/SvRating.svelte +22 -42
  107. package/src/SvSlider.svelte +30 -57
  108. package/src/SvSwitchButton.svelte +10 -17
  109. package/src/SvTabs.svelte +26 -39
  110. package/src/SvTagsInput.svelte +17 -28
  111. package/src/SvTimePicker.svelte +52 -210
  112. package/src/SvToggleButton.svelte +9 -9
  113. package/src/SvTree.svelte +30 -109
  114. package/src/createAutocomplete.svelte.ts +104 -0
  115. package/src/createCalendar.svelte.ts +488 -0
  116. package/src/createCheckbox.svelte.ts +74 -0
  117. package/src/createColorInput.svelte.ts +115 -0
  118. package/src/createCombobox.svelte.ts +158 -0
  119. package/src/createCountryInput.svelte.ts +126 -0
  120. package/src/createDateTimePicker.svelte.ts +216 -0
  121. package/src/createDropdownList.svelte.ts +125 -0
  122. package/src/createGauge.svelte.ts +86 -0
  123. package/src/createListbox.svelte.ts +186 -0
  124. package/src/createMaskedInput.svelte.ts +81 -0
  125. package/src/createNumberInput.svelte.ts +193 -0
  126. package/src/createPasswordInput.svelte.ts +87 -0
  127. package/src/createPhoneInput.svelte.ts +119 -0
  128. package/src/createRadioGroup.svelte.ts +132 -0
  129. package/src/createRating.svelte.ts +145 -0
  130. package/src/createSlider.svelte.ts +160 -0
  131. package/src/createSwitch.svelte.ts +78 -0
  132. package/src/createTabs.svelte.ts +129 -0
  133. package/src/createTagsInput.svelte.ts +98 -0
  134. package/src/createTimePicker.svelte.ts +289 -0
  135. package/src/createToggle.svelte.ts +69 -0
  136. package/src/createTree.svelte.ts +216 -0
  137. package/src/editor-contract.test.ts +32 -0
  138. package/src/editor-contract.ts +60 -0
  139. package/src/index.ts +39 -0
  140. package/src/ui-inputs.test.ts +26 -0
  141. package/src/ui-selection.test.ts +13 -0
@@ -2,7 +2,12 @@
2
2
  /**
3
3
  * SvPasswordInput - a password field with a reveal toggle and an optional
4
4
  * strength meter. Parity: Smart `smart-password-input`. Emits the string value.
5
+ *
6
+ * The styled renderer over the headless `createPasswordInput` core: the reveal
7
+ * toggle + strength heuristic come from the core, spread here via prop-getters.
5
8
  */
9
+ import { createPasswordInput } from './createPasswordInput.svelte'
10
+
6
11
  type Props = {
7
12
  value?: string
8
13
  onChange?: (value: string) => void
@@ -33,38 +38,23 @@
33
38
  autocomplete = 'current-password',
34
39
  }: Props = $props()
35
40
 
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
41
+ const pw = createPasswordInput({
42
+ value: () => value,
43
+ onChange: (v) => onChange?.(v),
44
+ placeholder: () => placeholder,
45
+ disabled: () => disabled,
46
+ readonly: () => readonly,
47
+ ariaLabel: () => ariaLabel,
48
+ autocomplete: () => autocomplete,
48
49
  })
49
- const strengthLabel = ['', 'Weak', 'Fair', 'Good', 'Strong']
50
50
  </script>
51
51
 
52
52
  <div class="sv-pw sv-pw--{size}" class:is-disabled={disabled}>
53
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
- />
54
+ <input class="sv-pw__input" {...pw.inputProps()} />
65
55
  {#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}
56
+ <button class="sv-pw__eye" {...pw.toggleProps()}>
57
+ {#if pw.revealed}
68
58
  <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
59
  {:else}
70
60
  <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>
@@ -75,10 +65,10 @@
75
65
  {#if showStrength}
76
66
  <div class="sv-pw__meter" aria-hidden="true">
77
67
  {#each Array(4) as _, i (i)}
78
- <span class="sv-pw__bar is-lvl{strength}" class:on={i < strength}></span>
68
+ <span class="sv-pw__bar is-lvl{pw.strength}" class:on={i < pw.strength}></span>
79
69
  {/each}
80
70
  </div>
81
- <span class="sv-pw__strength is-lvl{strength}">{strengthLabel[strength]}</span>
71
+ <span class="sv-pw__strength is-lvl{pw.strength}">{pw.strengthLabel}</span>
82
72
  {/if}
83
73
  {#if name}<input type="hidden" {name} value={value} />{/if}
84
74
  </div>
@@ -1,7 +1,3 @@
1
- /**
2
- * SvPasswordInput - a password field with a reveal toggle and an optional
3
- * strength meter. Parity: Smart `smart-password-input`. Emits the string value.
4
- */
5
1
  type Props = {
6
2
  value?: string;
7
3
  onChange?: (value: string) => void;
Binary file
@@ -1,5 +1,5 @@
1
1
  <script lang="ts" module>
2
- export type RadioOption = { value: string | number; label: string; disabled?: boolean }
2
+ export type { RadioOption } from './createRadioGroup.svelte'
3
3
  </script>
4
4
 
5
5
  <script lang="ts">
@@ -8,6 +8,8 @@
8
8
  * roving tabindex + arrow-key navigation. Parity: Smart `smart-radio-button`
9
9
  * (as a managed group). Controlled via `value` + `onChange`.
10
10
  */
11
+ import { createRadioGroup, type RadioOption } from './createRadioGroup.svelte'
12
+
11
13
  type Props = {
12
14
  options: ReadonlyArray<RadioOption>
13
15
  value?: string | number | null
@@ -30,50 +32,25 @@
30
32
  ariaLabel,
31
33
  }: Props = $props()
32
34
 
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
- }
35
+ // The styled radio group is just a renderer over the headless core.
36
+ const rg = createRadioGroup({
37
+ options: () => options,
38
+ value: () => value,
39
+ onChange: (v) => onChange?.(v),
40
+ disabled: () => disabled,
41
+ ariaLabel: () => ariaLabel,
42
+ })
58
43
  </script>
59
44
 
60
45
  <div
61
46
  class="sv-radiogroup sv-radiogroup--{orientation} sv-radiogroup--{size}"
62
- role="radiogroup"
63
- aria-label={ariaLabel}
64
- aria-disabled={disabled}
47
+ {...rg.groupProps()}
65
48
  >
66
- {#each options as opt, i (opt.value)}
49
+ {#each options as opt (opt.value)}
67
50
  <button
68
- type="button"
69
- role="radio"
70
51
  class="sv-radio"
71
52
  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)}
53
+ {...rg.radioProps(opt)}
77
54
  >
78
55
  <span class="sv-radio__dot"></span>
79
56
  <span class="sv-radio__label">{opt.label}</span>
@@ -1,13 +1,10 @@
1
- export type RadioOption = {
2
- value: string | number;
3
- label: string;
4
- disabled?: boolean;
5
- };
1
+ export type { RadioOption } from './createRadioGroup.svelte';
6
2
  /**
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
- */
3
+ * SvRadioGroup - an accessible radio group (WAI-ARIA radiogroup pattern) with
4
+ * roving tabindex + arrow-key navigation. Parity: Smart `smart-radio-button`
5
+ * (as a managed group). Controlled via `value` + `onChange`.
6
+ */
7
+ import { type RadioOption } from './createRadioGroup.svelte';
11
8
  type Props = {
12
9
  options: ReadonlyArray<RadioOption>;
13
10
  value?: string | number | null;
@@ -4,12 +4,17 @@
4
4
  * `smart-rating`. Hover preview, keyboard, optional half steps, readOnly.
5
5
  * Uses the grid's `--sg-rating-*` tokens so it matches the in-grid editor.
6
6
  */
7
+ import { createRating } from './createRating.svelte'
8
+
7
9
  type Props = {
8
10
  value?: number
9
11
  onChange?: (value: number) => void
10
12
  max?: number
11
13
  /** Allow half-star precision. */
12
14
  allowHalf?: boolean
15
+ /** Read-only: shown but not editable (the kit-standard name). */
16
+ readonly?: boolean
17
+ /** @deprecated use `readonly` - kept for back-compat. */
13
18
  readOnly?: boolean
14
19
  disabled?: boolean
15
20
  size?: 'sm' | 'md' | 'lg'
@@ -22,6 +27,7 @@
22
27
  onChange,
23
28
  max = 5,
24
29
  allowHalf = false,
30
+ readonly = false,
25
31
  readOnly = false,
26
32
  disabled = false,
27
33
  size = 'md',
@@ -29,63 +35,37 @@
29
35
  ariaLabel,
30
36
  }: Props = $props()
31
37
 
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
- }
38
+ const ro = $derived(readonly || readOnly)
48
39
 
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
- }
40
+ // The styled rating is just a renderer over the headless core.
41
+ const r = createRating({
42
+ value: () => value,
43
+ onChange: (v) => onChange?.(v),
44
+ max: () => max,
45
+ allowHalf: () => allowHalf,
46
+ readonly: () => ro,
47
+ disabled: () => disabled,
48
+ ariaLabel: () => ariaLabel,
49
+ })
57
50
  </script>
58
51
 
59
52
  <div
60
53
  class="sv-rating sv-rating--{size}"
61
- class:is-readonly={readOnly}
54
+ class:is-readonly={ro}
62
55
  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)}
56
+ {...r.rootProps()}
72
57
  >
73
58
  {#each Array(max) as _, i (i)}
74
59
  <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)) }}
60
+ class="sv-rating__star is-{r.fillOf(i)}"
61
+ {...r.starProps(i)}
82
62
  >
83
63
  <svg viewBox="0 0 24 24" aria-hidden="true">
84
64
  <defs>
85
65
  <linearGradient id="half-{i}"><stop offset="50%" stop-color="var(--_on)" /><stop offset="50%" stop-color="var(--_off)" /></linearGradient>
86
66
  </defs>
87
67
  <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)'} />
68
+ fill={r.fillOf(i) === 'full' ? 'var(--_on)' : r.fillOf(i) === 'half' ? `url(#half-${i})` : 'var(--_off)'} />
89
69
  </svg>
90
70
  </button>
91
71
  {/each}
@@ -1,14 +1,12 @@
1
- /**
2
- * SvRating - a star rating control (WAI-ARIA slider). Parity: Smart
3
- * `smart-rating`. Hover preview, keyboard, optional half steps, readOnly.
4
- * Uses the grid's `--sg-rating-*` tokens so it matches the in-grid editor.
5
- */
6
1
  type Props = {
7
2
  value?: number;
8
3
  onChange?: (value: number) => void;
9
4
  max?: number;
10
5
  /** Allow half-star precision. */
11
6
  allowHalf?: boolean;
7
+ /** Read-only: shown but not editable (the kit-standard name). */
8
+ readonly?: boolean;
9
+ /** @deprecated use `readonly` - kept for back-compat. */
12
10
  readOnly?: boolean;
13
11
  disabled?: boolean;
14
12
  size?: 'sm' | 'md' | 'lg';
@@ -3,7 +3,12 @@
3
3
  * SvSlider - a single or range slider (WAI-ARIA slider) with steps, ticks,
4
4
  * keyboard + pointer drag. Parity: Smart `smart-slider`. Single emits number;
5
5
  * range emits [number, number].
6
+ *
7
+ * The behavior lives in the headless `createSlider` core; this component is
8
+ * just one styled renderer over it (it only owns pointer/DOM measurement).
6
9
  */
10
+ import { createSlider } from './createSlider.svelte'
11
+
7
12
  type Props = {
8
13
  value?: number | [number, number]
9
14
  onChange?: (value: any) => void
@@ -38,15 +43,22 @@
38
43
  formatValue,
39
44
  }: Props = $props()
40
45
 
41
- const isVert = $derived(orientation === 'vertical')
42
- const lo = $derived(range ? (value as [number, number])[0] : min)
43
- const hi = $derived(range ? (value as [number, number])[1] : (value as number))
44
-
45
- function clampStep(v: number): number {
46
- const stepped = Math.round((v - min) / step) * step + min
47
- return Math.min(max, Math.max(min, Number(stepped.toFixed(10))))
48
- }
49
- const pct = (v: number) => ((v - min) / (max - min)) * 100
46
+ // Behavior (value<->position math, stepping, keyboard, drag state, ARIA) lives
47
+ // in the headless `createSlider` core; the component keeps the DOM measuring.
48
+ const slider = createSlider({
49
+ value: () => value,
50
+ onChange: (v) => onChange?.(v),
51
+ min: () => min,
52
+ max: () => max,
53
+ step: () => step,
54
+ range: () => range,
55
+ orientation: () => orientation,
56
+ disabled: () => disabled,
57
+ ariaLabel: () => ariaLabel,
58
+ })
59
+ const lo = $derived(slider.lo)
60
+ const hi = $derived(slider.hi)
61
+ const pct = (v: number) => slider.percentOf(v)
50
62
 
51
63
  const tickValues = $derived.by<number[]>(() => {
52
64
  if (!ticks) return []
@@ -54,58 +66,18 @@
54
66
  return Array.from({ length: ticks }, (_, i) => min + ((max - min) / (ticks - 1)) * i)
55
67
  })
56
68
 
69
+ // The rect is measured here (a DOM concern) and passed INTO the core.
57
70
  let trackEl: HTMLDivElement | null = null
58
- let dragging = $state<null | 'lo' | 'hi' | 'single'>(null)
59
-
60
- function valueFromPointer(e: PointerEvent): number {
61
- const rect = trackEl!.getBoundingClientRect()
62
- const frac = isVert
63
- ? 1 - (e.clientY - rect.top) / rect.height
64
- : (e.clientX - rect.left) / rect.width
65
- return clampStep(min + Math.min(1, Math.max(0, frac)) * (max - min))
66
- }
67
-
68
- function emitSingle(v: number) { onChange?.(v) }
69
- function emitRange(nlo: number, nhi: number) { onChange?.([Math.min(nlo, nhi), Math.max(nlo, nhi)]) }
70
-
71
71
  function onTrackDown(e: PointerEvent) {
72
72
  if (disabled) return
73
- const v = valueFromPointer(e)
74
- if (range) {
75
- const which = Math.abs(v - lo) <= Math.abs(v - hi) ? 'lo' : 'hi'
76
- dragging = which
77
- which === 'lo' ? emitRange(v, hi) : emitRange(lo, v)
78
- } else {
79
- dragging = 'single'
80
- emitSingle(v)
81
- }
73
+ slider.pointerDown({ x: e.clientX, y: e.clientY }, trackEl!.getBoundingClientRect())
82
74
  trackEl?.setPointerCapture(e.pointerId)
83
75
  }
84
76
  function onMove(e: PointerEvent) {
85
- if (!dragging) return
86
- const v = valueFromPointer(e)
87
- if (dragging === 'single') emitSingle(v)
88
- else if (dragging === 'lo') emitRange(v, hi)
89
- else emitRange(lo, v)
90
- }
91
- function onUp(e: PointerEvent) { if (dragging) { dragging = null; trackEl?.releasePointerCapture(e.pointerId) } }
92
-
93
- function onThumbKey(e: KeyboardEvent, which: 'lo' | 'hi' | 'single') {
94
- if (disabled) return
95
- const cur = which === 'lo' ? lo : hi
96
- let next = cur
97
- if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = clampStep(cur + step)
98
- else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = clampStep(cur - step)
99
- else if (e.key === 'Home') next = min
100
- else if (e.key === 'End') next = max
101
- else if (e.key === 'PageUp') next = clampStep(cur + step * 10)
102
- else if (e.key === 'PageDown') next = clampStep(cur - step * 10)
103
- else return
104
- e.preventDefault()
105
- if (which === 'single') emitSingle(next)
106
- else if (which === 'lo') emitRange(next, hi)
107
- else emitRange(lo, next)
77
+ if (!slider.dragging) return
78
+ slider.setFromPointer({ x: e.clientX, y: e.clientY }, trackEl!.getBoundingClientRect())
108
79
  }
80
+ function onUp(e: PointerEvent) { if (slider.dragging) { slider.endDrag(); trackEl?.releasePointerCapture(e.pointerId) } }
109
81
 
110
82
  const fmt = (v: number) => (formatValue ? formatValue(v) : String(v))
111
83
  </script>
@@ -119,6 +91,7 @@
119
91
  <div
120
92
  bind:this={trackEl}
121
93
  class="sv-slider__track"
94
+ {...slider.trackProps()}
122
95
  onpointerdown={onTrackDown}
123
96
  onpointermove={onMove}
124
97
  onpointerup={onUp}
@@ -134,14 +107,14 @@
134
107
  {/each}
135
108
 
136
109
  {#if range}
137
- <div class="sv-slider__thumb" style:--p={`${pct(lo)}%`} role="slider" tabindex={disabled ? -1 : 0} aria-valuemin={min} aria-valuemax={hi} aria-valuenow={lo} aria-label="Minimum" onkeydown={(e) => onThumbKey(e, 'lo')}>
110
+ <div class="sv-slider__thumb" style:--p={`${pct(lo)}%`} {...slider.thumbProps(0)}>
138
111
  {#if showValue}<span class="sv-slider__bubble">{fmt(lo)}</span>{/if}
139
112
  </div>
140
- <div class="sv-slider__thumb" style:--p={`${pct(hi)}%`} role="slider" tabindex={disabled ? -1 : 0} aria-valuemin={lo} aria-valuemax={max} aria-valuenow={hi} aria-label="Maximum" onkeydown={(e) => onThumbKey(e, 'hi')}>
113
+ <div class="sv-slider__thumb" style:--p={`${pct(hi)}%`} {...slider.thumbProps(1)}>
141
114
  {#if showValue}<span class="sv-slider__bubble">{fmt(hi)}</span>{/if}
142
115
  </div>
143
116
  {:else}
144
- <div class="sv-slider__thumb" style:--p={`${pct(hi)}%`} role="slider" tabindex={disabled ? -1 : 0} aria-valuemin={min} aria-valuemax={max} aria-valuenow={hi} aria-label={ariaLabel ?? 'Value'} onkeydown={(e) => onThumbKey(e, 'single')}>
117
+ <div class="sv-slider__thumb" style:--p={`${pct(hi)}%`} {...slider.thumbProps(0)}>
145
118
  {#if showValue}<span class="sv-slider__bubble">{fmt(hi)}</span>{/if}
146
119
  </div>
147
120
  {/if}
@@ -1,8 +1,3 @@
1
- /**
2
- * SvSlider - a single or range slider (WAI-ARIA slider) with steps, ticks,
3
- * keyboard + pointer drag. Parity: Smart `smart-slider`. Single emits number;
4
- * range emits [number, number].
5
- */
6
1
  type Props = {
7
2
  value?: number | [number, number];
8
3
  onChange?: (value: any) => void;
@@ -4,6 +4,8 @@
4
4
  * `smart-switch-button`. Controlled via `checked` + `onChange`; keyboard
5
5
  * togglable; optional inline on/off labels.
6
6
  */
7
+ import { createSwitch } from './createSwitch.svelte'
8
+
7
9
  type Props = {
8
10
  checked?: boolean
9
11
  onChange?: (checked: boolean) => void
@@ -27,28 +29,19 @@
27
29
  offLabel,
28
30
  }: Props = $props()
29
31
 
30
- function toggle() {
31
- if (disabled) return
32
- onChange?.(!checked)
33
- }
34
- function onKeydown(e: KeyboardEvent) {
35
- if (disabled) return
36
- if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggle() }
37
- else if (e.key === 'ArrowRight') { if (!checked) onChange?.(true) }
38
- else if (e.key === 'ArrowLeft') { if (checked) onChange?.(false) }
39
- }
32
+ // The styled switch is just a renderer over the headless core.
33
+ const sw = createSwitch({
34
+ checked: () => checked,
35
+ onChange: (v) => onChange?.(v),
36
+ disabled: () => disabled,
37
+ ariaLabel: () => ariaLabel,
38
+ })
40
39
  </script>
41
40
 
42
41
  <button
43
- type="button"
44
- role="switch"
45
42
  class="sv-switch sv-switch--{size}"
46
43
  class:is-on={checked}
47
- aria-checked={checked}
48
- aria-label={ariaLabel}
49
- {disabled}
50
- onclick={toggle}
51
- onkeydown={onKeydown}
44
+ {...sw.switchProps()}
52
45
  >
53
46
  <span class="sv-switch__track">
54
47
  {#if onLabel || offLabel}
@@ -1,8 +1,3 @@
1
- /**
2
- * SvSwitchButton - an on/off sliding switch (ARIA `switch`). Parity: Smart
3
- * `smart-switch-button`. Controlled via `checked` + `onChange`; keyboard
4
- * togglable; optional inline on/off labels.
5
- */
6
1
  type Props = {
7
2
  checked?: boolean;
8
3
  onChange?: (checked: boolean) => void;
@@ -1,5 +1,5 @@
1
1
  <script lang="ts" module>
2
- export type TabItem = { id: string; label: string; disabled?: boolean }
2
+ export type { TabItem } from './createTabs.svelte'
3
3
  </script>
4
4
 
5
5
  <script lang="ts">
@@ -7,8 +7,12 @@
7
7
  * SvTabs - a WAI-ARIA tabs widget with roving tabindex + arrow-key navigation.
8
8
  * Parity: Smart `smart-tabs`. Controlled via `value` + `onChange`; the active
9
9
  * panel is rendered through the `panel` snippet (receives the active id).
10
+ *
11
+ * The behavior (roving focus, activation, ARIA) lives in the headless
12
+ * `createTabs` core; this component is just one styled renderer over it.
10
13
  */
11
14
  import type { Snippet } from 'svelte'
15
+ import { createTabs, type TabItem } from './createTabs.svelte'
12
16
 
13
17
  type Props = {
14
18
  tabs: ReadonlyArray<TabItem>
@@ -34,58 +38,41 @@
34
38
  ariaLabel,
35
39
  }: Props = $props()
36
40
 
37
- const active = $derived(value ?? tabs.find((t) => !t.disabled)?.id ?? tabs[0]?.id ?? '')
38
- const enabled = $derived(tabs.filter((t) => !t.disabled))
39
- let tablistEl: HTMLDivElement | null = null
41
+ const tabsCtl = createTabs({
42
+ tabs: () => tabs,
43
+ value: () => value,
44
+ onChange: (id) => { value = id; onChange?.(id) },
45
+ orientation: () => orientation,
46
+ activation: () => activation,
47
+ ariaLabel: () => ariaLabel,
48
+ })
49
+ const active = $derived(tabsCtl.activeId)
40
50
 
41
- function select(id: string) {
42
- const t = tabs.find((x) => x.id === id)
43
- if (!t || t.disabled) return
44
- value = id
45
- onChange?.(id)
46
- }
47
- function focusTab(id: string) {
48
- queueMicrotask(() => tablistEl?.querySelector<HTMLElement>(`[data-tab="${id}"]`)?.focus())
49
- }
50
- function onKeydown(e: KeyboardEvent) {
51
- const idx = enabled.findIndex((t) => t.id === active)
52
- let next: string | null = null
53
- const fwd = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'
54
- const back = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'
55
- if (e.key === fwd) next = enabled[(idx + 1) % enabled.length]?.id ?? null
56
- else if (e.key === back) next = enabled[(idx - 1 + enabled.length) % enabled.length]?.id ?? null
57
- else if (e.key === 'Home') next = enabled[0]?.id ?? null
58
- else if (e.key === 'End') next = enabled.at(-1)?.id ?? null
59
- else if ((e.key === 'Enter' || e.key === ' ') && activation === 'manual') { e.preventDefault(); select(active); return }
60
- else return
61
- if (next) {
62
- e.preventDefault()
63
- focusTab(next)
64
- if (activation === 'automatic') select(next)
51
+ // DOM focus movement is a render concern: follow the core's roving focus target.
52
+ let tablistEl: HTMLDivElement | null = null
53
+ let lastFocusTick = 0
54
+ $effect(() => {
55
+ if (tabsCtl.focusTick !== lastFocusTick) {
56
+ lastFocusTick = tabsCtl.focusTick
57
+ const fid = tabsCtl.focusId
58
+ queueMicrotask(() => tablistEl?.querySelector<HTMLElement>(`[data-tab="${fid}"]`)?.focus())
65
59
  }
66
- }
60
+ })
67
61
  </script>
68
62
 
69
63
  <div class="sv-tabs sv-tabs--{orientation} sv-tabs--{variant}">
70
- <div bind:this={tablistEl} class="sv-tabs__list" role="tablist" aria-orientation={orientation} aria-label={ariaLabel}>
64
+ <div bind:this={tablistEl} class="sv-tabs__list" {...tabsCtl.tablistProps()}>
71
65
  {#each tabs as tab (tab.id)}
72
66
  <button
73
67
  type="button"
74
68
  class="sv-tabs__tab"
75
69
  class:is-active={tab.id === active}
76
- role="tab"
77
- data-tab={tab.id}
78
- aria-selected={tab.id === active}
79
- aria-controls={`panel-${tab.id}`}
80
- tabindex={tab.id === active ? 0 : -1}
81
- disabled={tab.disabled}
82
- onclick={() => select(tab.id)}
83
- onkeydown={onKeydown}
70
+ {...tabsCtl.tabProps(tab.id)}
84
71
  >{tab.label}</button>
85
72
  {/each}
86
73
  </div>
87
74
  {#if panel}
88
- <div class="sv-tabs__panel" role="tabpanel" id={`panel-${active}`} tabindex="0">
75
+ <div class="sv-tabs__panel" {...tabsCtl.panelProps(active)}>
89
76
  {@render panel(active)}
90
77
  </div>
91
78
  {/if}
@@ -1,14 +1,14 @@
1
- export type TabItem = {
2
- id: string;
3
- label: string;
4
- disabled?: boolean;
5
- };
1
+ export type { TabItem } from './createTabs.svelte';
6
2
  /**
7
3
  * SvTabs - a WAI-ARIA tabs widget with roving tabindex + arrow-key navigation.
8
4
  * Parity: Smart `smart-tabs`. Controlled via `value` + `onChange`; the active
9
5
  * panel is rendered through the `panel` snippet (receives the active id).
6
+ *
7
+ * The behavior (roving focus, activation, ARIA) lives in the headless
8
+ * `createTabs` core; this component is just one styled renderer over it.
10
9
  */
11
10
  import type { Snippet } from 'svelte';
11
+ import { type TabItem } from './createTabs.svelte';
12
12
  type Props = {
13
13
  tabs: ReadonlyArray<TabItem>;
14
14
  value?: string;