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.
- package/LICENSE.md +21 -21
- package/README.md +101 -100
- package/dist/App.svelte +507 -507
- package/dist/Container.svelte +59 -59
- package/dist/app.css +234 -235
- package/dist/app.d.ts +10 -0
- package/dist/lib/Accordion.svelte +155 -155
- package/dist/lib/Badge.svelte +44 -44
- package/dist/lib/Button.svelte +185 -170
- package/dist/lib/Calendar.svelte +384 -384
- package/dist/lib/Card.svelte +103 -103
- package/dist/lib/Carousel.svelte +293 -293
- package/dist/lib/Carousel.svelte.d.ts +1 -1
- package/dist/lib/CheckBox.svelte +210 -210
- package/dist/lib/CodeView.svelte +308 -307
- package/dist/lib/ColorPicker.svelte +159 -159
- package/dist/lib/ContextMenu.svelte +328 -322
- package/dist/lib/DatePicker.svelte +246 -246
- package/dist/lib/Dialog.svelte +233 -233
- package/dist/lib/Field.svelte +299 -299
- package/dist/lib/FilePicker.svelte +295 -240
- package/dist/lib/FilePicker.svelte.d.ts +6 -1
- package/dist/lib/Form.svelte +438 -438
- package/dist/lib/Hamburger.svelte +217 -217
- package/dist/lib/InstallPWA.svelte +94 -94
- package/dist/lib/Menu.svelte +623 -623
- package/dist/lib/NoticeBase.svelte +140 -140
- package/dist/lib/PaginatedCard.svelte +73 -73
- package/dist/lib/Pagination.svelte +119 -119
- package/dist/lib/PrimaryColorSelect.svelte +111 -111
- package/dist/lib/ProgressBar.svelte +141 -141
- package/dist/lib/ProgressCircle.svelte +190 -190
- package/dist/lib/Radio.svelte +189 -189
- package/dist/lib/SearchInput.svelte +104 -104
- package/dist/lib/Select.svelte +524 -524
- package/dist/lib/Slider.svelte +253 -253
- package/dist/lib/Splitter.svelte +159 -150
- package/dist/lib/Switch.svelte +168 -167
- package/dist/lib/Table.svelte +299 -299
- package/dist/lib/Tabs.svelte +213 -213
- package/dist/lib/ThemeToggle.svelte +128 -127
- package/dist/lib/TimePicker.svelte +312 -312
- package/dist/lib/TimePickerNew.svelte +634 -0
- package/dist/lib/TimePickerNew.svelte.d.ts +49 -0
- package/dist/lib/Toast.svelte +123 -123
- package/dist/lib/Tooltip.svelte +110 -110
- package/dist/lib/Topbar.svelte +107 -107
- package/dist/lib/__tests__/Accordion.test.d.ts +1 -0
- package/dist/lib/__tests__/Accordion.test.js +171 -0
- package/dist/lib/__tests__/Badge.test.d.ts +1 -0
- package/dist/lib/__tests__/Badge.test.js +41 -0
- package/dist/lib/__tests__/Button.test.d.ts +1 -0
- package/dist/lib/__tests__/Button.test.js +269 -0
- package/dist/lib/__tests__/Calendar.test.d.ts +1 -0
- package/dist/lib/__tests__/Calendar.test.js +171 -0
- package/dist/lib/__tests__/Card.test.d.ts +1 -0
- package/dist/lib/__tests__/Card.test.js +148 -0
- package/dist/lib/__tests__/Carousel.test.d.ts +1 -0
- package/dist/lib/__tests__/Carousel.test.js +439 -0
- package/dist/lib/__tests__/CheckBox.test.d.ts +1 -0
- package/dist/lib/__tests__/CheckBox.test.js +152 -0
- package/dist/lib/__tests__/CodeView.test.d.ts +1 -0
- package/dist/lib/__tests__/CodeView.test.js +157 -0
- package/dist/lib/__tests__/ColorPicker.test.d.ts +1 -0
- package/dist/lib/__tests__/ColorPicker.test.js +93 -0
- package/dist/lib/__tests__/ContextMenu.test.d.ts +1 -0
- package/dist/lib/__tests__/ContextMenu.test.js +67 -0
- package/dist/lib/__tests__/DatePicker.test.d.ts +1 -0
- package/dist/lib/__tests__/DatePicker.test.js +108 -0
- package/dist/lib/__tests__/Dialog.test.d.ts +1 -0
- package/dist/lib/__tests__/Dialog.test.js +183 -0
- package/dist/lib/__tests__/Field.test.d.ts +1 -0
- package/dist/lib/__tests__/Field.test.js +190 -0
- package/dist/lib/__tests__/FilePicker.test.d.ts +1 -0
- package/dist/lib/__tests__/FilePicker.test.js +179 -0
- package/dist/lib/__tests__/Form.integration.test.d.ts +1 -0
- package/dist/lib/__tests__/Form.integration.test.js +158 -0
- package/dist/lib/__tests__/Form.test.d.ts +1 -0
- package/dist/lib/__tests__/Form.test.js +463 -0
- package/dist/lib/__tests__/Hamburger.test.d.ts +1 -0
- package/dist/lib/__tests__/Hamburger.test.js +161 -0
- package/dist/lib/__tests__/InstallPWA.test.d.ts +1 -0
- package/dist/lib/__tests__/InstallPWA.test.js +15 -0
- package/dist/lib/__tests__/Menu.test.d.ts +1 -0
- package/dist/lib/__tests__/Menu.test.js +285 -0
- package/dist/lib/__tests__/NoticeBase.test.d.ts +1 -0
- package/dist/lib/__tests__/NoticeBase.test.js +60 -0
- package/dist/lib/__tests__/PaginatedCard.test.d.ts +1 -0
- package/dist/lib/__tests__/PaginatedCard.test.js +89 -0
- package/dist/lib/__tests__/Pagination.test.d.ts +1 -0
- package/dist/lib/__tests__/Pagination.test.js +168 -0
- package/dist/lib/__tests__/PrimaryColorSelect.test.d.ts +1 -0
- package/dist/lib/__tests__/PrimaryColorSelect.test.js +92 -0
- package/dist/lib/__tests__/ProgressBar.test.d.ts +1 -0
- package/dist/lib/__tests__/ProgressBar.test.js +69 -0
- package/dist/lib/__tests__/ProgressCircle.test.d.ts +1 -0
- package/dist/lib/__tests__/ProgressCircle.test.js +71 -0
- package/dist/lib/__tests__/Radio.test.d.ts +1 -0
- package/dist/lib/__tests__/Radio.test.js +127 -0
- package/dist/lib/__tests__/SearchInput.test.d.ts +1 -0
- package/dist/lib/__tests__/SearchInput.test.js +80 -0
- package/dist/lib/__tests__/Select.test.d.ts +1 -0
- package/dist/lib/__tests__/Select.test.js +408 -0
- package/dist/lib/__tests__/Slider.test.d.ts +1 -0
- package/dist/lib/__tests__/Slider.test.js +213 -0
- package/dist/lib/__tests__/Splitter.test.d.ts +1 -0
- package/dist/lib/__tests__/Splitter.test.js +87 -0
- package/dist/lib/__tests__/Switch.test.d.ts +1 -0
- package/dist/lib/__tests__/Switch.test.js +97 -0
- package/dist/lib/__tests__/Table.test.d.ts +1 -0
- package/dist/lib/__tests__/Table.test.js +349 -0
- package/dist/lib/__tests__/Tabs.test.d.ts +1 -0
- package/dist/lib/__tests__/Tabs.test.js +262 -0
- package/dist/lib/__tests__/ThemeToggle.test.d.ts +1 -0
- package/dist/lib/__tests__/ThemeToggle.test.js +84 -0
- package/dist/lib/__tests__/TimePicker.test.d.ts +1 -0
- package/dist/lib/__tests__/TimePicker.test.js +146 -0
- package/dist/lib/__tests__/TimePickerNew.test.d.ts +1 -0
- package/dist/lib/__tests__/TimePickerNew.test.js +322 -0
- package/dist/lib/__tests__/Toast.test.d.ts +1 -0
- package/dist/lib/__tests__/Toast.test.js +135 -0
- package/dist/lib/__tests__/Tooltip.test.d.ts +1 -0
- package/dist/lib/__tests__/Tooltip.test.js +171 -0
- package/dist/lib/__tests__/Topbar.test.d.ts +1 -0
- package/dist/lib/__tests__/Topbar.test.js +25 -0
- package/dist/lib/__tests__/setupLangContext.d.ts +1 -0
- package/dist/lib/__tests__/setupLangContext.js +65 -0
- package/dist/lib/__tests__/storage.test.d.ts +1 -0
- package/dist/lib/__tests__/storage.test.js +124 -0
- package/dist/lib/__tests__/utils.test.d.ts +1 -0
- package/dist/lib/__tests__/utils.test.js +11 -0
- package/dist/lib/index.d.ts +1 -0
- package/dist/lib/index.js +1 -0
- package/dist/lib/lang.d.ts +4 -0
- package/dist/lib/lang.js +4 -0
- package/dist/styles.css +234 -232
- package/dist/utils/index.js +15 -4
- package/package.json +52 -52
package/dist/lib/Field.svelte
CHANGED
|
@@ -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>
|