feat(field): floating label mode + value-weight doctrine

Floating label (`<Field floatingLabel>`), the label typography doctrine, and
its propagation across the text-field family.

Floating label (Material outlined, text inputs only):
- CSS-only via :focus-within + :placeholder-shown — no new soma state. The
  label starts as a placeholder inside the control and floats onto the top
  border when focused/filled. Reuses Field.Label + Field.Input. Requires the
  input to carry `placeholder=" "`. Segmented controls (Date/Time) not covered.
- Floated position is themeable via `--field-floating-label-top` (default 0 =
  on the border) without touching the recipe.

Label weight doctrine ("label one step below the component"):
- The field VALUE is now `medium` and the label stays `regular` — one weight
  step below (the size offset was already derived one step down). Base Field via
  `--field-control-font-weight`; propagated to the specialized text fields
  (number/css via spin-field, mask, password, search, textarea, date, time,
  date-range, time-range, color) so every field value reads at medium.

Verified: recipe-css-contract + component-visual-attrs + api-contract (30),
eidos-lint 0 invalid, smoke, check 0 field errors. Browser: floating resting↔
floated transitions correctly; value 500 / label 400 across fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
menubar-v4-safe
dev 3 months ago
parent 71f6b9a5d7
commit 92f9723388

@ -123,6 +123,7 @@
border-radius: var(--color-field-radius);
background: var(--color-field-bg);
color: inherit;
font-weight: var(--font-weight-medium);
/* Only `background` transitions. `border-color` must be INSTANT: the focus
border toggles via `:has(segment:focus-visible)`, and a keyboard increment
briefly blurs/refocuses the segment — a transitioned border-color would

@ -122,6 +122,7 @@
border-radius: var(--date-field-radius);
background: var(--date-field-bg);
color: inherit;
font-weight: var(--font-weight-medium);
/* Only `background` transitions. `border-color` must be INSTANT: the focus
border toggles via `:has(segment:focus-visible)`, and a keyboard increment
briefly blurs/refocuses the segment — a transitioned border-color would

@ -60,6 +60,7 @@
[data-date-range-field-input] {
grid-row: 2;
min-inline-size: 0;
font-weight: var(--font-weight-medium);
}
[data-date-range-field-input][data-endpoint='start'] {

@ -193,6 +193,9 @@
color: var(--field-control-color);
font-family: var(--field-font-family);
font-size: var(--_field-control-font-size);
/* Value/control text is one weight step above the label (medium vs regular)
— the "label one step below the component" doctrine. */
font-weight: var(--field-control-font-weight);
line-height: var(--field-control-line-height);
transition:
background var(--field-transition-duration) var(--field-transition-ease),
@ -278,8 +281,74 @@
color: var(--field-error-color);
}
/* ── Floating label ──────────────────────────────────────────────────────
* Material-style: the label overlays the control as a placeholder and floats
* up onto the top border when the field is focused OR filled. "Filled" is read
* from the input's `:placeholder-shown` — so the input MUST carry
* `placeholder=" "` (a space). Text inputs only (Field.Input); segmented
* controls (Date/Time field) are not covered. Floating overrides orientation.
*/
[data-field][data-floating-label] {
position: relative;
display: block;
}
/* Resting state: label centered in the control, at the control's font size,
reading like a placeholder. */
[data-field][data-floating-label] > [data-field-label] {
position: absolute;
/* Centre on the control (which sits at the field's top edge), NOT the whole
field — the field also stacks helper/error below the control. */
inset-block-start: calc(var(--_field-control-height) / 2);
inset-inline-start: calc(var(--_field-control-px) - var(--space-1));
transform: translateY(-50%);
transform-origin: inline-start center;
block-size: auto;
min-block-size: 0;
max-inline-size: calc(100% - 2 * var(--_field-control-px));
overflow: hidden;
white-space: nowrap;
pointer-events: none;
padding-inline: var(--space-1);
font-size: var(--_field-control-font-size);
color: var(--field-control-placeholder-color);
background: transparent;
transition:
inset-block-start var(--field-transition-duration) var(--field-transition-ease),
transform var(--field-transition-duration) var(--field-transition-ease),
font-size var(--field-transition-duration) var(--field-transition-ease),
color var(--field-transition-duration) var(--field-transition-ease),
background var(--field-transition-duration) var(--field-transition-ease);
z-index: 1;
}
/* The control fills the field; helper/error stack below it. */
[data-field][data-floating-label] > [data-field-control] {
inline-size: 100%;
}
/* Floated: focused OR filled → sit on the top border, shrunk. */
[data-field][data-floating-label]:focus-within > [data-field-label],
[data-field][data-floating-label]:has([data-field-input]:not(:placeholder-shown)) > [data-field-label] {
/* `--field-floating-label-top` (default 0) is the floated centre line — 0 sits
on the control's top border (outlined). Themeable without touching this rule. */
inset-block-start: var(--field-floating-label-top);
transform: translateY(-50%) scale(0.85);
color: var(--field-label-color);
background: var(--_field-control-bg);
}
/* Accent the floated label while the field is focused. */
[data-field][data-floating-label]:focus-within > [data-field-label] {
color: var(--_field-accent-text);
}
@media (prefers-reduced-motion: reduce) {
[data-field-control] {
transition: none;
}
[data-field][data-floating-label] > [data-field-label] {
transition: none;
}
}

@ -13,6 +13,7 @@
variant = 'surface',
orientation = 'vertical',
color = 'primary',
floatingLabel = false,
children: bodyContent,
...rest
}: FieldProps = $props();
@ -27,6 +28,7 @@
data-variant={variant}
data-orientation={orientation}
data-color={color}
data-floating-label={floatingLabel ? '' : undefined}
>
{#snippet children(snippetProps)}
{@render bodyContent?.(snippetProps)}

@ -33,6 +33,14 @@ export type FieldProps = ProviderProps & {
orientation?: FieldOrientation;
/** Accent palette for focus and required indicator. @default 'primary' */
color?: FieldColor;
/**
* Float the label: it starts as a placeholder inside the control and floats
* up to the top border when the field is focused or filled (Material style).
* Requires the input to carry `placeholder=" "` so the empty state is
* detectable via `:placeholder-shown`. Text inputs only — segmented controls
* (Date/Time field) are not covered. @default false
*/
floatingLabel?: boolean;
};
export type FieldLabelProps = LabelProps;

@ -47,6 +47,7 @@
background: transparent;
color: inherit;
font: inherit;
font-weight: var(--font-weight-medium);
outline: none;
/* Masked patterns are digit-heavy — keep glyph columns aligned. */
font-variant-numeric: tabular-nums;

@ -206,6 +206,7 @@
background: transparent;
color: inherit;
font: inherit;
font-weight: var(--font-weight-medium);
outline: none;
/* Slightly wider tracking helps recognise the · placeholder mask. */
letter-spacing: var(--tracking-wide);

@ -139,6 +139,7 @@
background: transparent;
color: inherit;
font: inherit;
font-weight: var(--font-weight-medium);
outline: none;
}

@ -117,6 +117,7 @@
background: transparent;
color: inherit;
font: inherit;
font-weight: var(--font-weight-medium);
outline: none;
transition: box-shadow var(--spin-field-transition-duration)
var(--spin-field-transition-ease);

@ -102,6 +102,7 @@
background: var(--_textarea-bg);
color: var(--_textarea-color);
font-family: var(--font-family-primary);
font-weight: var(--font-weight-medium);
font-size: var(--_textarea-font-size);
line-height: var(--_textarea-line-height);
outline: none;

@ -114,6 +114,7 @@
border-radius: var(--time-field-radius);
background: var(--time-field-bg);
color: inherit;
font-weight: var(--font-weight-medium);
/* Only `background` transitions. `border-color` must be INSTANT: the focus
border toggles via `:has(segment:focus-visible)`, and a keyboard increment
briefly blurs/refocuses the segment — a transitioned border-color would

@ -61,6 +61,7 @@
[data-time-range-field-input] {
grid-row: 2;
min-inline-size: 0;
font-weight: var(--font-weight-medium);
}
[data-time-range-field-input][data-endpoint='start'] {

@ -1033,6 +1033,7 @@
--field-label-color: var(--color-content-primary);
--field-label-color-invalid: var(--color-risk-text);
--field-label-color-disabled: var(--color-content-disabled);
--field-floating-label-top: 0px;
--field-layout-column-gap: var(--space-4);
--field-required-color: var(--color-risk-text);
--field-control-height-xs: var(--size-xs-control-height);
@ -1056,6 +1057,7 @@
--field-control-font-size-lg: var(--size-lg-font-size);
--field-control-font-size-xl: var(--size-xl-font-size);
--field-control-line-height: var(--leading-ui);
--field-control-font-weight: var(--font-weight-medium);
--field-control-radius: var(--radius-md);
--field-control-border-width: var(--border-width);
--field-control-border: var(--color-border-default);

@ -793,6 +793,11 @@ export const THEME_BASE_RECIPE_TOKENS = {
'label-color': 'var(--color-content-primary)',
'label-color-invalid': 'var(--color-risk-text)',
'label-color-disabled': 'var(--color-content-disabled)',
// Floated-label vertical anchor (floatingLabel mode). `0` = the label's
// centre lands ON the control's top border (outlined style, default). A
// theme can retarget it — negative to float fully above, or a positive
// offset to float top-inside (filled style) — without touching the recipe.
'floating-label-top': '0px',
'layout-column-gap': 'var(--space-4)',
'required-color': 'var(--color-risk-text)',
'control-height-xs': 'var(--size-xs-control-height)',
@ -816,6 +821,10 @@ export const THEME_BASE_RECIPE_TOKENS = {
'control-font-size-lg': 'var(--size-lg-font-size)',
'control-font-size-xl': 'var(--size-xl-font-size)',
'control-line-height': 'var(--leading-ui)',
// Value/control text sits one weight step ABOVE the label: the value is
// `medium`, the label stays `regular` (the "label one step below the
// component" doctrine — the size offset is derived in field.css).
'control-font-weight': 'var(--font-weight-medium)',
'control-radius': 'var(--radius-md)',
'control-border-width': 'var(--border-width)',
'control-border': 'var(--color-border-default)',

@ -29,6 +29,7 @@
let variant = $state<FieldVariant>('surface');
let orientation = $state<FieldOrientation>('vertical');
let color = $state<FieldColor>('primary');
let floatingLabel = $state(false);
const compiled = compileMorfo(fieldMorfo);
const partsList = $derived([...compiled.parts.byKebab.values()]);
@ -163,12 +164,13 @@
{variant}
{orientation}
{color}
{floatingLabel}
>
<Field.Label>Email <Field.RequiredIndicator /></Field.Label>
<Field.Control>
<Field.Prefix>@</Field.Prefix>
<Field.Input type="email" bind:value />
<Field.Suffix>.com</Field.Suffix>
{#if !floatingLabel}<Field.Prefix>@</Field.Prefix>{/if}
<Field.Input type="email" bind:value placeholder={floatingLabel ? ' ' : undefined} />
{#if !floatingLabel}<Field.Suffix>.com</Field.Suffix>{/if}
</Field.Control>
<Field.HelperText>Use your work email.</Field.HelperText>
<Field.ErrorText>Enter a valid email.</Field.ErrorText>
@ -272,7 +274,7 @@
{/each}
</span>
</label>
{#each ['required', 'invalid', 'disabled', 'readonly', 'dirty', 'touched'] as flag}
{#each ['required', 'invalid', 'disabled', 'readonly', 'dirty', 'touched', 'floatingLabel'] as flag}
<label data-uix-control>
<span data-uix-control-label>{flag}</span>
<span data-uix-switch>
@ -291,9 +293,12 @@
{:else if flag === 'dirty'}
<input type="checkbox" bind:checked={dirty} />
<span data-uix-switch-label>{dirty ? 'on' : 'off'}</span>
{:else}
{:else if flag === 'touched'}
<input type="checkbox" bind:checked={touched} />
<span data-uix-switch-label>{touched ? 'on' : 'off'}</span>
{:else}
<input type="checkbox" bind:checked={floatingLabel} />
<span data-uix-switch-label>{floatingLabel ? 'on' : 'off'}</span>
{/if}
</span>
</label>

Loading…
Cancel
Save

Powered by TurnKey Linux.