@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.
@@ -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 { Variant } from "../types/variants.js";
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
- // Border colour comes from the variant map (the shared tint scheme — token @ --ui-tint-border at
195
- // rest, @ --ui-tint-border-hover on hover/focus — same as Card/DataTable/TextArea, so every bordered
196
- // surface stays uniform under any theme). `borderless` overrides it with transparent (the field's
197
- // own background) — the 1px box stays, so the field never jumps, and the focus outline still fires.
198
- const frameStyles = borderless ? "[border-color:transparent]" : borderVariantClass[variant];
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 fieldBg =
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
- fieldBg,
233
+ fillStyles,
205
234
  "border", // 1px — matches --ui-border (List/TextArea); uniform across the library
206
235
  paddingClasses,
207
236
  frameStyles,
208
- "focus-within:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)]",
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
- // Disabled state — borders are uniformly 1px now, so no padding compensation is needed.
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 bind:this={fieldElement} class="relative {wrapperClasses}" style="user-select: none;">
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 { Variant } from "../types/variants.js";
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 { Variant } from "../types/variants.js";
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 frameStyles = borderless ? "[border-color:transparent]" : borderVariantClass[variant];
194
- const fieldBg =
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
- fieldBg,
216
+ fillStyles,
198
217
  "border",
199
218
  multiSelect ? "p-1" : "pl-2 pr-0",
200
219
  frameStyles,
201
- "focus-within:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)]",
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
- const disabledStyles = disabled
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 { Variant } from "../types/variants.js";
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 { Variant } from "../types/variants.js";
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
- // Border colour from the variant map (same calc as Input); `borderless` → transparent (its own
323
- // background), keeping the 1px box + focus outline.
324
- const frameStyles = borderless ? "[border-color:transparent]" : borderVariantClass[variant];
325
- // Field background follows the variant axis: `ghost` transparent; every other variant fills with
326
- // --ui-color-background so the field reads as a field on a non-white surface.
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 themeStyles = [
330
- fieldBg,
331
- frameStyles,
332
- "focus-within:[outline:2px_solid_color-mix(in_srgb,currentColor_70%,transparent)]",
333
- "focus-within:[outline-offset:0px]",
334
- ].join(" ");
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
- // Disabled state — borders are uniformly 1px, so no padding compensation is needed.
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 { Variant } from "../types/variants.js";
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.21.5-dev.149.8ed1480",
3
+ "version": "0.21.5-dev.151.b182d28",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"