@spaethtech/svelte-ui 0.21.5-dev.149.8ed1480 → 0.21.5-dev.150.7bf55da

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;
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.150.7bf55da",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"