svelte-comp 1.3.3 → 1.3.6

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 (138) hide show
  1. package/LICENSE.md +21 -21
  2. package/README.md +101 -100
  3. package/dist/App.svelte +507 -507
  4. package/dist/Container.svelte +59 -59
  5. package/dist/app.css +234 -235
  6. package/dist/app.d.ts +10 -0
  7. package/dist/lib/Accordion.svelte +155 -155
  8. package/dist/lib/Badge.svelte +44 -44
  9. package/dist/lib/Button.svelte +185 -170
  10. package/dist/lib/Calendar.svelte +384 -384
  11. package/dist/lib/Card.svelte +103 -103
  12. package/dist/lib/Carousel.svelte +293 -293
  13. package/dist/lib/Carousel.svelte.d.ts +1 -1
  14. package/dist/lib/CheckBox.svelte +210 -210
  15. package/dist/lib/CodeView.svelte +308 -307
  16. package/dist/lib/ColorPicker.svelte +159 -159
  17. package/dist/lib/ContextMenu.svelte +328 -322
  18. package/dist/lib/DatePicker.svelte +246 -246
  19. package/dist/lib/Dialog.svelte +233 -233
  20. package/dist/lib/Field.svelte +299 -299
  21. package/dist/lib/FilePicker.svelte +295 -240
  22. package/dist/lib/FilePicker.svelte.d.ts +6 -1
  23. package/dist/lib/Form.svelte +438 -438
  24. package/dist/lib/Hamburger.svelte +217 -217
  25. package/dist/lib/InstallPWA.svelte +94 -94
  26. package/dist/lib/Menu.svelte +623 -623
  27. package/dist/lib/NoticeBase.svelte +140 -140
  28. package/dist/lib/PaginatedCard.svelte +73 -73
  29. package/dist/lib/Pagination.svelte +119 -119
  30. package/dist/lib/PrimaryColorSelect.svelte +111 -111
  31. package/dist/lib/ProgressBar.svelte +141 -141
  32. package/dist/lib/ProgressCircle.svelte +190 -190
  33. package/dist/lib/Radio.svelte +189 -189
  34. package/dist/lib/SearchInput.svelte +104 -104
  35. package/dist/lib/Select.svelte +524 -524
  36. package/dist/lib/Slider.svelte +253 -253
  37. package/dist/lib/Splitter.svelte +159 -150
  38. package/dist/lib/Switch.svelte +168 -167
  39. package/dist/lib/Table.svelte +299 -299
  40. package/dist/lib/Tabs.svelte +213 -213
  41. package/dist/lib/ThemeToggle.svelte +128 -127
  42. package/dist/lib/TimePicker.svelte +312 -312
  43. package/dist/lib/TimePickerNew.svelte +634 -0
  44. package/dist/lib/TimePickerNew.svelte.d.ts +49 -0
  45. package/dist/lib/Toast.svelte +123 -123
  46. package/dist/lib/Tooltip.svelte +110 -110
  47. package/dist/lib/Topbar.svelte +107 -107
  48. package/dist/lib/__tests__/Accordion.test.d.ts +1 -0
  49. package/dist/lib/__tests__/Accordion.test.js +171 -0
  50. package/dist/lib/__tests__/Badge.test.d.ts +1 -0
  51. package/dist/lib/__tests__/Badge.test.js +41 -0
  52. package/dist/lib/__tests__/Button.test.d.ts +1 -0
  53. package/dist/lib/__tests__/Button.test.js +269 -0
  54. package/dist/lib/__tests__/Calendar.test.d.ts +1 -0
  55. package/dist/lib/__tests__/Calendar.test.js +171 -0
  56. package/dist/lib/__tests__/Card.test.d.ts +1 -0
  57. package/dist/lib/__tests__/Card.test.js +148 -0
  58. package/dist/lib/__tests__/Carousel.test.d.ts +1 -0
  59. package/dist/lib/__tests__/Carousel.test.js +439 -0
  60. package/dist/lib/__tests__/CheckBox.test.d.ts +1 -0
  61. package/dist/lib/__tests__/CheckBox.test.js +152 -0
  62. package/dist/lib/__tests__/CodeView.test.d.ts +1 -0
  63. package/dist/lib/__tests__/CodeView.test.js +157 -0
  64. package/dist/lib/__tests__/ColorPicker.test.d.ts +1 -0
  65. package/dist/lib/__tests__/ColorPicker.test.js +93 -0
  66. package/dist/lib/__tests__/ContextMenu.test.d.ts +1 -0
  67. package/dist/lib/__tests__/ContextMenu.test.js +67 -0
  68. package/dist/lib/__tests__/DatePicker.test.d.ts +1 -0
  69. package/dist/lib/__tests__/DatePicker.test.js +108 -0
  70. package/dist/lib/__tests__/Dialog.test.d.ts +1 -0
  71. package/dist/lib/__tests__/Dialog.test.js +183 -0
  72. package/dist/lib/__tests__/Field.test.d.ts +1 -0
  73. package/dist/lib/__tests__/Field.test.js +190 -0
  74. package/dist/lib/__tests__/FilePicker.test.d.ts +1 -0
  75. package/dist/lib/__tests__/FilePicker.test.js +179 -0
  76. package/dist/lib/__tests__/Form.integration.test.d.ts +1 -0
  77. package/dist/lib/__tests__/Form.integration.test.js +158 -0
  78. package/dist/lib/__tests__/Form.test.d.ts +1 -0
  79. package/dist/lib/__tests__/Form.test.js +463 -0
  80. package/dist/lib/__tests__/Hamburger.test.d.ts +1 -0
  81. package/dist/lib/__tests__/Hamburger.test.js +161 -0
  82. package/dist/lib/__tests__/InstallPWA.test.d.ts +1 -0
  83. package/dist/lib/__tests__/InstallPWA.test.js +15 -0
  84. package/dist/lib/__tests__/Menu.test.d.ts +1 -0
  85. package/dist/lib/__tests__/Menu.test.js +285 -0
  86. package/dist/lib/__tests__/NoticeBase.test.d.ts +1 -0
  87. package/dist/lib/__tests__/NoticeBase.test.js +60 -0
  88. package/dist/lib/__tests__/PaginatedCard.test.d.ts +1 -0
  89. package/dist/lib/__tests__/PaginatedCard.test.js +89 -0
  90. package/dist/lib/__tests__/Pagination.test.d.ts +1 -0
  91. package/dist/lib/__tests__/Pagination.test.js +168 -0
  92. package/dist/lib/__tests__/PrimaryColorSelect.test.d.ts +1 -0
  93. package/dist/lib/__tests__/PrimaryColorSelect.test.js +92 -0
  94. package/dist/lib/__tests__/ProgressBar.test.d.ts +1 -0
  95. package/dist/lib/__tests__/ProgressBar.test.js +69 -0
  96. package/dist/lib/__tests__/ProgressCircle.test.d.ts +1 -0
  97. package/dist/lib/__tests__/ProgressCircle.test.js +71 -0
  98. package/dist/lib/__tests__/Radio.test.d.ts +1 -0
  99. package/dist/lib/__tests__/Radio.test.js +127 -0
  100. package/dist/lib/__tests__/SearchInput.test.d.ts +1 -0
  101. package/dist/lib/__tests__/SearchInput.test.js +80 -0
  102. package/dist/lib/__tests__/Select.test.d.ts +1 -0
  103. package/dist/lib/__tests__/Select.test.js +408 -0
  104. package/dist/lib/__tests__/Slider.test.d.ts +1 -0
  105. package/dist/lib/__tests__/Slider.test.js +213 -0
  106. package/dist/lib/__tests__/Splitter.test.d.ts +1 -0
  107. package/dist/lib/__tests__/Splitter.test.js +87 -0
  108. package/dist/lib/__tests__/Switch.test.d.ts +1 -0
  109. package/dist/lib/__tests__/Switch.test.js +97 -0
  110. package/dist/lib/__tests__/Table.test.d.ts +1 -0
  111. package/dist/lib/__tests__/Table.test.js +349 -0
  112. package/dist/lib/__tests__/Tabs.test.d.ts +1 -0
  113. package/dist/lib/__tests__/Tabs.test.js +262 -0
  114. package/dist/lib/__tests__/ThemeToggle.test.d.ts +1 -0
  115. package/dist/lib/__tests__/ThemeToggle.test.js +84 -0
  116. package/dist/lib/__tests__/TimePicker.test.d.ts +1 -0
  117. package/dist/lib/__tests__/TimePicker.test.js +146 -0
  118. package/dist/lib/__tests__/TimePickerNew.test.d.ts +1 -0
  119. package/dist/lib/__tests__/TimePickerNew.test.js +322 -0
  120. package/dist/lib/__tests__/Toast.test.d.ts +1 -0
  121. package/dist/lib/__tests__/Toast.test.js +135 -0
  122. package/dist/lib/__tests__/Tooltip.test.d.ts +1 -0
  123. package/dist/lib/__tests__/Tooltip.test.js +171 -0
  124. package/dist/lib/__tests__/Topbar.test.d.ts +1 -0
  125. package/dist/lib/__tests__/Topbar.test.js +25 -0
  126. package/dist/lib/__tests__/setupLangContext.d.ts +1 -0
  127. package/dist/lib/__tests__/setupLangContext.js +65 -0
  128. package/dist/lib/__tests__/storage.test.d.ts +1 -0
  129. package/dist/lib/__tests__/storage.test.js +124 -0
  130. package/dist/lib/__tests__/utils.test.d.ts +1 -0
  131. package/dist/lib/__tests__/utils.test.js +11 -0
  132. package/dist/lib/index.d.ts +1 -0
  133. package/dist/lib/index.js +1 -0
  134. package/dist/lib/lang.d.ts +4 -0
  135. package/dist/lib/lang.js +4 -0
  136. package/dist/styles.css +234 -232
  137. package/dist/utils/index.js +15 -4
  138. package/package.json +52 -52
@@ -1,150 +1,159 @@
1
- <!-- src/lib/Splitter.svelte -->
2
- <script lang="ts">
3
- /**
4
- * @component Splitter
5
- * @description Resizable split panel container with horizontal or vertical orientation.
6
- *
7
- * @prop direction {'horizontal' | 'vertical'} - Split orientation
8
- * @options horizontal|vertical
9
- * @default horizontal
10
- *
11
- * @prop initialSize {number} - Initial size of the first panel as percentage
12
- * @default 30
13
- *
14
- * @prop dividerSize {number} - Width/height of the divider handle in pixels
15
- * @default 4
16
- *
17
- * @prop minSize {number} - Minimum size of the first panel as percentage
18
- * @default 10
19
- *
20
- * @prop maxSize {number} - Maximum size of the first panel as percentage
21
- * @default 90
22
- *
23
- * @prop first {Snippet} - Content for the first panel
24
- *
25
- * @prop second {Snippet} - Content for the second panel
26
- *
27
- * @note Uses pointer events for smooth dragging with proper event delegation
28
- * @note Responsive - automatically adjusts to container resize
29
- * @note Accessible with proper cursor hints and hover states
30
- * @note No wrapper elements - panels render directly for clean DOM structure
31
- * @note Supports any content type through snippet rendering
32
- */
33
-
34
- import type { Snippet } from "svelte";
35
-
36
- type Props = {
37
- direction?: "horizontal" | "vertical";
38
- initialSize?: number;
39
- dividerSize?: number;
40
- minSize?: number;
41
- maxSize?: number;
42
- first?: Snippet;
43
- second?: Snippet;
44
- };
45
-
46
- let {
47
- direction = "horizontal",
48
- initialSize = 30,
49
- dividerSize = 4,
50
- minSize = 10,
51
- maxSize = 90,
52
- first,
53
- second,
54
- }: Props = $props();
55
-
56
- let container: HTMLElement;
57
-
58
- let size = $derived(initialSize);
59
- let isDragging = $state(false);
60
- let startSize = 0;
61
- let startPos = 0;
62
- let containerSize = 0;
63
-
64
- function getContainerSize(): number {
65
- if (!container) return 0;
66
- return direction === "horizontal"
67
- ? container.offsetWidth
68
- : container.offsetHeight;
69
- }
70
-
71
- function startDrag(e: PointerEvent): void {
72
- isDragging = true;
73
- startSize = size;
74
- startPos = direction === "horizontal" ? e.clientX : e.clientY;
75
- containerSize = getContainerSize();
76
-
77
- document.addEventListener("pointermove", onDrag);
78
- document.addEventListener("pointerup", stopDrag);
79
- document.addEventListener("pointercancel", stopDrag);
80
-
81
- e.preventDefault();
82
- e.stopPropagation();
83
- }
84
-
85
- function onDrag(e: PointerEvent): void {
86
- if (!isDragging) return;
87
-
88
- const currentPos = direction === "horizontal" ? e.clientX : e.clientY;
89
- const delta = currentPos - startPos;
90
- const deltaPercent = (delta / containerSize) * 100;
91
- const newSize = startSize + deltaPercent;
92
-
93
- size = Math.max(minSize, Math.min(maxSize, newSize));
94
-
95
- e.preventDefault();
96
- e.stopPropagation();
97
- }
98
-
99
- function stopDrag(): void {
100
- isDragging = false;
101
- document.removeEventListener("pointermove", onDrag);
102
- document.removeEventListener("pointerup", stopDrag);
103
- document.removeEventListener("pointercancel", stopDrag);
104
- }
105
-
106
- $effect(() => {
107
- const onResize = () => {
108
- containerSize = getContainerSize();
109
- };
110
- window.addEventListener("resize", onResize);
111
- return () => window.removeEventListener("resize", onResize);
112
- });
113
- </script>
114
-
115
- <div
116
- bind:this={container}
117
- class="w-full h-full overflow-hidden"
118
- class:flex={direction === "horizontal"}
119
- class:flex-col={direction === "vertical"}
120
- >
121
- {#if direction === "horizontal"}
122
- <div class="overflow-auto min-w-0 min-h-0" style="width: {size}%">
123
- {@render first?.()}
124
- </div>
125
-
126
- <div
127
- class="touch-none select-none z-10 cursor-col-resize bg-[var(--color-bg-muted)] hover:bg-[var(--color-bg-hover)] transition-colors"
128
- style="width: {dividerSize}px"
129
- onpointerdown={startDrag}
130
- ></div>
131
-
132
- <div class="overflow-auto min-w-0 min-h-0 flex-1">
133
- {@render second?.()}
134
- </div>
135
- {:else}
136
- <div class="overflow-auto min-w-0 min-h-0" style="height: {size}%">
137
- {@render first?.()}
138
- </div>
139
-
140
- <div
141
- class="touch-none select-none z-10 cursor-row-resize bg-[var(--color-bg-muted)] hover:bg-[var(--color-bg-hover)] transition-colors"
142
- style="height: {dividerSize}px"
143
- onpointerdown={startDrag}
144
- ></div>
145
-
146
- <div class="overflow-auto min-w-0 min-h-0" style="height: calc(100% - {size}% - {dividerSize}px)">
147
- {@render second?.()}
148
- </div>
149
- {/if}
150
- </div>
1
+ <!-- src/lib/Splitter.svelte -->
2
+ <script lang="ts">
3
+ /**
4
+ * @component Splitter
5
+ * @description Resizable split panel container with horizontal or vertical orientation.
6
+ *
7
+ * @prop direction {'horizontal' | 'vertical'} - Split orientation
8
+ * @options horizontal|vertical
9
+ * @default horizontal
10
+ *
11
+ * @prop initialSize {number} - Initial size of the first panel as percentage
12
+ * @default 30
13
+ *
14
+ * @prop dividerSize {number} - Width/height of the divider handle in pixels
15
+ * @default 4
16
+ *
17
+ * @prop minSize {number} - Minimum size of the first panel as percentage
18
+ * @default 10
19
+ *
20
+ * @prop maxSize {number} - Maximum size of the first panel as percentage
21
+ * @default 90
22
+ *
23
+ * @prop first {Snippet} - Content for the first panel
24
+ *
25
+ * @prop second {Snippet} - Content for the second panel
26
+ *
27
+ * @note Uses pointer events for smooth dragging with proper event delegation
28
+ * @note Responsive - automatically adjusts to container resize
29
+ * @note Accessible with proper cursor hints and hover states
30
+ * @note No wrapper elements - panels render directly for clean DOM structure
31
+ * @note Supports any content type through snippet rendering
32
+ */
33
+
34
+ import type { Snippet } from "svelte";
35
+
36
+ type Props = {
37
+ direction?: "horizontal" | "vertical";
38
+ initialSize?: number;
39
+ dividerSize?: number;
40
+ minSize?: number;
41
+ maxSize?: number;
42
+ first?: Snippet;
43
+ second?: Snippet;
44
+ };
45
+
46
+ let {
47
+ direction = "horizontal",
48
+ initialSize = 30,
49
+ dividerSize = 4,
50
+ minSize = 10,
51
+ maxSize = 90,
52
+ first,
53
+ second,
54
+ }: Props = $props();
55
+
56
+ let container: HTMLElement;
57
+
58
+ let size = $derived(initialSize);
59
+ let isDragging = $state(false);
60
+ let startSize = 0;
61
+ let startPos = 0;
62
+ let containerSize = 0;
63
+
64
+ function getContainerSize(): number {
65
+ if (!container) return 0;
66
+ return direction === "horizontal"
67
+ ? container.offsetWidth
68
+ : container.offsetHeight;
69
+ }
70
+
71
+ function startDrag(e: PointerEvent): void {
72
+ isDragging = true;
73
+ startSize = size;
74
+ startPos = direction === "horizontal" ? e.clientX : e.clientY;
75
+ containerSize = getContainerSize();
76
+
77
+ document.addEventListener("pointermove", onDrag);
78
+ document.addEventListener("pointerup", stopDrag);
79
+ document.addEventListener("pointercancel", stopDrag);
80
+
81
+ e.preventDefault();
82
+ e.stopPropagation();
83
+ }
84
+
85
+ function onDrag(e: PointerEvent): void {
86
+ if (!isDragging || containerSize <= 0) return;
87
+
88
+ const currentPos = direction === "horizontal" ? e.clientX : e.clientY;
89
+ const delta = currentPos - startPos;
90
+ const deltaPercent = (delta / containerSize) * 100;
91
+ const newSize = startSize + deltaPercent;
92
+
93
+ size = Math.max(minSize, Math.min(maxSize, newSize));
94
+
95
+ e.preventDefault();
96
+ e.stopPropagation();
97
+ }
98
+
99
+ function stopDrag(): void {
100
+ isDragging = false;
101
+ document.removeEventListener("pointermove", onDrag);
102
+ document.removeEventListener("pointerup", stopDrag);
103
+ document.removeEventListener("pointercancel", stopDrag);
104
+ }
105
+
106
+ $effect(() => {
107
+ const onResize = () => {
108
+ containerSize = getContainerSize();
109
+ };
110
+ window.addEventListener("resize", onResize);
111
+ return () => window.removeEventListener("resize", onResize);
112
+ });
113
+ </script>
114
+
115
+ <div
116
+ bind:this={container}
117
+ class="w-full h-full overflow-hidden"
118
+ class:flex={direction === "horizontal"}
119
+ class:flex-col={direction === "vertical"}
120
+ >
121
+ {#if direction === "horizontal"}
122
+ <div class="overflow-auto min-w-0 min-h-0" style="width: {size}%">
123
+ {@render first?.()}
124
+ </div>
125
+
126
+ <div
127
+ role="separator"
128
+ aria-orientation="vertical"
129
+ aria-label="Resize handle"
130
+ class="touch-none select-none z-10 cursor-col-resize bg-[var(--color-bg-muted)] hover:bg-[var(--color-bg-hover)] transition-colors [@media(pointer:coarse)]:min-w-11"
131
+ style="width: {dividerSize}px; min-width: {dividerSize}px"
132
+ onpointerdown={startDrag}
133
+ ></div>
134
+
135
+ <div class="overflow-auto min-w-0 min-h-0 flex-1">
136
+ {@render second?.()}
137
+ </div>
138
+ {:else}
139
+ <div class="overflow-auto min-w-0 min-h-0" style="height: {size}%">
140
+ {@render first?.()}
141
+ </div>
142
+
143
+ <div
144
+ role="separator"
145
+ aria-orientation="horizontal"
146
+ aria-label="Resize handle"
147
+ class="touch-none select-none z-10 cursor-row-resize bg-[var(--color-bg-muted)] hover:bg-[var(--color-bg-hover)] transition-colors [@media(pointer:coarse)]:min-h-11"
148
+ style="height: {dividerSize}px; min-height: {dividerSize}px"
149
+ onpointerdown={startDrag}
150
+ ></div>
151
+
152
+ <div
153
+ class="overflow-auto min-w-0 min-h-0"
154
+ style="height: calc(100% - {size}% - {dividerSize}px)"
155
+ >
156
+ {@render second?.()}
157
+ </div>
158
+ {/if}
159
+ </div>
@@ -1,167 +1,168 @@
1
- <!-- src/lib/Switch.svelte -->
2
- <script lang="ts">
3
- /**
4
- * @component Switch
5
- * @description A compact toggle switch component built on top of a native `<input type="checkbox">`. Supports optional labels around the control and works naturally with `bind:checked`.
6
- *
7
- * @prop sz {SizeKey} - Size preset for the control
8
- * @options xs|sm|md|lg|xl
9
- * @default md
10
- *
11
- * @prop checked {boolean} - Current state (bindable)
12
- * @default false
13
- *
14
- * @prop leftLabel {string} - Optional label rendered on the left side
15
- *
16
- * @prop rightLabel {string} - Optional label rendered on the right side
17
- *
18
- * @prop topLabel {string} - Optional label placed above the switch
19
- *
20
- * @prop onChange {(v: boolean) => void} - Fired on toggle with the new value
21
- *
22
- * @prop class {string} - External wrapper classes
23
- * @default ""
24
- *
25
- * @note Built over a real checkbox so browser accessibility comes for free: keyboard (Space/Enter), focus ring, and screen reader semantics.
26
- * @note Labels do not affect the actual checkbox hitbox, but the whole area is clickable if wrapped correctly.
27
- * @note Size preset adjusts track width, knob size, and spacing.
28
- * @note Reflects `disabled` by dimming visuals and removing pointer events.
29
- * @note The component keeps no internal state besides the bound `checked` value, so it's predictable in forms and controlled UI.
30
- */
31
- import type { HTMLInputAttributes } from "svelte/elements";
32
- import type { SizeKey } from "./types";
33
- import { TEXT } from "./types";
34
- import { cx, uid } from "../utils";
35
-
36
- type Props = HTMLInputAttributes & {
37
- sz?: SizeKey;
38
- checked?: boolean;
39
- leftLabel?: string;
40
- rightLabel?: string;
41
- topLabel?: string;
42
- onChange?: (v: boolean) => void;
43
- class?: string;
44
- };
45
-
46
- let {
47
- sz = "md",
48
- checked = $bindable(false),
49
- leftLabel,
50
- rightLabel,
51
- topLabel,
52
- onChange,
53
- class: externalClass = "",
54
- ...rest
55
- }: Props = $props();
56
-
57
- const inputId = $derived(rest.id ?? uid("sw-"));
58
-
59
- const track = {
60
- xs: "w-8 h-4",
61
- sm: "w-10 h-5",
62
- md: "w-12 h-6",
63
- lg: "w-14 h-7",
64
- xl: "w-16 h-8",
65
- } as const;
66
-
67
- const knob = {
68
- xs: "h-[14px] w-[14px]",
69
- sm: "h-[17px] w-[17px]",
70
- md: "h-5 w-5",
71
- lg: "h-6 w-6",
72
- xl: "h-7 w-7",
73
- } as const;
74
-
75
- const pad = {
76
- xs: "p-[1px]",
77
- sm: "p-[1.5px]",
78
- md: "p-[2px]",
79
- lg: "p-[2px]",
80
- xl: "p-[2px]",
81
- } as const;
82
-
83
- const isDisabled = $derived(
84
- "disabled" in rest ? Boolean(rest.disabled) : false
85
- );
86
-
87
- function toggle() {
88
- if (isDisabled) return;
89
- checked = !checked;
90
- onChange?.(checked);
91
- }
92
-
93
- const justifyClass = $derived(checked ? "justify-end" : "justify-start");
94
-
95
- const trackClass = $derived(
96
- cx(
97
- "relative inline-flex items-center rounded-full border transition focus:outline-none focus:ring-2 focus:ring-[var(--border-color-focus)]",
98
- track[sz],
99
- pad[sz],
100
- checked
101
- ? "bg-[var(--color-bg-primary)] border-[var(--color-bg-primary)]"
102
- : "bg-[var(--color-bg-muted)] border-[var(--border-color-default)]",
103
- isDisabled
104
- ? "opacity-[var(--opacity-disabled)] cursor-not-allowed"
105
- : "cursor-pointer",
106
- justifyClass
107
- )
108
- );
109
-
110
- const rootClass = $derived(
111
- cx("inline-flex flex-col items-center gap-1 select-none", externalClass)
112
- );
113
-
114
- const knobClass = $derived(
115
- cx(
116
- knob[sz],
117
- "rounded-full bg-white border border-[var(--border-color-default)] shadow-sm"
118
- )
119
- );
120
- </script>
121
-
122
- <label class={rootClass} for={inputId}>
123
- {#if topLabel}
124
- <span class="text-[var(--color-text-muted)] {TEXT[sz]}">
125
- {topLabel}
126
- </span>
127
- {/if}
128
-
129
- <div class="inline-flex items-center gap-2">
130
- {#if leftLabel}
131
- <span class="text-[var(--color-text-muted)] {TEXT[sz]}">
132
- {leftLabel}
133
- </span>
134
- {/if}
135
-
136
- <input
137
- id={inputId}
138
- type="checkbox"
139
- {checked}
140
- {...rest}
141
- class="sr-only"
142
- aria-checked={checked}
143
- aria-invalid={rest["aria-invalid"] || undefined}
144
- aria-describedby={rest["aria-describedby"]}
145
- onchange={() => toggle()}
146
- />
147
-
148
- <button
149
- type="button"
150
- class={trackClass}
151
- onclick={toggle}
152
- aria-pressed={checked}
153
- aria-label={typeof rest["aria-label"] === "string"
154
- ? rest["aria-label"]
155
- : "Switch"}
156
- disabled={isDisabled}
157
- >
158
- <span class={knobClass}></span>
159
- </button>
160
-
161
- {#if rightLabel}
162
- <span class="text-[var(--color-text-muted)] {TEXT[sz]}">
163
- {rightLabel}
164
- </span>
165
- {/if}
166
- </div>
167
- </label>
1
+ <!-- src/lib/Switch.svelte -->
2
+ <script lang="ts">
3
+ /**
4
+ * @component Switch
5
+ * @description A compact toggle switch component built on top of a native `<input type="checkbox">`. Supports optional labels around the control and works naturally with `bind:checked`.
6
+ *
7
+ * @prop sz {SizeKey} - Size preset for the control
8
+ * @options xs|sm|md|lg|xl
9
+ * @default md
10
+ *
11
+ * @prop checked {boolean} - Current state (bindable)
12
+ * @default false
13
+ *
14
+ * @prop leftLabel {string} - Optional label rendered on the left side
15
+ *
16
+ * @prop rightLabel {string} - Optional label rendered on the right side
17
+ *
18
+ * @prop topLabel {string} - Optional label placed above the switch
19
+ *
20
+ * @prop onChange {(v: boolean) => void} - Fired on toggle with the new value
21
+ *
22
+ * @prop class {string} - External wrapper classes
23
+ * @default ""
24
+ *
25
+ * @note Built over a real checkbox so browser accessibility comes for free: keyboard (Space/Enter), focus ring, and screen reader semantics.
26
+ * @note Labels do not affect the actual checkbox hitbox, but the whole area is clickable if wrapped correctly.
27
+ * @note Size preset adjusts track width, knob size, and spacing.
28
+ * @note Reflects `disabled` by dimming visuals and removing pointer events.
29
+ * @note The component keeps no internal state besides the bound `checked` value, so it's predictable in forms and controlled UI.
30
+ */
31
+ import type { HTMLInputAttributes } from "svelte/elements";
32
+ import type { SizeKey } from "./types";
33
+ import { TEXT } from "./types";
34
+ import { cx, uid } from "../utils";
35
+
36
+ type Props = HTMLInputAttributes & {
37
+ sz?: SizeKey;
38
+ checked?: boolean;
39
+ leftLabel?: string;
40
+ rightLabel?: string;
41
+ topLabel?: string;
42
+ onChange?: (v: boolean) => void;
43
+ class?: string;
44
+ };
45
+
46
+ let {
47
+ sz = "md",
48
+ checked = $bindable(false),
49
+ leftLabel,
50
+ rightLabel,
51
+ topLabel,
52
+ onChange,
53
+ class: externalClass = "",
54
+ ...rest
55
+ }: Props = $props();
56
+
57
+ const inputId = $derived(rest.id ?? uid("sw-"));
58
+
59
+ const track = {
60
+ xs: "w-8 h-4",
61
+ sm: "w-10 h-5",
62
+ md: "w-12 h-6",
63
+ lg: "w-14 h-7",
64
+ xl: "w-16 h-8",
65
+ } as const;
66
+
67
+ const knob = {
68
+ xs: "h-[14px] w-[14px]",
69
+ sm: "h-[17px] w-[17px]",
70
+ md: "h-5 w-5",
71
+ lg: "h-6 w-6",
72
+ xl: "h-7 w-7",
73
+ } as const;
74
+
75
+ const pad = {
76
+ xs: "p-[1px]",
77
+ sm: "p-[1.5px]",
78
+ md: "p-[2px]",
79
+ lg: "p-[2px]",
80
+ xl: "p-[2px]",
81
+ } as const;
82
+
83
+ const isDisabled = $derived(
84
+ "disabled" in rest ? Boolean(rest.disabled) : false
85
+ );
86
+
87
+ function toggle() {
88
+ if (isDisabled) return;
89
+ checked = !checked;
90
+ onChange?.(checked);
91
+ }
92
+
93
+ const justifyClass = $derived(checked ? "justify-end" : "justify-start");
94
+
95
+ const trackClass = $derived(
96
+ cx(
97
+ "relative inline-flex items-center rounded-full border transition focus:outline-none focus:ring-2 focus:ring-[var(--border-color-focus)]",
98
+ track[sz],
99
+ pad[sz],
100
+ checked
101
+ ? "bg-[var(--color-bg-primary)] border-[var(--color-bg-primary)]"
102
+ : "bg-[var(--color-bg-muted)] border-[var(--border-color-default)]",
103
+ isDisabled
104
+ ? "opacity-[var(--opacity-disabled)] cursor-not-allowed"
105
+ : "cursor-pointer",
106
+ "[@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11",
107
+ justifyClass
108
+ )
109
+ );
110
+
111
+ const rootClass = $derived(
112
+ cx("inline-flex flex-col items-center gap-1 select-none", externalClass)
113
+ );
114
+
115
+ const knobClass = $derived(
116
+ cx(
117
+ knob[sz],
118
+ "rounded-full bg-[var(--color-text-inverse,#fff)] border border-[var(--border-color-default)] shadow-[0_1px_2px_var(--shadow-color)]"
119
+ )
120
+ );
121
+ </script>
122
+
123
+ <label class={rootClass} for={inputId}>
124
+ {#if topLabel}
125
+ <span class={cx("text-[var(--color-text-muted)]", TEXT[sz])}>
126
+ {topLabel}
127
+ </span>
128
+ {/if}
129
+
130
+ <div class="inline-flex items-center gap-2">
131
+ {#if leftLabel}
132
+ <span class={cx("text-[var(--color-text-muted)]", TEXT[sz])}>
133
+ {leftLabel}
134
+ </span>
135
+ {/if}
136
+
137
+ <input
138
+ id={inputId}
139
+ type="checkbox"
140
+ {checked}
141
+ {...rest}
142
+ class="sr-only"
143
+ aria-checked={checked}
144
+ aria-invalid={rest["aria-invalid"] || undefined}
145
+ aria-describedby={rest["aria-describedby"]}
146
+ onchange={() => toggle()}
147
+ />
148
+
149
+ <button
150
+ type="button"
151
+ class={trackClass}
152
+ onclick={toggle}
153
+ aria-pressed={checked}
154
+ aria-label={typeof rest["aria-label"] === "string"
155
+ ? rest["aria-label"]
156
+ : "Switch"}
157
+ disabled={isDisabled}
158
+ >
159
+ <span class={knobClass}></span>
160
+ </button>
161
+
162
+ {#if rightLabel}
163
+ <span class={cx("text-[var(--color-text-muted)]", TEXT[sz])}>
164
+ {rightLabel}
165
+ </span>
166
+ {/if}
167
+ </div>
168
+ </label>