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
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,55 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr

## [Unreleased]

### Fixed

- **`ui-progress-bar` : la barre disparaissait avec `valuePosition="bottom"`** (FSHSP-224).
En colonne, `align-items: flex-end` ramenait la piste à sa largeur de contenu, soit 0 px.
La piste s'étire désormais, seul le libellé reste aligné à droite.

- **`ui-file-upload` : messages d'erreur invisibles en mode clair** (FSHSP-224). Le message
de refus et le texte d'une ligne en erreur de `ui-file-upload-list` prenaient
`--informative-errorhigh-content-default`, le contenu prévu pour la surface d'erreur forte :
blanc sur fond neutre, mesuré à 1,07:1. Ils passent à `--form-error-content-default`, comme
les autres champs du kit (4,51:1 en clair, 12,26:1 en sombre). Les poignées
`--ui-file-upload-message-color` et `--ui-file-upload-list-color-error` gardent leur nom,
seule leur valeur par défaut change.

- **`ui-file-upload` : avec `name`, un `<form>` natif recevait un fichier vide** (FSHSP-224).
Le nom était posé sur le sélecteur, que le composant vide après chaque choix pour qu'un même
fichier choisi deux fois redéclenche `change`. Un champ caché porte désormais la sélection
courante sous `name`. Sans `name`, rien ne change.

- **`ui-editor` : la zone d'édition réécrivait la saisie de l'utilisateur** (FSHSP-224). Elle
était resynchronisée depuis la valeur assainie dès que l'assainissement changeait quoi que ce
soit, or `DomSanitizer` encode les accents (`é` devient `&#233;`) et retire tout `style`.
- Taper un caractère accentué renvoyait le curseur au début : `abcdé fin` donnait `nabcdé fi`.
- Chaque mise en forme perdait la sélection, et la suivante demandait de resélectionner.
- L'alignement (`style="text-align: …"`) et le retrait (`<blockquote style="margin: …">`)
disparaissaient au clic qui les appliquait et à chaque valeur rechargée (la story
`TextAlign` elle-même s'affichait sans alignement). Ce `style` est désormais conservé,
réduit à une liste fermée (`text-align`, `margin`, `padding`, `border: none`, aucune
`url()`), la même que dans le kit React.
- **`ui-editor` : recolorer un passage laissait ses mots déjà colorés inchangés** (FSHSP-224).
La classe de la même famille restée à l'intérieur (couleur, surlignage, police ou taille)
l'emportait, étant plus proche du texte. Elle est retirée à la conversion, et la sélection est
reposée sur le texte mis en forme.

- **`ui-toast` : la bande de chaque toast captait le pointeur à côté de la carte**
(FSHSP-224). Elle fait toute la largeur de la pile (360 px) quand la carte épouse son
contenu : elle avalait les clics destinés à la page et suspendait le compte à rebours dès que
le pointeur passait sur la même ligne. Le pointeur et la pause au survol reviennent à la carte.

- **`ui-toast` : un message en attente au-delà de `stackVisibleLimit` expirait sans avoir
été affiché** (FSHSP-224). Son délai était armé dès l'arrivée : le message n'était jamais vu,
ni annoncé aux lecteurs d'écran. Le compte à rebours démarre désormais quand la carte paraît,
et repart de `life` si elle est repoussée hors de la pile, comme dans le kit React.

- **`ui-menu` : cliquer un parent de cascade refermait son sous-menu** (FSHSP-224). En
`submenus="flyout"` (donc aussi dans `ui-context-menu`), le survol avait déjà ouvert le
sous-menu et le clic le basculait. Au tactile, où le tap émet un `mouseenter` avant le clic,
la cascade ne s'ouvrait jamais. Le clic ouvre désormais, sans refermer.

## [0.11.0] - 2026-09-18

### Added
Expand Down
16 changes: 8 additions & 8 deletions docs/SECURITY-PRACTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,14 @@ Audit du 2026-08-27 sur `projects/`, `src/`, `storybook/`, `scripts/` :
**un seul `bypassSecurityTrust…()` dans tout le dépôt**, et aucun `eval()`,
`new Function()` ni `document.write()`.

| # | Fichier | API | Justification |
| --- | ---------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `projects/ui-kit/base/ui-image/src/lib/ui-image.ts` | `bypassSecurityTrustHtml` | **Inévitable** : l'assainisseur d'Angular supprime `<svg>` en entier, donc `sanitize()` ne rendrait rien. Valeur assainie en amont par `sanitizeInlineSvg()`. Voir §3. |
| 2 | `projects/ui-kit/base/ui-image/src/lib/ui-image.html` | `[innerHTML]` | Réception du SVG assaini de l'exception 1. |
| 3 | `projects/ui-kit/base/ui-image/src/lib/ui-image-svg.ts` | `innerHTML =` | Écriture dans un `<template>` **détaché** (contenu inerte : rien ne s'exécute, rien n'est chargé). C'est l'étape de parsing du scrub. |
| 4 | `projects/ui-kit/forms/ui-editor/src/lib/ui-editor.ts` | `innerHTML =` | Valeur issue de `sanitize(SecurityContext.HTML, …)`. Écriture directe imposée par le `contenteditable` : une liaison réécrirait la zone à chaque frappe et effondrerait le caret. |
| 5 | `projects/ui-kit/forms/ui-editor/src/lib/ui-editor-commands.ts` ×2 | `innerHTML =` | `<template>` détaché, contenu inerte — parsing du scrub de collage, et projection en texte pour le compteur de caractères. |
| 6 | `projects/ui-kit/informative/ui-tooltip/src/lib/ui-tooltip-panel.html` | `[innerHTML]` | Mode `escape=false`, **opt-in**. Ce n'est pas un contournement : la liaison passe par l'assainisseur, aucun bypass n'est levé. |
| # | Fichier | API | Justification |
| --- | ---------------------------------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 1 | `projects/ui-kit/base/ui-image/src/lib/ui-image.ts` | `bypassSecurityTrustHtml` | **Inévitable** : l'assainisseur d'Angular supprime `<svg>` en entier, donc `sanitize()` ne rendrait rien. Valeur assainie en amont par `sanitizeInlineSvg()`. Voir §3. |
| 2 | `projects/ui-kit/base/ui-image/src/lib/ui-image.html` | `[innerHTML]` | Réception du SVG assaini de l'exception 1. |
| 3 | `projects/ui-kit/base/ui-image/src/lib/ui-image-svg.ts` | `innerHTML =` | Écriture dans un `<template>` **détaché** (contenu inerte : rien ne s'exécute, rien n'est chargé). C'est l'étape de parsing du scrub. |
| 4 | `projects/ui-kit/forms/ui-editor/src/lib/ui-editor.ts` | `innerHTML =` | Valeur issue de `sanitize(SecurityContext.HTML, …)`, plus le seul `style` d'alignement et de retrait (`sanitizeStyle()`, liste fermée, aucune `url()`). Écriture directe imposée par le `contenteditable` : une liaison réécrirait la zone à chaque frappe et effondrerait le caret. |
| 5 | `projects/ui-kit/forms/ui-editor/src/lib/ui-editor-commands.ts` ×2 | `innerHTML =` | `<template>` détaché, contenu inerte — parsing du scrub de collage, et projection en texte pour le compteur de caractères. |
| 6 | `projects/ui-kit/informative/ui-tooltip/src/lib/ui-tooltip-panel.html` | `[innerHTML]` | Mode `escape=false`, **opt-in**. Ce n'est pas un contournement : la liaison passe par l'assainisseur, aucun bypass n'est levé. |

---

Expand Down
4 changes: 2 additions & 2 deletions figma/component-vars.json
Original file line number Diff line number Diff line change
Expand Up @@ -13651,7 +13651,7 @@
}
},
"color-error": {
"$value": "{semantics.informative.errorHigh.content.default}",
"$value": "{semantics.form.error.content.default}",
"$type": "color",
"$description": "Couleur du texte d'une ligne en erreur.",
"$extensions": {
Expand Down Expand Up @@ -13837,7 +13837,7 @@
},
"message": {
"color": {
"$value": "{semantics.informative.errorHigh.content.default}",
"$value": "{semantics.form.error.content.default}",
"$type": "color",
"$description": "Couleur d'un message d'erreur.",
"$extensions": {
Expand Down
92 changes: 92 additions & 0 deletions projects/ui-kit/forms/ui-editor/src/lib/ui-editor-commands.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { SecurityContext } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { DomSanitizer } from '@angular/platform-browser';
import { afterEach, describe, expect, it } from 'vitest';
import {
convertColorMarkers,
EDITOR_COLORS,
EDITOR_HIGHLIGHTS,
sanitizeEditorHtml,
sanitizeStyle,
} from './ui-editor-commands';

function sanitize(html: string): string {
const sanitizer = TestBed.inject(DomSanitizer);
return sanitizeEditorHtml(html, (h) => sanitizer.sanitize(SecurityContext.HTML, h) ?? '');
}

describe('sanitizeEditorHtml', () => {
it('keeps the alignment and indentation the browser writes, byte for byte', () => {
const aligned = '<p style="text-align: center;">Un</p>';
const indented =
'<blockquote style="margin: 0 0 0 40px; border: none; padding: 0px;"><p>Deux</p></blockquote>';
expect(sanitize(aligned)).toBe(aligned);
expect(sanitize(indented)).toBe(indented);
});

it('drops every other declaration, and still sanitises the rest', () => {
expect(sanitize('<p style="color: red; text-align: right">a</p>')).toBe(
'<p style="text-align: right;">a</p>',
);
expect(sanitize('<p style="background: url(https://evil.example/x)">a</p>')).toBe('<p>a</p>');
expect(
sanitize('<p style="text-align: center" onclick="alert(1)">a</p><script>x</script>'),
).toBe('<p style="text-align: center;">a</p>');
});

it('does not honour a relay class forged in the value', () => {
expect(sanitize('<p class="ui-editor-style-0">a</p><p style="text-align: left">b</p>')).toBe(
'<p>a</p><p style="text-align: left;">b</p>',
);
});
});

describe('sanitizeStyle', () => {
it('lets no url() or expression through', () => {
expect(sanitizeStyle('margin: url(x)')).toBe('');
expect(sanitizeStyle('text-align: expression(alert(1))')).toBe('');
expect(sanitizeStyle('border: 1px solid red')).toBe('');
});
});

describe('convertColorMarkers', () => {
const red = EDITOR_COLORS[0].className;
const blue = EDITOR_COLORS[1].className;
const highlight = EDITOR_HIGHLIGHTS[0].className;
let root: HTMLElement;

afterEach(() => root?.remove());

function mount(html: string): HTMLElement {
root = document.createElement('div');
root.setAttribute('contenteditable', 'true');
root.append(...new DOMParser().parseFromString(html, 'text/html').body.childNodes);
document.body.append(root);
return root;
}

it('recolors a word that already had a color, and only the color', () => {
mount(
`<p><font color="#010203"><span class="${red} ${highlight}">mot</span> et suite</font></p>`,
);
convertColorMarkers(root, blue);
expect(root.innerHTML).toBe(
`<p><span class="${blue}"><span class="${highlight}">mot</span> et suite</span></p>`,
);
});

it('unwraps a span the recolor left bare', () => {
mount(`<p><font color="#010203"><span class="${red}">mot</span> et suite</font></p>`);
convertColorMarkers(root, blue);
expect(root.innerHTML).toBe(`<p><span class="${blue}">mot et suite</span></p>`);
});

it('puts the selection back over the formatted text', () => {
mount('<p>avant <font color="#010203">formaté</font> après</p>');
getSelection()?.selectAllChildren(root.querySelector('font')!);
convertColorMarkers(root, blue);
const selection = getSelection()!;
expect(selection.isCollapsed).toBe(false);
expect(selection.toString()).toBe('formaté');
});
});
122 changes: 117 additions & 5 deletions projects/ui-kit/forms/ui-editor/src/lib/ui-editor-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,17 +276,48 @@ export function applyFontFamily(): void {

/** Rewrites the `<font>` elements `fontName` just produced into `<span class>`. */
export function convertFontMarkers(root: ParentNode, className: string): void {
convertMarkers(root, `font[face="${FONT_MARKER}"]`, className);
convertMarkers(root, `font[face="${FONT_MARKER}"]`, className, FONT_CLASSES);
}

/** @internal Replaces the matched legacy `<font>` elements with a classed span. */
function convertMarkers(root: ParentNode, selector: string, className: string): void {
function convertMarkers(
root: ParentNode,
selector: string,
className: string,
family: ReadonlySet<string>,
): void {
const spans: HTMLElement[] = [];
for (const el of Array.from(root.querySelectorAll(selector))) {
const span = document.createElement('span');
span.className = className;
span.append(...Array.from(el.childNodes));
el.replaceWith(span);
spans.push(span);
}

// A same-family class left inside would win over the new one.
for (const span of spans) {
for (const inner of Array.from(span.querySelectorAll<HTMLElement>('[class]'))) {
for (const cls of Array.from(inner.classList))
if (family.has(cls)) inner.classList.remove(cls);
if (inner.classList.length > 0) continue;
inner.removeAttribute('class');
if (inner.tagName === 'SPAN' && inner.attributes.length === 0) {
inner.replaceWith(...Array.from(inner.childNodes));
}
}
}

// Replacing the nodes collapsed the selection: put it back.
const live = spans.filter((span) => span.isConnected);
const selection = document.getSelection();
if (!live.length || !selection) return;
const last = live[live.length - 1];
const range = document.createRange();
range.setStart(live[0], 0);
range.setEnd(last, last.childNodes.length);
selection.removeAllRanges();
selection.addRange(range);
}

/**
Expand Down Expand Up @@ -337,7 +368,7 @@ export function applyFontSize(): void {

/** Rewrites the `<font>` elements `fontSize` just produced into `<span class>`. */
export function convertSizeMarkers(root: ParentNode, className: string): void {
convertMarkers(root, `font[size="${SIZE_MARKER}"]`, className);
convertMarkers(root, `font[size="${SIZE_MARKER}"]`, className, SIZE_CLASSES);
}

/** Text size active at the caret, or `null` when the text uses the default. */
Expand Down Expand Up @@ -405,7 +436,7 @@ export function applyTextColor(): void {

/** Rewrites the `<font>` elements `foreColor` just produced into `<span class>`. */
export function convertColorMarkers(root: ParentNode, className: string): void {
convertMarkers(root, `font[color="${COLOR_MARKER}"]`, className);
convertMarkers(root, `font[color="${COLOR_MARKER}"]`, className, COLOR_CLASSES);
}

/** Text color active at the caret, or `null` when the text uses the default. */
Expand Down Expand Up @@ -436,7 +467,12 @@ export function applyHighlightColor(): void {

/** Rewrites the `<span style>` elements `hiliteColor` just produced into `<span class>`. */
export function convertHighlightMarkers(root: ParentNode, className: string): void {
convertMarkers(root, `[style*="background-color: ${HILITE_MARKER_RGB}"]`, className);
convertMarkers(
root,
`[style*="background-color: ${HILITE_MARKER_RGB}"]`,
className,
HIGHLIGHT_CLASSES,
);
}

/** Highlight color active at the caret, or `null` when the text carries none. */
Expand Down Expand Up @@ -531,6 +567,82 @@ export function insertText(text: string): void {
document.execCommand('insertText', false, text);
}

// --- Value sanitisation --------------------------------------------------

const LENGTH = String.raw`(?:0|-?\d+(?:\.\d+)?(?:px|em|rem|%))`;
const LENGTHS = new RegExp(`^${LENGTH}(?:\\s+${LENGTH}){0,3}$`);

/** The only `style` a value keeps: alignment and indentation. Same list as the React kit. */
const STYLE_ALLOWLIST: Record<string, RegExp> = {
'text-align': /^(?:left|right|center|justify|start|end)$/,
margin: LENGTHS,
'margin-left': LENGTHS,
'margin-right': LENGTHS,
'margin-inline-start': LENGTHS,
padding: LENGTHS,
border: /^none$/,
};

/** Keeps only the allowed declarations of a `style` attribute. */
export function sanitizeStyle(style: string): string {
const kept: string[] = [];
for (const declaration of style.split(';')) {
const colon = declaration.indexOf(':');
if (colon < 0) continue;
const property = declaration.slice(0, colon).trim().toLowerCase();
const value = declaration.slice(colon + 1).trim().toLowerCase();
const pattern = STYLE_ALLOWLIST[property];
if (pattern?.test(value)) kept.push(`${property}: ${value};`);
}
return kept.join(' ');
}

const STYLE_RELAY = 'ui-editor-style-';

/** Sanitises a value with `sanitize`, relaying the allowed `style` through a class it keeps. */
export function sanitizeEditorHtml(html: string, sanitize: (html: string) => string): string {
if (typeof DOMParser === 'undefined' || !/\sstyle\s*=/i.test(html)) return sanitize(html);

const styles: string[] = [];
const source = parseInert(html);
for (const el of Array.from(source.querySelectorAll('*'))) {
const forged = Array.from(el.classList).filter((cls) => cls.startsWith(STYLE_RELAY));
if (forged.length) {
el.classList.remove(...forged);
if (!el.classList.length) el.removeAttribute('class');
}
const style = el.getAttribute('style');
if (style === null) continue;
el.removeAttribute('style');
const kept = sanitizeStyle(style);
if (!kept) continue;
el.classList.add(`${STYLE_RELAY}${styles.length}`);
styles.push(kept);
}

const safe = sanitize(source.innerHTML);
if (!styles.length) return safe;

const out = parseInert(safe);
for (const el of Array.from(out.querySelectorAll(`[class*="${STYLE_RELAY}"]`))) {
for (const cls of Array.from(el.classList)) {
if (!cls.startsWith(STYLE_RELAY)) continue;
el.classList.remove(cls);
const kept = styles[Number(cls.slice(STYLE_RELAY.length))];
if (kept) el.setAttribute('style', kept);
}
if (!el.classList.length) el.removeAttribute('class');
}
return out.innerHTML;
}

/** @internal Inert parse; the leading `<remove>` keeps head-only tags in the body. */
function parseInert(html: string): HTMLElement {
const body = new DOMParser().parseFromString(`<body><remove></remove>${html}`, 'text/html').body;
body.firstChild?.remove();
return body;
}

// --- Content normalisation ---------------------------------------------

/** Inline + block tags the editor is allowed to produce or to accept on paste. */
Expand Down
Loading
Loading