@spaethtech/svelte-ui 0.21.5-dev.149.8ed1480 → 0.21.5-dev.151.b182d28
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/dist/components/Input.svelte +48 -20
- package/dist/components/Input.svelte.d.ts +1 -1
- package/dist/components/Select2.svelte +26 -11
- package/dist/components/Select2.svelte.d.ts +1 -1
- package/dist/components/TextArea.svelte +36 -19
- package/dist/components/TextArea.svelte.d.ts +1 -1
- package/dist/theme.css +3 -0
- package/docs/themes.md +21 -0
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type { HTMLInputAttributes } from "svelte/elements";
|
|
4
4
|
import type { Size } from "../types/sizes.js";
|
|
5
5
|
import { responsiveClasses, type Responsive } from "../types/responsive.js";
|
|
6
|
-
import type
|
|
6
|
+
import { variantToken, type Variant } from "../types/variants.js";
|
|
7
7
|
import FieldChrome, { nextFieldId } from "./FieldChrome.svelte";
|
|
8
8
|
|
|
9
9
|
// Omit the native `size` attribute (character width, a number) — we repurpose `size` as the
|
|
@@ -191,22 +191,50 @@
|
|
|
191
191
|
paddingClasses = "px-2";
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
194
|
+
// Frame + fill follow the field's "liveness", ordered by the shared border-tint ladder — all from
|
|
195
|
+
// the variant's OWN token via `--field-accent` (set on the wrapper below; neutral/ghost → the
|
|
196
|
+
// neutral token), so there's NO `secondary` special-case: editable 50% > read-only 35% > disabled
|
|
197
|
+
// 15% (quiet → live). Read-only and disabled take the muted neutral fill; editable fills with the
|
|
198
|
+
// page background. `borderless` (editable only) drops the visible frame while keeping the 1px box.
|
|
199
|
+
const editableFrame = borderless ? "[border-color:transparent]" : borderVariantClass[variant];
|
|
199
200
|
// Field background follows the variant axis: `ghost` is transparent (chromeless); every other
|
|
200
201
|
// variant fills with --ui-color-background so the field reads as a field on a non-white page.
|
|
201
|
-
const
|
|
202
|
+
const editableBg =
|
|
202
203
|
variant === "ghost" ? "bg-transparent" : "[background-color:var(--ui-color-background)]";
|
|
204
|
+
const mutedFill =
|
|
205
|
+
"[background-color:color-mix(in_srgb,var(--ui-color-neutral)_var(--ui-tint-fill-muted),transparent)]";
|
|
206
|
+
|
|
207
|
+
let fillStyles: string;
|
|
208
|
+
let frameStyles: string;
|
|
209
|
+
let stateStyles = ""; // cursor + (for read-only) the inset focus ring
|
|
210
|
+
if (disabled) {
|
|
211
|
+
// Quietest frame; muted fill; not focusable (no ring). No more secondary-blue border.
|
|
212
|
+
fillStyles = mutedFill;
|
|
213
|
+
frameStyles =
|
|
214
|
+
"[border-color:color-mix(in_srgb,var(--field-accent)_var(--ui-tint-border-disabled),transparent)]";
|
|
215
|
+
stateStyles = "cursor-not-allowed";
|
|
216
|
+
} else if (readonly) {
|
|
217
|
+
// A distinct read-only look: quieter-than-editable frame (35% → 80% on hover/focus), muted fill,
|
|
218
|
+
// full-strength text, and the focus ring kept (keyboard users land here). The ring is drawn INSIDE
|
|
219
|
+
// the frame (outline-offset -2px) so a scrolling Card/Dialog can't clip it.
|
|
220
|
+
fillStyles = mutedFill;
|
|
221
|
+
frameStyles =
|
|
222
|
+
"[border-color:color-mix(in_srgb,var(--field-accent)_var(--ui-tint-border-readonly),transparent)] hover:[border-color:color-mix(in_srgb,var(--field-accent)_var(--ui-tint-border-hover),transparent)] focus-within:[border-color:color-mix(in_srgb,var(--field-accent)_var(--ui-tint-border-hover),transparent)]";
|
|
223
|
+
stateStyles =
|
|
224
|
+
"focus-within:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)] focus-within:[outline-offset:-2px]";
|
|
225
|
+
} else {
|
|
226
|
+
fillStyles = editableBg;
|
|
227
|
+
frameStyles = editableFrame;
|
|
228
|
+
stateStyles =
|
|
229
|
+
"focus-within:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)] focus-within:[outline-offset:-2px]";
|
|
230
|
+
}
|
|
231
|
+
|
|
203
232
|
const themeStyles = [
|
|
204
|
-
|
|
233
|
+
fillStyles,
|
|
205
234
|
"border", // 1px — matches --ui-border (List/TextArea); uniform across the library
|
|
206
235
|
paddingClasses,
|
|
207
236
|
frameStyles,
|
|
208
|
-
|
|
209
|
-
"focus-within:[outline-offset:0px]",
|
|
237
|
+
stateStyles,
|
|
210
238
|
].join(" ");
|
|
211
239
|
|
|
212
240
|
// Error state (shared): chrome `error` set OR a failing `validate` (once touched).
|
|
@@ -216,15 +244,7 @@
|
|
|
216
244
|
"[border-color:var(--ui-color-error)] focus-within:[ring-color:var(--ui-color-error)]";
|
|
217
245
|
}
|
|
218
246
|
|
|
219
|
-
|
|
220
|
-
// (readonly renders like a normal field: same 1px frame + hover/focus glow, e.g. Select's trigger.)
|
|
221
|
-
let disabledStyles = "";
|
|
222
|
-
if (disabled) {
|
|
223
|
-
disabledStyles =
|
|
224
|
-
"![background-color:color-mix(in_srgb,var(--ui-color-text)_5%,transparent)] [border-color:color-mix(in_srgb,var(--ui-color-secondary)_var(--ui-tint-border),transparent)] cursor-not-allowed";
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
return `${base} ${themeStyles} ${validationStyles} ${disabledStyles}`.trim();
|
|
247
|
+
return `${base} ${themeStyles} ${validationStyles}`.trim();
|
|
228
248
|
});
|
|
229
249
|
|
|
230
250
|
let inputClasses = $derived.by(() => {
|
|
@@ -240,6 +260,10 @@
|
|
|
240
260
|
"disabled:bg-transparent",
|
|
241
261
|
"disabled:[color:color-mix(in_srgb,var(--ui-color-text)_40%,transparent)]",
|
|
242
262
|
"disabled:cursor-not-allowed",
|
|
263
|
+
// Read-only keeps full-strength text but reads as non-editable: a text cursor (it's selectable)
|
|
264
|
+
// and no blinking caret (nothing to type).
|
|
265
|
+
"read-only:cursor-text",
|
|
266
|
+
"read-only:[caret-color:transparent]",
|
|
243
267
|
].join(" ");
|
|
244
268
|
|
|
245
269
|
return `${base} ${themeStyles} ${inputClass}`.trim();
|
|
@@ -323,7 +347,11 @@
|
|
|
323
347
|
class={containerClass}
|
|
324
348
|
>
|
|
325
349
|
{#snippet control({ describedBy })}
|
|
326
|
-
<div
|
|
350
|
+
<div
|
|
351
|
+
bind:this={fieldElement}
|
|
352
|
+
class="relative {wrapperClasses}"
|
|
353
|
+
style="user-select: none; --field-accent: var({variantToken[variant]});"
|
|
354
|
+
>
|
|
327
355
|
{#if icon && iconPosition === "left"}
|
|
328
356
|
{#if iconClickable}
|
|
329
357
|
<div
|
|
@@ -2,7 +2,7 @@ import type { Snippet } from "svelte";
|
|
|
2
2
|
import type { HTMLInputAttributes } from "svelte/elements";
|
|
3
3
|
import type { Size } from "../types/sizes.js";
|
|
4
4
|
import { type Responsive } from "../types/responsive.js";
|
|
5
|
-
import type
|
|
5
|
+
import { type Variant } from "../types/variants.js";
|
|
6
6
|
interface Props extends Omit<HTMLInputAttributes, "size"> {
|
|
7
7
|
/** Accepts number too so remote-form field spreads (`{...field.as(...)}`) type-check. */
|
|
8
8
|
value?: string | number;
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import FieldChrome, { nextFieldId } from "./FieldChrome.svelte";
|
|
25
25
|
import type { Size } from "../types/sizes.js";
|
|
26
26
|
import { responsiveClasses, type Responsive } from "../types/responsive.js";
|
|
27
|
-
import type
|
|
27
|
+
import { variantToken, type Variant } from "../types/variants.js";
|
|
28
28
|
|
|
29
29
|
type Option = Record<string, any> & {
|
|
30
30
|
value: string;
|
|
@@ -190,24 +190,39 @@
|
|
|
190
190
|
const base = multiSelect
|
|
191
191
|
? `relative w-full flex items-stretch [border-radius:var(--ui-border-radius)]`
|
|
192
192
|
: `relative w-full ${heightClass} flex items-center [border-radius:var(--ui-border-radius)]`;
|
|
193
|
-
const
|
|
194
|
-
const
|
|
193
|
+
const editableFrame = borderless ? "[border-color:transparent]" : borderVariantClass[variant];
|
|
194
|
+
const editableBg =
|
|
195
195
|
variant === "ghost" ? "bg-transparent" : "[background-color:var(--ui-color-background)]";
|
|
196
|
+
// Disabled picks ONE frame (not appended) so it isn't overridden by the editable border at equal
|
|
197
|
+
// specificity: the quietest frame from the field's OWN variant token via --field-accent (NOT the old
|
|
198
|
+
// secondary-blue) + the shared muted fill. A native <select> has no read-only state. The focus ring
|
|
199
|
+
// is drawn INSIDE the frame (offset -2px) so a scrolling container can't clip it (matches Input).
|
|
200
|
+
let fillStyles: string;
|
|
201
|
+
let frameStyles: string;
|
|
202
|
+
let stateStyles = "";
|
|
203
|
+
if (disabled) {
|
|
204
|
+
fillStyles =
|
|
205
|
+
"[background-color:color-mix(in_srgb,var(--ui-color-neutral)_var(--ui-tint-fill-muted),transparent)]";
|
|
206
|
+
frameStyles =
|
|
207
|
+
"[border-color:color-mix(in_srgb,var(--field-accent)_var(--ui-tint-border-disabled),transparent)]";
|
|
208
|
+
stateStyles = "cursor-not-allowed";
|
|
209
|
+
} else {
|
|
210
|
+
fillStyles = editableBg;
|
|
211
|
+
frameStyles = editableFrame;
|
|
212
|
+
stateStyles =
|
|
213
|
+
"focus-within:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)] focus-within:[outline-offset:-2px]";
|
|
214
|
+
}
|
|
196
215
|
const themeStyles = [
|
|
197
|
-
|
|
216
|
+
fillStyles,
|
|
198
217
|
"border",
|
|
199
218
|
multiSelect ? "p-1" : "pl-2 pr-0",
|
|
200
219
|
frameStyles,
|
|
201
|
-
|
|
202
|
-
"focus-within:[outline-offset:0px]",
|
|
220
|
+
stateStyles,
|
|
203
221
|
].join(" ");
|
|
204
222
|
const validationStyles = invalid
|
|
205
223
|
? "[border-color:var(--ui-color-error)] focus-within:[ring-color:var(--ui-color-error)]"
|
|
206
224
|
: "";
|
|
207
|
-
|
|
208
|
-
? "![background-color:color-mix(in_srgb,var(--ui-color-text)_5%,transparent)] [border-color:color-mix(in_srgb,var(--ui-color-secondary)_var(--ui-tint-border),transparent)] cursor-not-allowed"
|
|
209
|
-
: "";
|
|
210
|
-
return `${base} ${themeStyles} ${validationStyles} ${disabledStyles}`.trim();
|
|
225
|
+
return `${base} ${themeStyles} ${validationStyles}`.trim();
|
|
211
226
|
});
|
|
212
227
|
|
|
213
228
|
// The <select> itself — mirrors Input's inner <input> (flex-1, transparent, no border/outline).
|
|
@@ -237,7 +252,7 @@
|
|
|
237
252
|
|
|
238
253
|
<FieldChrome {label} {aside} {error} {description} required={!!required} {controlId} {size} class={className}>
|
|
239
254
|
{#snippet control({ describedBy })}
|
|
240
|
-
<div class={wrapperClasses}>
|
|
255
|
+
<div class={wrapperClasses} style="--field-accent: var({variantToken[variant]});">
|
|
241
256
|
<!-- The real control: native <select>. We set `selected` per-option (rather than `bind:value`,
|
|
242
257
|
which forbids a dynamic `multiple`) so SSR emits the correct selected option — right text
|
|
243
258
|
before hydration / with JS off — and `onchange` keeps client state in sync. -->
|
|
@@ -21,7 +21,7 @@ import type { HTMLSelectAttributes } from "svelte/elements";
|
|
|
21
21
|
import type { FieldChromeProps } from "./field-chrome.js";
|
|
22
22
|
import type { Size } from "../types/sizes.js";
|
|
23
23
|
import { type Responsive } from "../types/responsive.js";
|
|
24
|
-
import type
|
|
24
|
+
import { type Variant } from "../types/variants.js";
|
|
25
25
|
type Option = Record<string, any> & {
|
|
26
26
|
value: string;
|
|
27
27
|
label: string;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { Snippet } from "svelte";
|
|
7
7
|
import type { Size } from "../types/sizes.js";
|
|
8
8
|
import { responsiveClasses, resolveScalar, type Responsive } from "../types/responsive.js";
|
|
9
|
-
import type
|
|
9
|
+
import { variantToken, type Variant } from "../types/variants.js";
|
|
10
10
|
import { DEV } from "esm-env";
|
|
11
11
|
import FieldChrome, { nextFieldId } from "./FieldChrome.svelte";
|
|
12
12
|
|
|
@@ -319,19 +319,37 @@
|
|
|
319
319
|
let wrapperClasses = $derived.by(() => {
|
|
320
320
|
const base = `relative w-full [border-radius:var(--ui-border-radius)] border ${padClass}`;
|
|
321
321
|
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const fieldBg =
|
|
322
|
+
// Frame + fill follow the field's liveness, all from the variant's OWN token via `--field-accent`
|
|
323
|
+
// (set on the wrapper below; neutral/ghost → the neutral token) — same ladder + inset focus ring as
|
|
324
|
+
// Input: editable 50% > read-only 35% > disabled 15%, with a muted neutral fill for the inert states.
|
|
325
|
+
const editableFrame = borderless ? "[border-color:transparent]" : borderVariantClass[variant];
|
|
326
|
+
const editableBg =
|
|
328
327
|
variant === "ghost" ? "bg-transparent" : "[background-color:var(--ui-color-background)]";
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
"focus-within:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)]"
|
|
333
|
-
|
|
334
|
-
|
|
328
|
+
const mutedFill =
|
|
329
|
+
"![background-color:color-mix(in_srgb,var(--ui-color-neutral)_var(--ui-tint-fill-muted),transparent)]";
|
|
330
|
+
const insetRing =
|
|
331
|
+
"focus-within:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)] focus-within:[outline-offset:-2px]";
|
|
332
|
+
|
|
333
|
+
let fillStyles: string;
|
|
334
|
+
let frameStyles: string;
|
|
335
|
+
let stateStyles = "";
|
|
336
|
+
if (disabled) {
|
|
337
|
+
fillStyles = mutedFill;
|
|
338
|
+
frameStyles =
|
|
339
|
+
"[border-color:color-mix(in_srgb,var(--field-accent)_var(--ui-tint-border-disabled),transparent)]";
|
|
340
|
+
stateStyles = "cursor-not-allowed";
|
|
341
|
+
} else if (readonly) {
|
|
342
|
+
fillStyles = mutedFill;
|
|
343
|
+
frameStyles =
|
|
344
|
+
"[border-color:color-mix(in_srgb,var(--field-accent)_var(--ui-tint-border-readonly),transparent)] hover:[border-color:color-mix(in_srgb,var(--field-accent)_var(--ui-tint-border-hover),transparent)] focus-within:[border-color:color-mix(in_srgb,var(--field-accent)_var(--ui-tint-border-hover),transparent)]";
|
|
345
|
+
stateStyles = insetRing;
|
|
346
|
+
} else {
|
|
347
|
+
fillStyles = editableBg;
|
|
348
|
+
frameStyles = editableFrame;
|
|
349
|
+
stateStyles = insetRing;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const themeStyles = [fillStyles, frameStyles, stateStyles].join(" ");
|
|
335
353
|
|
|
336
354
|
// Error state (shared): chrome `error` set OR a failing `validate` (once touched).
|
|
337
355
|
let validationStyles = "";
|
|
@@ -340,12 +358,7 @@
|
|
|
340
358
|
"[border-color:var(--ui-color-error)] focus-within:[outline:2px_solid_color-mix(in_srgb,var(--ui-color-error)_70%,transparent)]";
|
|
341
359
|
}
|
|
342
360
|
|
|
343
|
-
|
|
344
|
-
const disabledStyles = disabled
|
|
345
|
-
? "![background-color:color-mix(in_srgb,var(--ui-color-text)_5%,transparent)] [border-color:color-mix(in_srgb,var(--ui-color-secondary)_50%,transparent)] cursor-not-allowed"
|
|
346
|
-
: "";
|
|
347
|
-
|
|
348
|
-
return `${base} ${themeStyles} ${validationStyles} ${disabledStyles}`.trim();
|
|
361
|
+
return `${base} ${themeStyles} ${validationStyles}`.trim();
|
|
349
362
|
});
|
|
350
363
|
|
|
351
364
|
let textareaClasses = $derived.by(() => {
|
|
@@ -358,6 +371,9 @@
|
|
|
358
371
|
"disabled:bg-transparent",
|
|
359
372
|
"disabled:[color:color-mix(in_srgb,var(--ui-color-text)_40%,transparent)]",
|
|
360
373
|
"disabled:cursor-not-allowed",
|
|
374
|
+
// Read-only (but not disabled): text cursor, no caret — matches Input. (`effectiveReadonly` sets
|
|
375
|
+
// the readonly attr on disabled too, so gate on the raw props to avoid fighting disabled's cursor.)
|
|
376
|
+
readonly && !disabled ? "cursor-text [caret-color:transparent]" : "",
|
|
361
377
|
].join(" ");
|
|
362
378
|
|
|
363
379
|
// Padding adjustment for the copy icon (only when the button is shown)
|
|
@@ -708,6 +724,7 @@
|
|
|
708
724
|
}}
|
|
709
725
|
role="group"
|
|
710
726
|
aria-label={i18n.messages.textAreaControls}
|
|
727
|
+
style="--field-accent: var({variantToken[variant]});"
|
|
711
728
|
>
|
|
712
729
|
<textarea
|
|
713
730
|
bind:this={textareaElement}
|
|
@@ -2,7 +2,7 @@ import type { HTMLTextareaAttributes } from "svelte/elements";
|
|
|
2
2
|
import type { Snippet } from "svelte";
|
|
3
3
|
import type { Size } from "../types/sizes.js";
|
|
4
4
|
import { type Responsive } from "../types/responsive.js";
|
|
5
|
-
import type
|
|
5
|
+
import { type Variant } from "../types/variants.js";
|
|
6
6
|
type TextAreaCopy = "none" | "selection" | "button";
|
|
7
7
|
interface Props extends Omit<HTMLTextareaAttributes, "oncopy"> {
|
|
8
8
|
value?: string;
|
package/dist/theme.css
CHANGED
|
@@ -124,7 +124,10 @@
|
|
|
124
124
|
* TextArea, an outline Button) moves to on hover/focus. Theme-independent constants (no colour → no
|
|
125
125
|
* per-theme scope needed) — but a theme MAY override these to tune the whole tint language. */
|
|
126
126
|
--ui-tint-soft: 12%;
|
|
127
|
+
--ui-tint-fill-muted: 5%; /* disabled / read-only field fill — a faint neutral wash (quieter than soft) */
|
|
128
|
+
--ui-tint-border-disabled: 15%; /* disabled field frame — the QUIETEST frame (a disabled control recedes) */
|
|
127
129
|
--ui-tint-divider: 30%; /* soft internal dividers — deliberately lighter than the 50% frame/field border */
|
|
130
|
+
--ui-tint-border-readonly: 35%; /* read-only field frame — quieter than editable (50%), louder than a divider */
|
|
128
131
|
--ui-tint-border: 50%;
|
|
129
132
|
--ui-tint-border-hover: 80%;
|
|
130
133
|
--ui-tint-strong: 80%;
|
package/docs/themes.md
CHANGED
|
@@ -65,6 +65,27 @@ theme-independent** (identical in light and dark) — only the accent color flip
|
|
|
65
65
|
`color-mix(in srgb, var(--ui-color-neutral) var(--ui-tint-border), transparent)` for a frame edge,
|
|
66
66
|
`--ui-tint-border-hover` for its hover.)
|
|
67
67
|
|
|
68
|
+
### Field states — the border-tint ladder
|
|
69
|
+
|
|
70
|
+
A field's frame reflects how "live" it is, all from the **variant's own token** (so a `neutral` field is
|
|
71
|
+
monochrome, a `primary` field tints blue — there is **no `secondary` special-case** for disabled):
|
|
72
|
+
|
|
73
|
+
| state | border tint | fill |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| editable | `--ui-tint-border` (50%) → `--ui-tint-border-hover` (80%) on hover/focus | page background |
|
|
76
|
+
| **read-only** | `--ui-tint-border-readonly` (**35%**) → 80% on hover/focus | `--ui-tint-fill-muted` |
|
|
77
|
+
| **disabled** | `--ui-tint-border-disabled` (**15%**) | `--ui-tint-fill-muted` |
|
|
78
|
+
|
|
79
|
+
- `--ui-tint-border-readonly` — `35%` — read-only frame (quieter than editable, louder than a divider).
|
|
80
|
+
- `--ui-tint-border-disabled` — `15%` — disabled frame, the **quietest** (a disabled control recedes).
|
|
81
|
+
- `--ui-tint-fill-muted` — `5%` — the faint neutral wash behind a read-only or disabled field
|
|
82
|
+
(`color-mix(in srgb, var(--ui-color-neutral) var(--ui-tint-fill-muted), transparent)`).
|
|
83
|
+
|
|
84
|
+
Read-only keeps **full-strength text**, a text cursor, and the focus ring (it's selectable/focusable),
|
|
85
|
+
but hides the caret; disabled dims its text to 40%. The focus ring is drawn **inside** the frame
|
|
86
|
+
(`outline-offset: -2px`) so a scrolling `Card`/`Dialog` can't clip it. Overriding these percentages
|
|
87
|
+
per theme retunes read-only/disabled everywhere at once.
|
|
88
|
+
|
|
68
89
|
## Sizing — the `size` axis (`sm` / `md` / `lg`)
|
|
69
90
|
|
|
70
91
|
### Control heights
|