Skip to content

FSHSP-160 feat(editor): ui-editor, éditeur de texte riche sans moteur tiers - #92

Merged
LBU4SH merged 6 commits into
mainfrom
feat/ui-editor
Aug 26, 2026
Merged

FSHSP-160 feat(editor): ui-editor, éditeur de texte riche sans moteur tiers#92
LBU4SH merged 6 commits into
mainfrom
feat/ui-editor

Conversation

@LBU4SH

@LBU4SH LBU4SH commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Contexte

FSHSP-160 — le kit s'arrêtait à ui-textarea : dès qu'un projet avait besoin de gras, de listes ou d'un lien dans un champ libre, il installait Quill ou TipTap et repartait avec un champ qui ne ressemble à aucun autre du formulaire.

ui-editor couvre ce besoin sans ajouter une seule dépendance runtime : les commandes de mise en forme s'appuient sur l'API d'édition native du navigateur, isolée dans ui-editor-commands.ts pour qu'un futur moteur ne se substitue qu'à ce fichier. Le kit garde tslib comme unique dépendance.

Bâti sur le shell ui-field en mode multiligne, il partage libellé, boîte, niveaux, helper et validation avec les huit autres champs (BaseFormField, donc [(ngModel)], reactive forms et Signal Forms sans code supplémentaire).

Ce que contient la PR

Barre d'outils configurable (tools, toolbarPosition) formant un seul arrêt de tabulation : roving tabindex, aria-pressed sur les bascules uniquement, Échap rend le focus à la saisie. Cliquer un outil ne déplace pas le focus et préserve la sélection.

Deux listes déroulantes par défaut, blockFormat (Normal, Titre 1-3) et fontFamily. Elles affichent la valeur en vigueur — « Normal », « Inter » — et non leur propre nom. La police est lue sur le rendu effectif : dans un titre, qui porte --fontfamily-title, la liste affiche « DM Sans ». Largeur fixe et commune, sinon la barre saute à chaque changement de valeur.

fontSize existe mais est volontairement hors de la barre par défaut : à l'écran il ferait doublon avec blockFormat — les deux grossissent le texte — alors que seul le niveau porte la structure du document. Le proposer à côté invite à fabriquer des titres qui n'en sont pas. Il s'active via tools.

Sécurité de la valeur. Le HTML passe par DomSanitizer avant tout affichage, et le collage est réduit à une liste blanche de balises et de classes : script/style sont supprimés avec leur contenu, javascript: retiré des liens, styles en ligne et balises étrangères écartés. La décoration que insertHTML réinjecte après coup est nettoyée sur place.

Tokens uniquement. Police et taille sont écrites en classe liée aux tokens, jamais en style en ligne : rebinder --fontfamily-title déplace tout le contenu déjà saisi. 37 hooks --ui-editor-* documentés alimentent la table Theming générée.

Vérifications

Tout a été observé dans Storybook, pas seulement compilé :

Test Résultat
Gras sur sélection <b> posé, aria-pressed=true, focus conservé
Frappe caractères dans l'ordre, aucun saut de curseur
Roving tabindex un seul tabindex=0 ; ⭢ / Home / End / Échap conformes
Bloc de code p → pre → p, aria-pressed suit le curseur
Police / niveau h1 → « Titre 1 » + « DM Sans » ; paragraphe → « Normal » + « Inter »
Collage hostile script/style supprimés, javascript: retiré, bon lien et gras conservés
Compteur 17 pour 24 caractères de HTML — mesure bien le texte
Clair / sombre, désactivé, Signal Forms conformes

ui-kit:build, docs:config:check (0 variable sans ///), components:check et Prettier sont verts.

Points à noter pour la revue

  • text-decoration a été ajouté aux propriétés reconnues de scripts/component-vars.build.mjs (même forme que cursor / object-fit), pour que le hook de décoration des liens respecte la convention de nommage.
  • Le contenu produit par l'éditeur est créé hors du template et ne porte donc aucun attribut de scope : il est stylé via ::ng-deep cantonné à la zone de saisie (même précédent que ui-card / ui-image).
  • Le libellé est repris en aria-label plutôt qu'en <label for> : un contenteditable n'est pas un contrôle de formulaire.
  • L'outil link utilise window.prompt en v1.
  • Le node-id Figma de la story pointe encore la racine du UI Kit, à mettre à jour quand le composant existera dans Figma.
  • Insertion d'image non incluse, volontairement : la vraie question n'est pas l'insertion mais le stockage (base64 dans la valeur vs contrat d'upload confié au projet). À cadrer dans un ticket dédié.

🤖 Generated with Claude Code

LBU4SH added 4 commits August 26, 2026 08:37
Rich-text field built on the ui-field shell in multiline mode, with no
third-party editing engine: the formatting commands rely on the browser's
native editing API, confined to ui-editor-commands so a future engine only
has to replace that file. The kit keeps tslib as its sole runtime dependency.

- Configurable toolbar (tools, toolbarPosition) with a single tab stop:
  roving tabindex, aria-pressed on the toggles only, Escape returns focus to
  the editing area. Clicking a tool preserves the selection.
- Value is HTML, sanitised through DomSanitizer and reduced to a tag
  whitelist on paste; script/style are dropped with their content and
  insertHTML's own decoration is stripped back off in place.
- maxlength and showCount measure the text, never the markup.
- 27 --ui-editor-* hooks, documented for the generated theming table.

Adds `text-decoration` to the recognised property names in
component-vars.build.mjs (same shape as cursor / object-fit) so the link
decoration hook conforms to the naming convention.
- codeBlock toggles the block under the caret between <pre> and a plain
  paragraph, and reports its state through aria-pressed like the other
  toggles. No syntax highlighting: it would pull in a third-party dependency.
- fontFamily renders a dropdown rather than a button, since it picks among
  values instead of toggling one. The list is closed to the three system
  families: a free picker would write an arbitrary font into the value,
  outside the --fontfamily-* tokens.

The family is stored as a class, never as an inline style, so a project
rebinding --fontfamily-title moves the content already typed with it. Those
classes are the only ones the paste whitelist lets through, alongside the
new pre/code tags.

execCommand('fontName') emits obsolete <font face> elements, so the command
runs against a marker that is immediately rewritten into a span.class. The
dropdown is also the only toolbar control that takes the focus, so the
caret is remembered and restored before the command applies.
The single "font" dropdown conflated two different things: what a block IS
and how it looks. They are now separate.

- blockFormat (Normal, Titre 1-3) sets the block itself. A heading carries
  document structure, which a screen reader navigates; applying the title
  face to a paragraph never did. It reports no value at all when the caret
  sits in a block the list does not offer (code block, list item), rather
  than claiming "Normal".
- fontFamily now lists the REAL face each token resolves to — Inter, DM
  Sans, Roboto Mono — read from the computed style instead of hardcoded: a
  project rebinding --fontfamily-base would otherwise be shown a menu
  naming a font it no longer uses.
- fontSize (Petit -> Très grand) exists but is deliberately NOT in the
  default bar: on screen it duplicates blockFormat, since both make text
  bigger, yet only one carries structure. Offering both side by side
  invites headings that are merely large text. Opt in through `tools`.

Family and size are written as classes bound to the tokens, never as an
inline style, so rebinding a token moves the content already typed. h1-h3
join pre/code in the paste whitelist.

The dropdowns are the only toolbar controls that take the focus, so the
caret is remembered and restored before their command runs.
The closed dropdowns advertised their own name ("Niveau", "Police"), which
is where the current formatting should be readable instead. They now show
what the text actually uses: "Normal" and "Inter" at rest.

The family is read from the RENDERED font rather than inferred from the
absence of a class. A heading carries --fontfamily-title from its own
styling, so the list reports "DM Sans" there — claiming the base family
would have named a font the reader cannot see.

blockFormat keeps a blank state, and only it: in a code block or a list
item there is no honest level to display, and showing "Normal" would
invite a click that reformats the block. The spelled-out names stay as
aria-label, so nothing is lost for screen readers.

Every dropdown now shares one fixed width. Sizing them to their content
made the whole toolbar jump whenever a longer value was picked.
LBU4SH added 2 commits August 26, 2026 08:49
- The toolbar's role="toolbar" container carries the keyboard handler while
  focus stays on its children (roving tabindex) — same ARIA pattern already
  used by ui-menu, disabled the same way there.
- Removed the useless `null` initializer on `next` in onToolbarKeydown:
  every switch branch either assigns it or returns.
…n stories

Lint:
- The toolbar's role="toolbar" container carries the keyboard handler while
  focus stays on its children (roving tabindex) — same ARIA pattern already
  used by ui-menu, disabled the same way there.
- Removed the useless `null` initializer on `next` in onToolbarKeydown:
  every switch branch either assigns it or returns.

Dropdown width: replaced the measured 132px with 13ch (same precedent as
ui-empty-state's max-width). It now scales with $select-font-size instead
of a guessed pixel count for the longest label ("Roboto Mono").

Stories: widened the ui-editor demo wrapper (420px -> 640px) so the toolbar
and its dropdowns have visible room — scoped to this component's stories only.
@LBU4SH
LBU4SH merged commit 2779928 into main Aug 26, 2026
3 checks passed
@LBU4SH
LBU4SH deleted the feat/ui-editor branch August 26, 2026 07:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant