Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr

## [Unreleased]

### Added

- **Les champs à boîte acceptent un mode `floatLabel`, avec ses trois variantes** (FSHSP-157). Le libellé n'avait qu'une place possible, au-dessus du champ : un formulaire dense n'avait pas d'autre levier que de le retirer et de rabattre l'information sur le `placeholder`, qui disparaît à la première frappe et n'est pas un libellé pour un lecteur d'écran. `floatLabel` le fait descendre **dans** le champ, où il tient le rôle du placeholder, et remonter au focus, dès qu'une valeur est présente, tant qu'un panneau ouvert retient le champ (un déclencheur comme `ui-datepicker` confie le focus à son calendrier sans cesser d'être actif), ou sur autofill du navigateur (qui ne déclenche aucun évènement de saisie). Trois positions hautes, alignées sur celles de PrimeNG : `over` (au-dessus de la boîte), `in` (bande réservée en haut de la boîte, qui grandit d'autant pour que la rangée du contrôle garde sa hauteur) et `on` (à cheval sur le trait, qu'il entaille). Disponible sur les huit champs bâtis sur le shell `ui-field` : `ui-input`, `ui-textarea`, `ui-select`, `ui-autocomplete`, `ui-input-number`, `ui-input-mask`, `ui-input-tags` et `ui-datepicker` (qui le transmet à son déclencheur). L'input est porté par `BaseFormField`, donc un champ écrit sur cette base l'hérite sans code supplémentaire. Non renseigné, rien ne change : le libellé classique reste le défaut, sur tous les champs.
- Le libellé flottant reste un `<label for>` natif, marqueur `required` compris : il n'intercepte pas le pointeur au repos (le clic va au curseur de saisie) et le récupère une fois remonté. Seuls sa position et son échelle sont animées, jamais une `font-size` : le trajet ne provoque aucune reprise de mise en page, et la transition est coupée sous `prefers-reduced-motion`.
- Aucun recouvrement de mise en page : `over` réserve la bande où le libellé monte, `on` la moitié qui dépasse du trait, `in` grandit la boîte. Deux champs collés verticalement ne se chevauchent pas.
- En variante `in`, la bande est réservée par le **contrôle**, pas par la boîte : celui-ci continue de couvrir la boîte bord à bord (zone de clic, zone du curseur, fond d'autofill), seul le texte saisi descend sous le libellé. Affixes, unités, spinner, chevron, bouton d'effacement et libellé au repos restent centrés sur la boîte, qui se lit alors comme un tout.
- Le `placeholder` natif est neutralisé tant que `floatLabel` et `label` sont tous les deux renseignés : les deux textes occupent la même place et ne seraient lisibles ni l'un ni l'autre. `floatLabel` sans `label` ne fait rien.
- Nouveau mixin partagé `utils.field-float-inset($extra)`, dans la surface SCSS publiée : c'est par lui qu'un contrôle réserve la bande du libellé `in`. Un champ écrit hors du kit sur `ui-field` l'inclut sur son contrôle (les mixins `utils.field-native-input` le font déjà).
- Neuf nouveaux réglages `--ui-field-float-label-*` (taille, interligne, échelle au repos, décalages, entaille de la variante `on`, retrait derrière une icône gauche), plus les trois valeurs dérivées qui en découlent : voir la table « Theming » de la doc.

### Changed

- **La boîte de `ui-field` est désormais enveloppée dans un `.ui-field-control`** (FSHSP-157). C'est le contexte de positionnement du libellé flottant, et il est rendu dans les deux modes plutôt que conditionnellement, pour que le DOM d'un champ ne dépende pas de l'option. Aucun impact visuel ni sur les sélecteurs publics ; un consommateur qui aurait écrit du CSS sur l'enchaînement direct `.ui-field > .ui-field-box` doit passer par le descendant.
- **`ui-label` tronque son texte quand il est contraint** au lieu de déborder (`text-overflow: ellipsis` sur `.ui-label-text`, `max-width: 100%` sur la racine). Sans contrainte de largeur, le comportement est inchangé : le texte passe à la ligne comme avant.

## [0.6.1] - 2026-08-22

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions figma/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The components' `--ui-*` variables, in the same DTCG shape as
one too. Generated by `npm run docs:config`; `docs:config:check` fails if it is stale.

**These are not design tokens.** A token belongs to the system (`--units-sm`); a
component variable belongs to one component, and 455 of the 575 entries merely _alias_ a
component variable belongs to one component, and 459 of the 580 entries merely _alias_ a
token. Hence a **dedicated collection** (`$extensions.com.4sh.ui-kit.figmaCollection`)
rather than adding them to `semantics` or `metrics`.

Expand Down Expand Up @@ -38,7 +38,7 @@ rather than adding them to `semantics` or `metrics`.
variables are `remote`, hence read-only (see `CLAUDE.md`).
- Keep the aliases as aliases: that is what preserves brand, light/dark and responsive.
- Literal values are in `px` (converted from `rem`, base 16), like the token files.
- `$extensions.com.4sh.ui-kit.skipped` lists 42 entries with nothing to create: `calc()`
- `$extensions.com.4sh.ui-kit.skipped` lists 46 entries with nothing to create: `calc()`
expressions, multi-value CSS shorthands, relative units (`em`, `ch`, `%`), duration /
easing / cursor / z-index, and internal plumbing.

Expand Down
140 changes: 136 additions & 4 deletions figma/component-vars.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
"Mode 1"
],
"counts": {
"tokens": 575,
"alias": 455,
"literal": 120,
"skipped": 42,
"tokens": 580,
"alias": 459,
"literal": 121,
"skipped": 46,
"derivedDescriptions": 197
},
"aliasTargets": [
Expand Down Expand Up @@ -79,6 +79,26 @@
"component": "ui-field",
"reason": "sans équivalent variable Figma (durée, easing, curseur, plan d’empilement, ratio)."
},
{
"cssVar": "--ui-field-float-label-indent",
"component": "ui-field",
"reason": "expression calc() : une variable Figma ne porte pas de calcul."
},
{
"cssVar": "--ui-field-float-label-indent-small",
"component": "ui-field",
"reason": "expression calc() : une variable Figma ne porte pas de calcul."
},
{
"cssVar": "--ui-field-float-label-scale",
"component": "ui-field",
"reason": "sans équivalent variable Figma (durée, easing, curseur, plan d’empilement, ratio)."
},
{
"cssVar": "--ui-field-float-label-scale-small",
"component": "ui-field",
"reason": "sans équivalent variable Figma (durée, easing, curseur, plan d’empilement, ratio)."
},
{
"cssVar": "--ui-field-focus-ring-opacity",
"component": "ui-field",
Expand Down Expand Up @@ -4634,6 +4654,118 @@
}
}
},
"float-label": {
"font-size": {
"$value": "{responsive.size.typography.text.sm}",
"$type": "dimension",
"$description": "Taille de police du libellé flottant en position haute.",
"$extensions": {
"com.figma": {
"resolvedType": "FLOAT",
"scopes": [
"FONT_SIZE"
],
"codeSyntax": {
"WEB": "var(--ui-field-float-label-font-size)"
}
},
"com.4sh.ui-kit": {
"cssVar": "--ui-field-float-label-font-size",
"component": "ui-field",
"entryPoint": "@4sh/ui-kit/forms/ui-field",
"descriptionSource": "scss"
}
}
},
"line-height": {
"$value": "1.25",
"$type": "number",
"$description": "Interligne du libellé flottant : c'est lui qui fixe la hauteur de la bande réservée.",
"$extensions": {
"com.figma": {
"resolvedType": "FLOAT",
"scopes": [
"LINE_HEIGHT"
],
"codeSyntax": {
"WEB": "var(--ui-field-float-label-line-height)"
}
},
"com.4sh.ui-kit": {
"cssVar": "--ui-field-float-label-line-height",
"component": "ui-field",
"entryPoint": "@4sh/ui-kit/forms/ui-field",
"descriptionSource": "scss"
}
}
},
"offset-x": {
"$value": "{metrics.units.md}",
"$type": "dimension",
"$description": "Décalage horizontal du libellé flottant, aligné sur l'inset du contrôle.",
"$extensions": {
"com.figma": {
"resolvedType": "FLOAT",
"scopes": [
"WIDTH_HEIGHT"
],
"codeSyntax": {
"WEB": "var(--ui-field-float-label-offset-x)"
}
},
"com.4sh.ui-kit": {
"cssVar": "--ui-field-float-label-offset-x",
"component": "ui-field",
"entryPoint": "@4sh/ui-kit/forms/ui-field",
"descriptionSource": "scss"
}
}
},
"offset-y": {
"$value": "{metrics.units.2xs}",
"$type": "dimension",
"$description": "Respiration au-dessus du libellé flottant dans la boîte, variante `in`.",
"$extensions": {
"com.figma": {
"resolvedType": "FLOAT",
"scopes": [
"WIDTH_HEIGHT"
],
"codeSyntax": {
"WEB": "var(--ui-field-float-label-offset-y)"
}
},
"com.4sh.ui-kit": {
"cssVar": "--ui-field-float-label-offset-y",
"component": "ui-field",
"entryPoint": "@4sh/ui-kit/forms/ui-field",
"descriptionSource": "scss"
}
}
},
"padding-x": {
"$value": "{metrics.units.2xs}",
"$type": "dimension",
"$description": "Inset horizontal du libellé flottant, variante `on` : c'est lui qui entaille le trait.",
"$extensions": {
"com.figma": {
"resolvedType": "FLOAT",
"scopes": [
"GAP"
],
"codeSyntax": {
"WEB": "var(--ui-field-float-label-padding-x)"
}
},
"com.4sh.ui-kit": {
"cssVar": "--ui-field-float-label-padding-x",
"component": "ui-field",
"entryPoint": "@4sh/ui-kit/forms/ui-field",
"descriptionSource": "scss"
}
}
}
},
"focus-ring-width": {
"$value": "{metrics.units.2xs}",
"$type": "dimension",
Expand Down
17 changes: 17 additions & 0 deletions projects/ui-kit/forms/src/lib/base-form-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { UiFeedbackLevel } from '@4sh/ui-kit/types';
export type FieldSize = 'default' | 'small';
/** Visual validation status (subset of `UiFeedbackLevel`, shared with `ui-helper`). */
export type FieldLevel = Extract<UiFeedbackLevel, 'default' | 'success' | 'error'>;
/**
* Placement of the floating label ("float label" mode):
* - `over`: rises above the box, where a classic label sits,
* - `in`: rises to a band reserved at the top **inside** the box,
* - `on`: rises **onto** the top border, notching it.
*/
export type FieldFloatLabel = 'over' | 'in' | 'on';

let nextUid = 0;

Expand Down Expand Up @@ -88,6 +95,16 @@ export abstract class BaseFormField<T> extends BaseFieldControl<T> {
size = input<FieldSize>('default');
/** Explicit validation status (`error` is forced when the control is invalid + touched). */
level = input<FieldLevel>('default');
/**
* Floating label placement. Unset = classic label above the box.
*
* The label then plays the placeholder role while at rest, so the native
* `placeholder` is neutralised (see `hasFloatLabel`).
*/
floatLabel = input<FieldFloatLabel>();

/** @ignore There is a label to float: a `floatLabel` alone has nothing to move. */
protected readonly hasFloatLabel = computed(() => !!this.floatLabel() && !!this.label());

/** @ignore Effective level: error when invalid, otherwise the requested `level`. */
protected readonly effectiveLevel = computed<FieldLevel>(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@
[required]="required()"
[size]="size()"
[level]="effectiveLevel()"
[floatLabel]="floatLabel()"
[filled]="isFilled()"
[disabled]="isDisabled()"
[readonly]="readonly()"
[message]="displayMessage()"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ $focus-ring-width: var(--ui-autocomplete-focus-ring-width, #{utils.$form-focus-r
gap: $tag-gap;
min-width: 0;
@include utils.field-inset-edges;
@include utils.field-float-inset;
}

// --- Selected value chip (role=option, roving focus, `multiple`) ----
Expand Down
10 changes: 8 additions & 2 deletions projects/ui-kit/forms/ui-autocomplete/src/lib/ui-autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,11 +491,17 @@ export class UiAutocomplete<T = unknown> extends BaseFormField<AutocompleteValue
})),
);

/** @ignore Placeholder hidden as soon as values are selected (`multiple`). */
/** @ignore Placeholder hidden as soon as values are selected (`multiple`), and
* while a floating label is at rest: it already holds that spot. */
protected readonly effectivePlaceholder = computed(() =>
this.multiple() && this.selectedValues().length ? '' : (this.placeholder() ?? ''),
this.hasFloatLabel() || (this.multiple() && this.selectedValues().length)
? ''
: (this.placeholder() ?? ''),
);

/** @ignore Keeps a floating label raised once focus leaves. */
protected readonly isFilled = computed(() => this.hasText() || this.selectedValues().length > 0);

/** @ignore Chips are removable unless the field is disabled/readonly. */
protected readonly canRemove = computed(() => !this.isDisabled() && !this.readonly());

Expand Down
14 changes: 14 additions & 0 deletions projects/ui-kit/forms/ui-autocomplete/ui-autocomplete.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,20 @@ instant — indispensable pour de très grandes listes. `virtualScroll` l'active

<Canvas of={AutocompleteStories.VirtualScroll} />

## Libellé flottant

`floatLabel` fait descendre le libellé **dans** le champ, où il tient le rôle du placeholder,
et le fait remonter au focus ou dès que le champ porte une valeur. Trois positions hautes :
`over` (au-dessus de la boîte), `in` (dans une bande réservée en haut de la boîte, qui grandit
d'autant) et `on` (à cheval sur le trait, qu'il entaille). Le `placeholder` est neutralisé tant
que `floatLabel` et `label` sont tous les deux renseignés : les deux textes se superposeraient.


<Canvas of={AutocompleteStories.FloatLabel} />

Le mécanisme est celui du shell partagé `ui-field`, commun à tous les champs à boîte : détail
des trois variantes, du déclenchement et des conséquences de mise en page sur [`ui-input`](?path=/docs/components-ui-forms-ui-input--docs).

## API

<ArgTypes of={AutocompleteStories} />
Expand Down
38 changes: 38 additions & 0 deletions projects/ui-kit/forms/ui-autocomplete/ui-autocomplete.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,14 @@ const meta: Meta<UiAutocomplete> = {
description: 'Taille du champ.',
table: { type: { summary: "'default' | 'small'" }, defaultValue: { summary: "'default'" } },
},
floatLabel: {
control: 'select',
options: [undefined, 'over', 'in', 'on'],
labels: { undefined: 'aucun (libellé classique)' },
description:
'Libellé flottant : le libellé descend dans le champ, où il tient le rôle du placeholder, et remonte au focus ou dès que le champ porte une valeur. Le `placeholder` est alors neutralisé.',
table: { type: { summary: 'FieldFloatLabel' }, defaultValue: { summary: 'undefined' } },
},
disabled: {
control: 'boolean',
description: 'Désactive le champ (attribut natif).',
Expand Down Expand Up @@ -761,3 +769,33 @@ export const VirtualScroll: Story = {
`,
}),
};

// --- Libellé flottant ---------------------------------------------------
/**
* `floatLabel` fait descendre le libellé **dans** le champ, où il tient le rôle du
* placeholder, puis le fait remonter au focus ou dès qu'une valeur est présente. Trois
* positions hautes : `over` (au-dessus de la boîte), `in` (dans une bande réservée en haut
* de la boîte, qui grandit d'autant) et `on` (à cheval sur le trait, qu'il entaille).
*/
export const FloatLabel: Story = {
render: () => ({
props: {
over: completer(COUNTRY_NAMES, (x) => x),
inside: completer(COUNTRY_NAMES, (x) => x),
on: completer(COUNTRY_NAMES, (x) => x),
a: null,
b: null,
c: 'France',
},
template: `
<div style="display:grid; grid-template-columns:repeat(3, 200px); gap:28px 20px; align-items:start;">
<ui-autocomplete floatLabel="over" label="Over label" [(ngModel)]="a"
[suggestions]="over.results" (completeMethod)="over.complete($event)" />
<ui-autocomplete floatLabel="in" label="In label" [(ngModel)]="b"
[suggestions]="inside.results" (completeMethod)="inside.complete($event)" />
<ui-autocomplete floatLabel="on" label="On label" [(ngModel)]="c" [dropdown]="true"
[suggestions]="on.results" (completeMethod)="on.complete($event)" />
</div>
`,
}),
};
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@
[placeholder]="resolvedPlaceholder()"
[size]="size()"
[level]="effectiveLevel()"
[floatLabel]="floatLabel()"
[required]="required()"
[disabled]="isDisabled()"
[readonly]="triggerReadonly()"
Expand Down
17 changes: 17 additions & 0 deletions projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,23 @@ la sélection. En mode `timeOnly`, l'icône du déclencheur devient une horloge.
<Canvas of={DatepickerStories.Clearable} />
<Canvas of={DatepickerStories.TimeOnly} />

## Libellé flottant

`floatLabel` fait descendre le libellé **dans** le champ, où il tient le rôle du placeholder,
et le fait remonter au focus ou dès que le champ porte une valeur. Trois positions hautes :
`over` (au-dessus de la boîte), `in` (dans une bande réservée en haut de la boîte, qui grandit
d'autant) et `on` (à cheval sur le trait, qu'il entaille). Le `placeholder` est neutralisé tant
que `floatLabel` et `label` sont tous les deux renseignés : les deux textes se superposeraient.

Le déclencheur étant un `ui-input`, l'option se transmet telle quelle. Le libellé reste en
position haute pendant toute l'ouverture du calendrier, y compris quand celui-ci prend le
focus.

<Canvas of={DatepickerStories.FloatLabel} />

Le mécanisme est celui du shell partagé `ui-field`, commun à tous les champs à boîte : détail
des trois variantes, du déclenchement et des conséquences de mise en page sur [`ui-input`](?path=/docs/components-ui-forms-ui-input--docs).

## Saisie manuelle (`allowInput`)

Par défaut le champ est en lecture seule : la date se choisit uniquement dans le panneau.
Expand Down
Loading
Loading