diff --git a/src/uix/eidos/components/color-field/color-field.css b/src/uix/eidos/components/color-field/color-field.css index ea373e2fc..eb51b8358 100644 --- a/src/uix/eidos/components/color-field/color-field.css +++ b/src/uix/eidos/components/color-field/color-field.css @@ -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 diff --git a/src/uix/eidos/components/date-field/date-field.css b/src/uix/eidos/components/date-field/date-field.css index 85fd616b1..20f24bfa4 100644 --- a/src/uix/eidos/components/date-field/date-field.css +++ b/src/uix/eidos/components/date-field/date-field.css @@ -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 diff --git a/src/uix/eidos/components/date-range-field/date-range-field.css b/src/uix/eidos/components/date-range-field/date-range-field.css index e196eef00..b9b0c4958 100644 --- a/src/uix/eidos/components/date-range-field/date-range-field.css +++ b/src/uix/eidos/components/date-range-field/date-range-field.css @@ -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'] { diff --git a/src/uix/eidos/components/field/field.css b/src/uix/eidos/components/field/field.css index ab184779c..0f9c77e97 100644 --- a/src/uix/eidos/components/field/field.css +++ b/src/uix/eidos/components/field/field.css @@ -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; + } } diff --git a/src/uix/eidos/components/field/field.svelte b/src/uix/eidos/components/field/field.svelte index b7c0c2e2f..6d682a861 100644 --- a/src/uix/eidos/components/field/field.svelte +++ b/src/uix/eidos/components/field/field.svelte @@ -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)} diff --git a/src/uix/eidos/components/field/types.ts b/src/uix/eidos/components/field/types.ts index 073235734..1f5ddff48 100644 --- a/src/uix/eidos/components/field/types.ts +++ b/src/uix/eidos/components/field/types.ts @@ -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; diff --git a/src/uix/eidos/components/mask-field/mask-field.css b/src/uix/eidos/components/mask-field/mask-field.css index 8cfe54b40..1b5517ce6 100644 --- a/src/uix/eidos/components/mask-field/mask-field.css +++ b/src/uix/eidos/components/mask-field/mask-field.css @@ -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; diff --git a/src/uix/eidos/components/password-field/password-field.css b/src/uix/eidos/components/password-field/password-field.css index f74133fbe..cf2d5c3fb 100644 --- a/src/uix/eidos/components/password-field/password-field.css +++ b/src/uix/eidos/components/password-field/password-field.css @@ -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); diff --git a/src/uix/eidos/components/search-field/search-field.css b/src/uix/eidos/components/search-field/search-field.css index 8e7414e19..3b0904d7f 100644 --- a/src/uix/eidos/components/search-field/search-field.css +++ b/src/uix/eidos/components/search-field/search-field.css @@ -139,6 +139,7 @@ background: transparent; color: inherit; font: inherit; + font-weight: var(--font-weight-medium); outline: none; } diff --git a/src/uix/eidos/components/spin-field/spin-field.css b/src/uix/eidos/components/spin-field/spin-field.css index cb65b5d61..d20a15230 100644 --- a/src/uix/eidos/components/spin-field/spin-field.css +++ b/src/uix/eidos/components/spin-field/spin-field.css @@ -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); diff --git a/src/uix/eidos/components/textarea/textarea.css b/src/uix/eidos/components/textarea/textarea.css index 95d7d4997..233182d22 100644 --- a/src/uix/eidos/components/textarea/textarea.css +++ b/src/uix/eidos/components/textarea/textarea.css @@ -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; diff --git a/src/uix/eidos/components/time-field/time-field.css b/src/uix/eidos/components/time-field/time-field.css index 1c5b64ff7..053f686ed 100644 --- a/src/uix/eidos/components/time-field/time-field.css +++ b/src/uix/eidos/components/time-field/time-field.css @@ -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 diff --git a/src/uix/eidos/components/time-range-field/time-range-field.css b/src/uix/eidos/components/time-range-field/time-range-field.css index bdafe91bf..b22316588 100644 --- a/src/uix/eidos/components/time-range-field/time-range-field.css +++ b/src/uix/eidos/components/time-range-field/time-range-field.css @@ -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'] { diff --git a/src/uix/eidos/generated/base.css b/src/uix/eidos/generated/base.css index dacff61c9..da2a01cd8 100644 --- a/src/uix/eidos/generated/base.css +++ b/src/uix/eidos/generated/base.css @@ -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); diff --git a/src/uix/eidos/lib/recipes/base.ts b/src/uix/eidos/lib/recipes/base.ts index 732d2c6b5..ce15f3972 100644 --- a/src/uix/eidos/lib/recipes/base.ts +++ b/src/uix/eidos/lib/recipes/base.ts @@ -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)', diff --git a/web/routes/uix/components/field/+page.svelte b/web/routes/uix/components/field/+page.svelte index ffacc0daf..13a14239d 100644 --- a/web/routes/uix/components/field/+page.svelte +++ b/web/routes/uix/components/field/+page.svelte @@ -29,6 +29,7 @@ let variant = $state('surface'); let orientation = $state('vertical'); let color = $state('primary'); + let floatingLabel = $state(false); const compiled = compileMorfo(fieldMorfo); const partsList = $derived([...compiled.parts.byKebab.values()]); @@ -163,12 +164,13 @@ {variant} {orientation} {color} + {floatingLabel} > Email - @ - - .com + {#if !floatingLabel}@{/if} + + {#if !floatingLabel}.com{/if} Use your work email. Enter a valid email. @@ -272,7 +274,7 @@ {/each} - {#each ['required', 'invalid', 'disabled', 'readonly', 'dirty', 'touched'] as flag} + {#each ['required', 'invalid', 'disabled', 'readonly', 'dirty', 'touched', 'floatingLabel'] as flag}