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,299 +1,299 @@
1
- <!-- src/lib/Field.svelte -->
2
- <script lang="ts">
3
- /**
4
- * @component Field
5
- * @description Unified input/textarea field with label, leading/trailing content, clear button, and validation.
6
- *
7
- * @prop as {"input" | "textarea"} - Underlying element to render
8
- * @default "input"
9
- *
10
- * @prop label {string} - Label text rendered above the field
11
- *
12
- * @prop sz {SizeKey} - Size preset for spacing and typography
13
- * @options xs|sm|md|lg|xl
14
- * @default md
15
- *
16
- * @prop variant {FieldVariant} - Visual style variant
17
- * @options default|filled|neutral
18
- * @default default
19
- *
20
- * @prop clearable {boolean} - Shows a clear button for text inputs
21
- * @default true
22
- *
23
- * @prop rows {number} - Row count for textarea mode
24
- * @default 3
25
- *
26
- * @prop parseNumber {boolean} - Coerces numeric input when possible
27
- * @default false
28
- *
29
- * @prop leading {Snippet | string} - Leading content rendered inside the field
30
- *
31
- * @prop trailing {Snippet | string} - Trailing content rendered inside the field
32
- *
33
- * @prop onChange {(val: string | number) => void} - Fired when the value changes
34
- *
35
- * @prop value {string | number} - Controlled field value (bindable)
36
- * @default ""
37
- *
38
- * @prop class {string} - Additional classes applied to the root label
39
- * @default ""
40
- *
41
- * @prop id {string} - Custom id used for label and input linkage
42
- *
43
- * @prop type {string} - Input type when `as="input"`
44
- *
45
- * @prop invalid {boolean} - Marks the field invalid and sets `aria-invalid`
46
- * @default false
47
- *
48
- * @prop describedBy {string} - ID of helper or error text for accessibility
49
- *
50
- * @note `bind:value` is supported; `onChange` receives cast value (`number` when `parseNumber` succeeds, otherwise `string` or `""`).
51
- * @note Clear button appears only for text inputs (not for `type="number"`) and sets value to an empty string.
52
- * @note Automatic padding for leading/trailing content; label is linked via an auto-generated `id`.
53
- * @note Accessibility: sets `aria-invalid`, `aria-describedby`; number inputs also set `inputmode="decimal"`.
54
- */
55
- import type { Snippet } from "svelte";
56
- import type {
57
- HTMLInputAttributes,
58
- HTMLTextareaAttributes,
59
- } from "svelte/elements";
60
- import type { SizeKey, FieldVariant, FieldType } from "./types";
61
- import { TEXT } from "./types";
62
- import { uid, cx } from "../utils";
63
-
64
- type Props = (HTMLInputAttributes & HTMLTextareaAttributes & FieldType) & {
65
- value?: string | number;
66
- };
67
-
68
- let {
69
- as = "input",
70
- label,
71
- sz = "md",
72
- variant = "default",
73
- clearable = true,
74
- rows = 3,
75
- parseNumber = false,
76
- leading,
77
- trailing,
78
- onChange,
79
- value = $bindable<string | number>(""),
80
- class: externalClass = "",
81
- id: externalId,
82
- type,
83
- invalid = false,
84
- describedBy,
85
- ...rest
86
- }: Props = $props();
87
-
88
- const base =
89
- "w-full outline-none transition-colors duration-[var(--transition-fast)] ease-[var(--timing-default)] box-border rounded-[var(--radius-md)] border focus:border-[var(--border-color-focus)] focus:ring-2 focus:ring-[var(--border-color-focus)] disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed";
90
-
91
- const sizes: Record<SizeKey, string> = {
92
- xs: "px-2 h-6",
93
- sm: "px-3 h-7",
94
- md: "px-4 h-8",
95
- lg: "px-5 h-9",
96
- xl: "px-6 h-10",
97
- };
98
-
99
- const variants: Record<FieldVariant, string> = {
100
- default:
101
- "bg-[var(--color-bg-surface)] [color:var(--color-text-default)] placeholder:[color:var(--color-text-muted)] border-[var(--border-color-default)]",
102
- filled:
103
- "bg-[var(--color-bg-muted)] [color:var(--color-text-default)] placeholder:[color:var(--color-text-muted)] border-[var(--border-color-default)]",
104
- neutral:
105
- "bg-transparent [color:var(--color-text-default)] placeholder:[color:var(--color-text-muted)] border-transparent hover:border-[var(--border-color-default)] focus:border-[var(--border-color-focus)]",
106
- };
107
-
108
- const rootClass = $derived(cx("flex flex-col gap-1", externalClass));
109
-
110
- const inputClass = $derived(
111
- as === "textarea"
112
- ? cx(base, variants[variant], TEXT.md, "px-4")
113
- : cx(base, sizes[sz], TEXT[sz], variants[variant])
114
- );
115
-
116
- const containerClass = $derived(
117
- cx("relative flex items-stretch", as === "textarea" && "items-start")
118
- );
119
-
120
- const id = $derived(externalId ?? uid("fld-"));
121
-
122
- const effectiveType = $derived(
123
- as === "input"
124
- ? type || (rest as HTMLInputAttributes).type || "text"
125
- : undefined
126
- );
127
-
128
- const isNumber = $derived(as === "input" && effectiveType === "number");
129
- const isPassword = $derived(as === "input" && effectiveType === "password");
130
-
131
- let showPassword = $state(false);
132
-
133
- function toOutgoing(v: string): string | number {
134
- if (parseNumber && isNumber) {
135
- if (v === "" || v === "-" || v === "." || v === "-.") {
136
- return v;
137
- }
138
- const n = Number(v);
139
- return Number.isNaN(n) ? "" : n;
140
- }
141
- return v;
142
- }
143
-
144
- function handleInput(e: Event) {
145
- const t = e.target as HTMLInputElement | HTMLTextAreaElement;
146
- const rawValue = t.value;
147
- const outgoing = toOutgoing(rawValue);
148
- value = outgoing;
149
- onChange?.(outgoing);
150
- }
151
-
152
- function clear() {
153
- const newValue = parseNumber && isNumber ? 0 : "";
154
- value = newValue;
155
- onChange?.(newValue);
156
- }
157
-
158
- const displayValue = $derived(String(value ?? ""));
159
- const showClear = $derived(
160
- clearable && as === "input" && displayValue.length > 0 && !isNumber
161
- );
162
- const needsRightPad = $derived(Boolean(trailing) || showClear || isPassword);
163
-
164
- const labelColor = "[color:var(--color-text-muted)]";
165
- </script>
166
-
167
- <label for={id} class={rootClass}>
168
- {#if label}
169
- <span class={cx(TEXT[sz], "font-medium", labelColor)}>{label}</span>
170
- {/if}
171
-
172
- <div class={containerClass}>
173
- {#if leading}
174
- {@const leadingIsSnippet = typeof leading === "function"}
175
- <div
176
- class={cx(
177
- "absolute inset-y-0 left-2 flex items-center [color:var(--color-text-muted)]"
178
- )}
179
- aria-hidden="true"
180
- >
181
- {#if leadingIsSnippet}
182
- {@render (leading as Snippet)()}
183
- {:else}
184
- {leading}
185
- {/if}
186
- </div>
187
- {/if}
188
-
189
- {#if as === "textarea"}
190
- <textarea
191
- {...rest}
192
- {id}
193
- {rows}
194
- class={cx(inputClass, needsRightPad && "pr-8", leading && "pl-8")}
195
- value={displayValue}
196
- aria-invalid={invalid || undefined}
197
- aria-describedby={describedBy}
198
- oninput={handleInput}
199
- ></textarea>
200
- {:else}
201
- <input
202
- {...rest}
203
- {id}
204
- type={isPassword ? (showPassword ? "text" : "password") : effectiveType}
205
- inputmode={isNumber ? "decimal" : undefined}
206
- class={cx(inputClass, needsRightPad && "pr-8", leading && "pl-8")}
207
- value={displayValue}
208
- aria-invalid={invalid || undefined}
209
- aria-describedby={describedBy}
210
- oninput={handleInput}
211
- />
212
- {/if}
213
-
214
- <div class={cx("absolute inset-y-0 right-2 flex items-center gap-1")}>
215
- {#if trailing}
216
- {@const trailingIsSnippet = typeof trailing === "function"}
217
- <span aria-hidden="true">
218
- {#if trailingIsSnippet}
219
- {@render (trailing as Snippet)()}
220
- {:else}
221
- {trailing}
222
- {/if}
223
- </span>
224
- {/if}
225
-
226
- {#if isPassword}
227
- <button
228
- type="button"
229
- class={cx("password-toggle [color:var(--color-text-muted)] hover:opacity-[var(--opacity-hover)]")}
230
- onmousedown={(e) => e.preventDefault()}
231
- onclick={() => (showPassword = !showPassword)}
232
- aria-label={showPassword ? "Hide password" : "Show password"}
233
- >
234
- {#if showPassword}
235
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
236
- <path d="M3 3L21 21" stroke="currentColor" stroke-width="2" />
237
- <path
238
- d="M10.5 10.677a2 2 0 002.823 2.823"
239
- stroke="currentColor"
240
- stroke-width="2"
241
- />
242
- <path
243
- d="M7.362 7.561C5.68 8.74 4.279 10.42 3 12c2.4 3.6 6 6 9 6 1.4 0 2.8-.5 4.1-1.2"
244
- stroke="currentColor"
245
- stroke-width="2"
246
- />
247
- <path
248
- d="M12 6c3 0 6 2.4 9 6-.6.9-1.3 1.7-2 2.5"
249
- stroke="currentColor"
250
- stroke-width="2"
251
- />
252
- </svg>
253
- {:else}
254
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
255
- <path
256
- d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
257
- stroke="currentColor"
258
- stroke-width="2"
259
- />
260
- <circle
261
- cx="12"
262
- cy="12"
263
- r="3"
264
- stroke="currentColor"
265
- stroke-width="2"
266
- />
267
- </svg>
268
- {/if}
269
- </button>
270
- {/if}
271
-
272
- {#if showClear}
273
- <button
274
- type="button"
275
- tabindex="-1"
276
- onmousedown={(e) => e.preventDefault()}
277
- class={cx("[color:var(--color-text-muted)] hover:opacity-[var(--opacity-hover)]")}
278
- onclick={clear}
279
- aria-label="Clear"
280
- title="Clear"
281
- >
282
- <svg
283
- width="18"
284
- height="18"
285
- viewBox="0 0 24 24"
286
- fill="none"
287
- stroke="currentColor"
288
- stroke-width="2"
289
- stroke-linecap="round"
290
- stroke-linejoin="round"
291
- >
292
- <line x1="18" y1="6" x2="6" y2="18" />
293
- <line x1="6" y1="6" x2="18" y2="18" />
294
- </svg>
295
- </button>
296
- {/if}
297
- </div>
298
- </div>
299
- </label>
1
+ <!-- src/lib/Field.svelte -->
2
+ <script lang="ts">
3
+ /**
4
+ * @component Field
5
+ * @description Unified input/textarea field with label, leading/trailing content, clear button, and validation.
6
+ *
7
+ * @prop as {"input" | "textarea"} - Underlying element to render
8
+ * @default "input"
9
+ *
10
+ * @prop label {string} - Label text rendered above the field
11
+ *
12
+ * @prop sz {SizeKey} - Size preset for spacing and typography
13
+ * @options xs|sm|md|lg|xl
14
+ * @default md
15
+ *
16
+ * @prop variant {FieldVariant} - Visual style variant
17
+ * @options default|filled|neutral
18
+ * @default default
19
+ *
20
+ * @prop clearable {boolean} - Shows a clear button for text inputs
21
+ * @default true
22
+ *
23
+ * @prop rows {number} - Row count for textarea mode
24
+ * @default 3
25
+ *
26
+ * @prop parseNumber {boolean} - Coerces numeric input when possible
27
+ * @default false
28
+ *
29
+ * @prop leading {Snippet | string} - Leading content rendered inside the field
30
+ *
31
+ * @prop trailing {Snippet | string} - Trailing content rendered inside the field
32
+ *
33
+ * @prop onChange {(val: string | number) => void} - Fired when the value changes
34
+ *
35
+ * @prop value {string | number} - Controlled field value (bindable)
36
+ * @default ""
37
+ *
38
+ * @prop class {string} - Additional classes applied to the root label
39
+ * @default ""
40
+ *
41
+ * @prop id {string} - Custom id used for label and input linkage
42
+ *
43
+ * @prop type {string} - Input type when `as="input"`
44
+ *
45
+ * @prop invalid {boolean} - Marks the field invalid and sets `aria-invalid`
46
+ * @default false
47
+ *
48
+ * @prop describedBy {string} - ID of helper or error text for accessibility
49
+ *
50
+ * @note `bind:value` is supported; `onChange` receives cast value (`number` when `parseNumber` succeeds, otherwise `string` or `""`).
51
+ * @note Clear button appears only for text inputs (not for `type="number"`) and sets value to an empty string.
52
+ * @note Automatic padding for leading/trailing content; label is linked via an auto-generated `id`.
53
+ * @note Accessibility: sets `aria-invalid`, `aria-describedby`; number inputs also set `inputmode="decimal"`.
54
+ */
55
+ import type { Snippet } from "svelte";
56
+ import type {
57
+ HTMLInputAttributes,
58
+ HTMLTextareaAttributes,
59
+ } from "svelte/elements";
60
+ import type { SizeKey, FieldVariant, FieldType } from "./types";
61
+ import { TEXT } from "./types";
62
+ import { uid, cx } from "../utils";
63
+
64
+ type Props = (HTMLInputAttributes & HTMLTextareaAttributes & FieldType) & {
65
+ value?: string | number;
66
+ };
67
+
68
+ let {
69
+ as = "input",
70
+ label,
71
+ sz = "md",
72
+ variant = "default",
73
+ clearable = true,
74
+ rows = 3,
75
+ parseNumber = false,
76
+ leading,
77
+ trailing,
78
+ onChange,
79
+ value = $bindable<string | number>(""),
80
+ class: externalClass = "",
81
+ id: externalId,
82
+ type,
83
+ invalid = false,
84
+ describedBy,
85
+ ...rest
86
+ }: Props = $props();
87
+
88
+ const base =
89
+ "w-full outline-none transition-colors duration-[var(--transition-fast)] ease-[var(--timing-default)] box-border rounded-[var(--radius-md)] border focus:border-[var(--border-color-focus)] focus:ring-2 focus:ring-[var(--border-color-focus)] disabled:opacity-[var(--opacity-disabled)] disabled:cursor-not-allowed [@media(pointer:coarse)]:min-h-11";
90
+
91
+ const sizes: Record<SizeKey, string> = {
92
+ xs: "px-2 h-6",
93
+ sm: "px-3 h-7",
94
+ md: "px-4 h-8",
95
+ lg: "px-5 h-9",
96
+ xl: "px-6 h-10",
97
+ };
98
+
99
+ const variants: Record<FieldVariant, string> = {
100
+ default:
101
+ "bg-[var(--color-bg-surface)] [color:var(--color-text-default)] placeholder:[color:var(--color-text-muted)] border-[var(--border-color-default)]",
102
+ filled:
103
+ "bg-[var(--color-bg-muted)] [color:var(--color-text-default)] placeholder:[color:var(--color-text-muted)] border-[var(--border-color-default)]",
104
+ neutral:
105
+ "bg-transparent [color:var(--color-text-default)] placeholder:[color:var(--color-text-muted)] border-transparent hover:border-[var(--border-color-default)] focus:border-[var(--border-color-focus)]",
106
+ };
107
+
108
+ const rootClass = $derived(cx("flex flex-col gap-1", externalClass));
109
+
110
+ const inputClass = $derived(
111
+ as === "textarea"
112
+ ? cx(base, variants[variant], TEXT.md, "px-4")
113
+ : cx(base, sizes[sz], TEXT[sz], variants[variant])
114
+ );
115
+
116
+ const containerClass = $derived(
117
+ cx("relative flex items-stretch", as === "textarea" && "items-start")
118
+ );
119
+
120
+ const id = $derived(externalId ?? uid("fld-"));
121
+
122
+ const effectiveType = $derived(
123
+ as === "input"
124
+ ? type || (rest as HTMLInputAttributes).type || "text"
125
+ : undefined
126
+ );
127
+
128
+ const isNumber = $derived(as === "input" && effectiveType === "number");
129
+ const isPassword = $derived(as === "input" && effectiveType === "password");
130
+
131
+ let showPassword = $state(false);
132
+
133
+ function toOutgoing(v: string): string | number {
134
+ if (parseNumber && isNumber) {
135
+ if (v === "" || v === "-" || v === "." || v === "-.") {
136
+ return v;
137
+ }
138
+ const n = Number(v);
139
+ return Number.isNaN(n) ? "" : n;
140
+ }
141
+ return v;
142
+ }
143
+
144
+ function handleInput(e: Event) {
145
+ const t = e.target as HTMLInputElement | HTMLTextAreaElement;
146
+ const rawValue = t.value;
147
+ const outgoing = toOutgoing(rawValue);
148
+ value = outgoing;
149
+ onChange?.(outgoing);
150
+ }
151
+
152
+ function clear() {
153
+ const newValue = parseNumber && isNumber ? 0 : "";
154
+ value = newValue;
155
+ onChange?.(newValue);
156
+ }
157
+
158
+ const displayValue = $derived(String(value ?? ""));
159
+ const showClear = $derived(
160
+ clearable && as === "input" && displayValue.length > 0 && !isNumber
161
+ );
162
+ const needsRightPad = $derived(Boolean(trailing) || showClear || isPassword);
163
+
164
+ const labelColor = "[color:var(--color-text-muted)]";
165
+ </script>
166
+
167
+ <label for={id} class={rootClass}>
168
+ {#if label}
169
+ <span class={cx(TEXT[sz], "font-medium", labelColor)}>{label}</span>
170
+ {/if}
171
+
172
+ <div class={containerClass}>
173
+ {#if leading}
174
+ {@const leadingIsSnippet = typeof leading === "function"}
175
+ <div
176
+ class={cx(
177
+ "absolute inset-y-0 left-2 flex items-center [color:var(--color-text-muted)]"
178
+ )}
179
+ aria-hidden="true"
180
+ >
181
+ {#if leadingIsSnippet}
182
+ {@render (leading as Snippet)()}
183
+ {:else}
184
+ {leading}
185
+ {/if}
186
+ </div>
187
+ {/if}
188
+
189
+ {#if as === "textarea"}
190
+ <textarea
191
+ {...rest}
192
+ {id}
193
+ {rows}
194
+ class={cx(inputClass, needsRightPad && "pr-8", leading && "pl-8")}
195
+ value={displayValue}
196
+ aria-invalid={invalid || undefined}
197
+ aria-describedby={describedBy}
198
+ oninput={handleInput}
199
+ ></textarea>
200
+ {:else}
201
+ <input
202
+ {...rest}
203
+ {id}
204
+ type={isPassword ? (showPassword ? "text" : "password") : effectiveType}
205
+ inputmode={isNumber ? "decimal" : undefined}
206
+ class={cx(inputClass, needsRightPad && "pr-8", leading && "pl-8")}
207
+ value={displayValue}
208
+ aria-invalid={invalid || undefined}
209
+ aria-describedby={describedBy}
210
+ oninput={handleInput}
211
+ />
212
+ {/if}
213
+
214
+ <div class={cx("absolute inset-y-0 right-2 flex items-center gap-1")}>
215
+ {#if trailing}
216
+ {@const trailingIsSnippet = typeof trailing === "function"}
217
+ <span aria-hidden="true">
218
+ {#if trailingIsSnippet}
219
+ {@render (trailing as Snippet)()}
220
+ {:else}
221
+ {trailing}
222
+ {/if}
223
+ </span>
224
+ {/if}
225
+
226
+ {#if isPassword}
227
+ <button
228
+ type="button"
229
+ class={cx("password-toggle [color:var(--color-text-muted)] hover:opacity-[var(--opacity-hover)]")}
230
+ onmousedown={(e) => e.preventDefault()}
231
+ onclick={() => (showPassword = !showPassword)}
232
+ aria-label={showPassword ? "Hide password" : "Show password"}
233
+ >
234
+ {#if showPassword}
235
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
236
+ <path d="M3 3L21 21" stroke="currentColor" stroke-width="2" />
237
+ <path
238
+ d="M10.5 10.677a2 2 0 002.823 2.823"
239
+ stroke="currentColor"
240
+ stroke-width="2"
241
+ />
242
+ <path
243
+ d="M7.362 7.561C5.68 8.74 4.279 10.42 3 12c2.4 3.6 6 6 9 6 1.4 0 2.8-.5 4.1-1.2"
244
+ stroke="currentColor"
245
+ stroke-width="2"
246
+ />
247
+ <path
248
+ d="M12 6c3 0 6 2.4 9 6-.6.9-1.3 1.7-2 2.5"
249
+ stroke="currentColor"
250
+ stroke-width="2"
251
+ />
252
+ </svg>
253
+ {:else}
254
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
255
+ <path
256
+ d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
257
+ stroke="currentColor"
258
+ stroke-width="2"
259
+ />
260
+ <circle
261
+ cx="12"
262
+ cy="12"
263
+ r="3"
264
+ stroke="currentColor"
265
+ stroke-width="2"
266
+ />
267
+ </svg>
268
+ {/if}
269
+ </button>
270
+ {/if}
271
+
272
+ {#if showClear}
273
+ <button
274
+ type="button"
275
+ tabindex="-1"
276
+ onmousedown={(e) => e.preventDefault()}
277
+ class={cx("[color:var(--color-text-muted)] hover:opacity-[var(--opacity-hover)]")}
278
+ onclick={clear}
279
+ aria-label="Clear"
280
+ title="Clear"
281
+ >
282
+ <svg
283
+ width="18"
284
+ height="18"
285
+ viewBox="0 0 24 24"
286
+ fill="none"
287
+ stroke="currentColor"
288
+ stroke-width="2"
289
+ stroke-linecap="round"
290
+ stroke-linejoin="round"
291
+ >
292
+ <line x1="18" y1="6" x2="6" y2="18" />
293
+ <line x1="6" y1="6" x2="18" y2="18" />
294
+ </svg>
295
+ </button>
296
+ {/if}
297
+ </div>
298
+ </div>
299
+ </label>