Files
cunningham/openspec/changes/archive/2026-01-26-field-variant-classic/design.md
Nathan Panchout 1468973f81 📝(openspec) add field-variant-classic change artifacts
- Add proposal, design, specs and tasks documentation
- Add verification report
- Archive completed change

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 14:32:26 +01:00

5.2 KiB

Context

Le design system Cunningham utilise actuellement un pattern "floating label" pour tous les champs de formulaire. Ce pattern est implémenté via le composant LabelledBox qui est utilisé par Input, TextArea, Select et DatePicker.

Architecture actuelle :

Field (wrapper: state, text, fullWidth)
  └── LabelledBox (gère le label flottant)
        └── Composant natif (input, textarea, etc.)

Le LabelledBox gère :

  • Le positionnement du label (absolu, avec transition)
  • L'état labelAsPlaceholder (label en grand = placeholder, label petit = au-dessus)
  • Le CSS des transitions

Goals / Non-Goals

Goals:

  • Ajouter une variante classic avec label externe et placeholder natif
  • Maintenir la rétrocompatibilité (floating = défaut)
  • Supporter la variante classic sur tous les composants concernés
  • Avoir un champ plus compact en mode classic

Non-Goals:

  • Changer le comportement par défaut des composants existants
  • Créer des composants séparés (ClassicInput, etc.)
  • Modifier Checkbox, Radio, Switch (déjà label externe)

Decisions

1. Type FieldVariant partagé

Créer un type exporté dans packages/react/src/components/Forms/types.ts :

export type FieldVariant = "floating" | "classic";

Pourquoi : Assure la cohérence du typage entre tous les composants.

2. Architecture en mode Classic

En mode classic, le label est rendu EN DEHORS du wrapper bordé, pas à l'intérieur :

Field (wrapper)
  └── label.c__input__label (EN DEHORS - nouveau)
  └── div.c__input__wrapper (wrapper bordé)
        └── Composant natif (input)

Vs mode floating (existant) :

Field (wrapper)
  └── div.c__input__wrapper (wrapper bordé)
        └── LabelledBox (gère le label flottant)
              └── Composant natif (input)

Pourquoi : Le label doit être visuellement au-dessus de la bordure du champ, pas à l'intérieur.

3. CSS pour la variante classic

Chaque composant a sa propre classe label et un modifier --classic sur le wrapper :

// Label externe
.c__input__label {
  display: block;
  font-size: var(--c--components--forms-labelledbox--classic-label-font-size);
  color: var(--c--components--forms-labelledbox--label-color--small);
  margin-bottom: var(--c--components--forms-labelledbox--classic-label-margin-bottom);
}

// Wrapper en mode classic
.c__input__wrapper--classic {
  align-items: center;  // Centrage vertical du contenu
  height: 2.75rem;      // Hauteur réduite (vs 3.5rem en floating)
}

Classes par composant :

  • Input : .c__input__label, .c__input__wrapper--classic
  • TextArea : .c__textarea__label, .c__textarea__wrapper--classic
  • Select : .c__select__label, .c__select--classic
  • DatePicker : .c__date-picker__label, .c__date-picker--classic

4. Gestion du placeholder par composant

Chaque composant gère son placeholder selon la variante :

Input / TextArea :

// Si variant="classic", passer placeholder au <input>/<textarea>
// Si variant="floating", ne pas passer placeholder (ignoré)
<input placeholder={variant === "classic" ? placeholder : undefined} />

Select :

// Si variant="classic" et pas de sélection, afficher placeholder stylisé
{variant === "classic" && !selectedItem && placeholder && (
  <span className="c__select__placeholder">{placeholder}</span>
)}

5. Props ajoutées sur chaque composant

// Input, TextArea, Select, DatePicker, DateRangePicker
type Props = {
  variant?: FieldVariant;      // NOUVEAU - défaut: "floating"
  placeholder?: string;        // Existe peut-être déjà, à vérifier
  // ... props existantes
};

6. Design tokens

Tokens existants réutilisés dans LabelledBox/tokens.ts :

"classic-label-margin-bottom": defaults.globals.spacings.xs,
"classic-label-font-size": defaults.globals.font.sizes.s,

Pour Select, token supplémentaire pour le placeholder :

"placeholder-color": defaults.contextuals.content.semantic.neutral.tertiary,

7. DateRangePicker spécifique

DateRangePicker a deux labels (startLabel, endLabel) affichés côte à côte au-dessus du wrapper :

.c__date-picker__range__labels
  └── label (startLabel)
  └── spacer
  └── label (endLabel)
.c__date-picker__wrapper
  └── DateFieldBox (start)
  └── separator
  └── DateFieldBox (end)

Risks / Trade-offs

[Label externe vs LabelledBox] → Duplication de la structure label. Mitigation : tokens CSS partagés pour la cohérence visuelle.

[Hauteur réduite en classic] → Les champs classic sont plus compacts (2.75rem vs 3.5rem). C'est intentionnel pour un look plus traditionnel.

[Select placeholder vs Input placeholder] → Le Select n'a pas de placeholder natif, on simule avec un span. Mitigation : même couleur via tokens.

Migration Plan

Pas de migration nécessaire. Le changement est additif :

  • Nouveau comportement opt-in via variant="classic"
  • Comportement existant inchangé (floating par défaut)
  • Aucun breaking change

Open Questions

Résolues :

  • Stories Storybook ajoutées pour chaque composant en mode classic
  • Tokens dans LabelledBox/tokens.ts (réutilisés par tous les composants)