From 3b62e542ec1f78f8c8749e8a272fd225fd17c1b8 Mon Sep 17 00:00:00 2001 From: Robin Lopez Date: Thu, 24 Sep 2026 11:21:01 +0200 Subject: [PATCH 1/7] FSHSP-224 fix(progress-bar): keep the track visible with valuePosition bottom --- CHANGELOG.md | 6 ++++++ .../ui-progress-bar/src/lib/ui-progress-bar.scss | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5767862..b5bb4f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ 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. + ## [0.11.0] - 2026-09-18 ### Added diff --git a/projects/ui-kit/informative/ui-progress-bar/src/lib/ui-progress-bar.scss b/projects/ui-kit/informative/ui-progress-bar/src/lib/ui-progress-bar.scss index 9df81b3..b4b5deb 100644 --- a/projects/ui-kit/informative/ui-progress-bar/src/lib/ui-progress-bar.scss +++ b/projects/ui-kit/informative/ui-progress-bar/src/lib/ui-progress-bar.scss @@ -85,8 +85,10 @@ $indeterminate-duration: var(--ui-progress-bar-indeterminate-duration, 1.6s); // // --- Value position: label below the bar --------------------------- &._bottom { flex-direction: column; - align-items: flex-end; // bottom-aligned on the cross axis + align-items: stretch; // flex-end collapses the track to 0 px gap: var(--units-sm); + + .ui-progress-bar-label { align-self: flex-end; } } // --- Value position: label inside the filled portion --------------- From cb86fd370400d0ec29b5eae6ac3b6487b2cc8f47 Mon Sep 17 00:00:00 2001 From: Robin Lopez Date: Thu, 24 Sep 2026 11:21:01 +0200 Subject: [PATCH 2/7] FSHSP-224 fix(file-upload): use the form error token for error text --- CHANGELOG.md | 8 ++++++++ figma/component-vars.json | 4 ++-- .../forms/ui-file-upload/src/lib/ui-file-upload-list.scss | 2 +- .../forms/ui-file-upload/src/lib/ui-file-upload.scss | 2 +- projects/ui-kit/styles/component-vars.scss | 4 ++-- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5bb4f2..aa050e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,14 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr 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. + ## [0.11.0] - 2026-09-18 ### Added diff --git a/figma/component-vars.json b/figma/component-vars.json index d42fe50..81297ae 100644 --- a/figma/component-vars.json +++ b/figma/component-vars.json @@ -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": { @@ -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": { diff --git a/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload-list.scss b/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload-list.scss index 9adb8e6..ef57d71 100644 --- a/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload-list.scss +++ b/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload-list.scss @@ -168,6 +168,6 @@ $progress-color: var(--ui-file-upload-item-progress-color, var(--global-text-def &._error { border-color: var(--ui-file-upload-list-stroke-error, var(--informative-errorhigh-stroke-default)); /// Couleur de bordure d'une ligne en erreur. .ui-file-upload-list-media, - .ui-file-upload-list-meta { color: var(--ui-file-upload-list-color-error, var(--informative-errorhigh-content-default)); } /// Couleur du texte d'une ligne en erreur. + .ui-file-upload-list-meta { color: var(--ui-file-upload-list-color-error, var(--form-error-content-default)); } /// Couleur du texte d'une ligne en erreur. } } diff --git a/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload.scss b/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload.scss index cde0fb7..646c6a0 100644 --- a/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload.scss +++ b/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload.scss @@ -230,7 +230,7 @@ $message-font-size: var(--ui-file-upload-message-font-size, var(--size-typograph &-messages { display: flex; flex-direction: column; gap: $messages-gap; } &-message { margin: 0; - color: var(--ui-file-upload-message-color, var(--informative-errorhigh-content-default)); /// Couleur d'un message d'erreur. + color: var(--ui-file-upload-message-color, var(--form-error-content-default)); /// Couleur d'un message d'erreur. font-size: $message-font-size; } diff --git a/projects/ui-kit/styles/component-vars.scss b/projects/ui-kit/styles/component-vars.scss index 16144d0..c5198f5 100644 --- a/projects/ui-kit/styles/component-vars.scss +++ b/projects/ui-kit/styles/component-vars.scss @@ -465,7 +465,7 @@ --ui-file-upload-color: var(--form-high-content-default); --ui-file-upload-color-disabled: var(--form-high-content-disabled); --ui-file-upload-focus-ring-color: var(--form-high-stroke-focused); - --ui-file-upload-message-color: var(--informative-errorhigh-content-default); + --ui-file-upload-message-color: var(--form-error-content-default); --ui-file-upload-stroke: var(--form-high-stroke-default); --ui-file-upload-stroke-disabled: var(--form-high-stroke-disabled); --ui-file-upload-stroke-dragging: var(--form-high-stroke-focused); @@ -513,7 +513,7 @@ // Couleurs --ui-file-upload-item-progress-color: var(--global-text-default); --ui-file-upload-list-color: var(--form-high-content-default); - --ui-file-upload-list-color-error: var(--informative-errorhigh-content-default); + --ui-file-upload-list-color-error: var(--form-error-content-default); --ui-file-upload-list-progress-surface: var(--form-high-surface-hover); --ui-file-upload-list-remove-color: var(--form-high-content-default); --ui-file-upload-list-remove-color-hover: var(--global-text-default); From 8f5ef9c85ea108bba20e6c299374c687fb7ea4f5 Mon Sep 17 00:00:00 2001 From: Robin Lopez Date: Thu, 24 Sep 2026 11:21:01 +0200 Subject: [PATCH 3/7] FSHSP-224 fix(file-upload): submit the current selection under name --- CHANGELOG.md | 5 +++++ .../ui-file-upload/src/lib/ui-file-upload.html | 11 ++++++++++- .../forms/ui-file-upload/src/lib/ui-file-upload.ts | 13 ++++++++++++- .../ui-kit/forms/ui-file-upload/ui-file-upload.mdx | 3 +++ .../forms/ui-file-upload/ui-file-upload.stories.ts | 5 +++++ 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa050e2..aad99b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr `--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 `
` 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. + ## [0.11.0] - 2026-09-18 ### Added diff --git a/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload.html b/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload.html index eb72039..a42a358 100644 --- a/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload.html +++ b/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload.html @@ -10,13 +10,22 @@ class="ui-file-upload-input" type="file" [id]="inputId" - [attr.name]="name()" [attr.accept]="accept() || null" [multiple]="multiple()" [disabled]="disabled()" [attr.aria-label]="ariaLabel() || null" (change)="onInputChange($event)" /> + @if (name()) { + + } @if (mode() === 'field') { diff --git a/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload.ts b/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload.ts index 8e98cd4..9f1852e 100644 --- a/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload.ts +++ b/projects/ui-kit/forms/ui-file-upload/src/lib/ui-file-upload.ts @@ -60,7 +60,7 @@ export class UiFileUpload { size = input('default'); // --- Selection ------------------------------------------------------- - /** Native input `name` (submitted with a surrounding form). */ + /** Native `name`: a surrounding `` submits the current selection under it. */ name = input(); /** Allow selecting more than one file. */ multiple = input(false, { transform: booleanAttribute }); @@ -153,6 +153,7 @@ export class UiFileUpload { private readonly host = inject(ElementRef); private readonly destroyRef = inject(DestroyRef); private readonly fileInput = viewChild.required>('fileInput'); + private readonly formValueInput = viewChild>('formValue'); /** In-flight requests, keyed by file id, so we can abort. */ private readonly requests = new Map(); @@ -174,6 +175,16 @@ export class UiFileUpload { for (const xhr of this.requests.values()) xhr.abort(); }); + // The picker is emptied after each choice: the hidden field carries the selection. + effect(() => { + const el = this.formValueInput()?.nativeElement; + const files = this.selection(); + if (!el || typeof DataTransfer === 'undefined') return; + const data = new DataTransfer(); + for (const f of files) data.items.add(f.file); + el.files = data.files; + }); + // A11y safeguard: the control needs an accessible name. if (isDevMode()) { effect(() => { diff --git a/projects/ui-kit/forms/ui-file-upload/ui-file-upload.mdx b/projects/ui-kit/forms/ui-file-upload/ui-file-upload.mdx index 2f777c1..462d641 100644 --- a/projects/ui-kit/forms/ui-file-upload/ui-file-upload.mdx +++ b/projects/ui-kit/forms/ui-file-upload/ui-file-upload.mdx @@ -25,6 +25,9 @@ les vignettes d'images utilisent des object URLs révoqués à la suppression. > formulaire. Pilotez-le via l'API dédiée — `files` / `filesChange`, `selected`, > `uploaded`, `removed` — et reportez le résultat (ids, URLs) dans votre > formulaire si besoin. +> +> Seul cas couvert sans code : un `` natif soumis par le navigateur. Avec +> `name`, il reçoit la sélection courante sous ce nom. ## API diff --git a/projects/ui-kit/forms/ui-file-upload/ui-file-upload.stories.ts b/projects/ui-kit/forms/ui-file-upload/ui-file-upload.stories.ts index 3bfa600..43266e2 100644 --- a/projects/ui-kit/forms/ui-file-upload/ui-file-upload.stories.ts +++ b/projects/ui-kit/forms/ui-file-upload/ui-file-upload.stories.ts @@ -54,6 +54,11 @@ const meta: Meta = { description: 'Autorise la sélection de plusieurs fichiers.', table: { type: { summary: 'boolean' }, defaultValue: { summary: 'false' } }, }, + name: { + control: { type: 'text' }, + description: 'Nom natif : un `` englobant soumet la sélection courante sous ce nom.', + table: { type: { summary: 'string' }, defaultValue: { summary: 'undefined' } }, + }, accept: { control: { type: 'text' }, description: 'Filtre natif de type MIME/extension (ex. "image/*,.pdf").', From 5235a48db088c5b69afaf280e380611d6c222689 Mon Sep 17 00:00:00 2001 From: Robin Lopez Date: Thu, 24 Sep 2026 11:21:02 +0200 Subject: [PATCH 4/7] FSHSP-224 fix(editor): keep typing, alignment, colors and selection intact --- CHANGELOG.md | 15 +++ docs/SECURITY-PRACTICES.md | 16 +-- .../src/lib/ui-editor-commands.spec.ts | 92 +++++++++++++ .../ui-editor/src/lib/ui-editor-commands.ts | 122 +++++++++++++++++- .../forms/ui-editor/src/lib/ui-editor.spec.ts | 49 +++++++ .../forms/ui-editor/src/lib/ui-editor.ts | 18 ++- projects/ui-kit/forms/ui-editor/ui-editor.mdx | 4 +- 7 files changed, 295 insertions(+), 21 deletions(-) create mode 100644 projects/ui-kit/forms/ui-editor/src/lib/ui-editor-commands.spec.ts create mode 100644 projects/ui-kit/forms/ui-editor/src/lib/ui-editor.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index aad99b8..e35c86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,21 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr 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 `é`) 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 (`
`) + 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. + ## [0.11.0] - 2026-09-18 ### Added diff --git a/docs/SECURITY-PRACTICES.md b/docs/SECURITY-PRACTICES.md index aea5e44..12fc221 100644 --- a/docs/SECURITY-PRACTICES.md +++ b/docs/SECURITY-PRACTICES.md @@ -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 `` 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 `

Un

'; + const indented = + '

Deux

'; + expect(sanitize(aligned)).toBe(aligned); + expect(sanitize(indented)).toBe(indented); + }); + + it('drops every other declaration, and still sanitises the rest', () => { + expect(sanitize('

a

')).toBe( + '

a

', + ); + expect(sanitize('

a

')).toBe('

a

'); + expect( + sanitize('

a

'), + ).toBe('

a

'); + }); + + it('does not honour a relay class forged in the value', () => { + expect(sanitize('

a

b

')).toBe( + '

a

b

', + ); + }); +}); + +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( + `

mot et suite

`, + ); + convertColorMarkers(root, blue); + expect(root.innerHTML).toBe( + `

mot et suite

`, + ); + }); + + it('unwraps a span the recolor left bare', () => { + mount(`

mot et suite

`); + convertColorMarkers(root, blue); + expect(root.innerHTML).toBe(`

mot et suite

`); + }); + + it('puts the selection back over the formatted text', () => { + mount('

avant formaté après

'); + getSelection()?.selectAllChildren(root.querySelector('font')!); + convertColorMarkers(root, blue); + const selection = getSelection()!; + expect(selection.isCollapsed).toBe(false); + expect(selection.toString()).toBe('formaté'); + }); +}); diff --git a/projects/ui-kit/forms/ui-editor/src/lib/ui-editor-commands.ts b/projects/ui-kit/forms/ui-editor/src/lib/ui-editor-commands.ts index 1aaf2bd..c7826eb 100644 --- a/projects/ui-kit/forms/ui-editor/src/lib/ui-editor-commands.ts +++ b/projects/ui-kit/forms/ui-editor/src/lib/ui-editor-commands.ts @@ -276,17 +276,48 @@ export function applyFontFamily(): void { /** Rewrites the `` elements `fontName` just produced into ``. */ 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 `` elements with a classed span. */ -function convertMarkers(root: ParentNode, selector: string, className: string): void { +function convertMarkers( + root: ParentNode, + selector: string, + className: string, + family: ReadonlySet, +): 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('[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); } /** @@ -337,7 +368,7 @@ export function applyFontSize(): void { /** Rewrites the `` elements `fontSize` just produced into ``. */ 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. */ @@ -405,7 +436,7 @@ export function applyTextColor(): void { /** Rewrites the `` elements `foreColor` just produced into ``. */ 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. */ @@ -436,7 +467,12 @@ export function applyHighlightColor(): void { /** Rewrites the `` elements `hiliteColor` just produced into ``. */ 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. */ @@ -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 = { + '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 `` keeps head-only tags in the body. */ +function parseInert(html: string): HTMLElement { + const body = new DOMParser().parseFromString(`${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. */ diff --git a/projects/ui-kit/forms/ui-editor/src/lib/ui-editor.spec.ts b/projects/ui-kit/forms/ui-editor/src/lib/ui-editor.spec.ts new file mode 100644 index 0000000..be384c6 --- /dev/null +++ b/projects/ui-kit/forms/ui-editor/src/lib/ui-editor.spec.ts @@ -0,0 +1,49 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { describe, expect, it } from 'vitest'; +import { UiEditor } from './ui-editor'; + +@Component({ + imports: [UiEditor, FormsModule], + template: ``, +}) +class Host { + readonly value = signal(''); +} + +async function setup() { + await TestBed.configureTestingModule({ imports: [Host] }).compileComponents(); + const fixture = TestBed.createComponent(Host); + fixture.detectChanges(); + await fixture.whenStable(); + const area = fixture.nativeElement.querySelector('.ui-editor-content') as HTMLElement; + return { fixture, area }; +} + +describe('ui-editor editing area', () => { + it('does not rewrite what the user typed, accents included', async () => { + const { fixture, area } = await setup(); + + const typed = document.createTextNode('café crème'); + area.append(typed); + area.dispatchEvent(new Event('input')); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.componentInstance.value()).toBe('café crème'); + expect(area.firstChild).toBe(typed); + }); + + it('renders an alignment written from outside', async () => { + const { fixture, area } = await setup(); + + fixture.componentInstance.value.set('

Centré

'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(area.innerHTML).toBe('

Centré

'); + }); +}); diff --git a/projects/ui-kit/forms/ui-editor/src/lib/ui-editor.ts b/projects/ui-kit/forms/ui-editor/src/lib/ui-editor.ts index b79b202..6959be2 100644 --- a/projects/ui-kit/forms/ui-editor/src/lib/ui-editor.ts +++ b/projects/ui-kit/forms/ui-editor/src/lib/ui-editor.ts @@ -60,6 +60,7 @@ import { readTextColor, removeLink, resolveFontLabel, + sanitizeEditorHtml, scrubInPlace, toggleCodeBlock, } from './ui-editor-commands'; @@ -168,8 +169,11 @@ export class UiEditor extends BaseFormField { private savedRange: Range | null = null; /** @ignore Sanitised value — never inject `modelValue` into the DOM raw. */ - private readonly safeValue = computed( - () => this.sanitizer.sanitize(SecurityContext.HTML, this.modelValue() ?? '') ?? '', + private readonly safeValue = computed(() => + sanitizeEditorHtml( + this.modelValue() ?? '', + (html) => this.sanitizer.sanitize(SecurityContext.HTML, html) ?? '', + ), ); /** @ignore Plain-text length, what `maxlength` and the counter measure. */ @@ -270,13 +274,13 @@ export class UiEditor extends BaseFormField { const value = this.safeValue(); const el = this.contentEl()?.nativeElement; if (!el) return; - // Already on screen, or the value we just read back from the user's own - // edit: rewriting would collapse the caret mid-keystroke. Comparing the - // live DOM too covers the case where sanitising reshapes the markup. - if (el.innerHTML === value || value === this.lastEmitted) return; + // Our own edit read back, or already on screen: rewriting would collapse the + // caret. Raw value on purpose: sanitising re-encodes it (`é` → `é`). + if (this.modelValue() === this.lastEmitted || el.innerHTML === value) return; /* eslint-disable-next-line no-restricted-syntax -- EXCEPTION JUSTIFIÉE : `value` vient de `safeValue()`, donc de - `DomSanitizer.sanitize(SecurityContext.HTML, …)`. L'écriture directe est + `DomSanitizer.sanitize(SecurityContext.HTML, …)`, plus le seul `style` + d'alignement et de retrait de `sanitizeStyle()`. L'écriture directe est imposée par le `contenteditable` : une liaison `[innerHTML]` réécrirait la zone à chaque frappe et effondrerait le caret. Registre : docs/SECURITY-PRACTICES.md. */ diff --git a/projects/ui-kit/forms/ui-editor/ui-editor.mdx b/projects/ui-kit/forms/ui-editor/ui-editor.mdx index 4cd7793..9215dde 100644 --- a/projects/ui-kit/forms/ui-editor/ui-editor.mdx +++ b/projects/ui-kit/forms/ui-editor/ui-editor.mdx @@ -16,7 +16,9 @@ navigateur, isolée dans `ui-editor-commands`. Le kit reste sans dépendance run La valeur est une **chaîne HTML**, assainie par `DomSanitizer` avant tout affichage, et réduite à une liste blanche de balises au collage (`b`, `strong`, `i`, `em`, `u`, `a`, `ul`, `ol`, `li`, `p`, -`br`, `div`, `span`, `pre`, `code`). +`br`, `div`, `span`, `pre`, `code`). Le seul `style` conservé est celui que l'éditeur écrit +lui-même pour l'alignement et le retrait (`text-align`, `margin`, `padding`, `border: none`) : +le `DomSanitizer` retire tout `style`, et ces deux mises en forme se perdaient sinon. Autonome, `[(ngModel)]` ou reactive forms (`ControlValueAccessor` via `BaseFormField`). Pour une saisie multiligne **sans mise en forme** → From 5a79bd53535f7c4470338c78d190f245acd3a10a Mon Sep 17 00:00:00 2001 From: Robin Lopez Date: Thu, 24 Sep 2026 11:21:02 +0200 Subject: [PATCH 5/7] FSHSP-224 fix(toast): give the pointer to the card, not the band --- CHANGELOG.md | 5 +++++ .../informative/ui-toast/src/lib/ui-toast-container.html | 8 ++++---- .../informative/ui-toast/src/lib/ui-toast-container.scss | 8 ++++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e35c86f..1655069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,11 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr 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. + ## [0.11.0] - 2026-09-18 ### Added diff --git a/projects/ui-kit/informative/ui-toast/src/lib/ui-toast-container.html b/projects/ui-kit/informative/ui-toast/src/lib/ui-toast-container.html index b3bcae5..76c95c9 100644 --- a/projects/ui-kit/informative/ui-toast/src/lib/ui-toast-container.html +++ b/projects/ui-kit/informative/ui-toast/src/lib/ui-toast-container.html @@ -7,12 +7,12 @@ #m="uiMotion" [animate.enter]="m.enter()" [animate.leave]="m.leave()" - (mouseenter)="pause(item.id)" - (mouseleave)="resume(item.id)" - (focusin)="pause(item.id)" - (focusout)="resume(item.id)" > ui-toast { + pointer-events: auto; + } } // --- Horizontal alignment of hugging cards ------------------------- From 9048dd52b2c28983e79b7ccc42ca4a5296548a7d Mon Sep 17 00:00:00 2001 From: Robin Lopez Date: Thu, 24 Sep 2026 11:21:03 +0200 Subject: [PATCH 6/7] FSHSP-224 fix(toast): start a queued toast countdown once shown --- CHANGELOG.md | 5 ++ .../src/lib/ui-toast-container.spec.ts | 50 +++++++++++++++++++ .../ui-toast/src/lib/ui-toast-container.ts | 14 +++--- .../ui-kit/informative/ui-toast/ui-toast.mdx | 2 +- 4 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 projects/ui-kit/informative/ui-toast/src/lib/ui-toast-container.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1655069..3981cec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,11 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr 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. + ## [0.11.0] - 2026-09-18 ### Added diff --git a/projects/ui-kit/informative/ui-toast/src/lib/ui-toast-container.spec.ts b/projects/ui-kit/informative/ui-toast/src/lib/ui-toast-container.spec.ts new file mode 100644 index 0000000..ff931d6 --- /dev/null +++ b/projects/ui-kit/informative/ui-toast/src/lib/ui-toast-container.spec.ts @@ -0,0 +1,50 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { UiToastContainer } from './ui-toast-container'; +import { UiToastService } from './ui-toast.service'; + +@Component({ + imports: [UiToastContainer], + template: ``, +}) +class Host {} + +async function setup() { + await TestBed.configureTestingModule({ imports: [Host] }).compileComponents(); + const fixture = TestBed.createComponent(Host); + const service = TestBed.inject(UiToastService); + fixture.detectChanges(); + const titles = (): string[] => + Array.from( + fixture.nativeElement.querySelectorAll('.ui-toast-title') as NodeListOf, + ).map((t) => t.textContent?.trim() ?? ''); + const advance = (ms: number) => { + vi.advanceTimersByTime(ms); + fixture.detectChanges(); + }; + return { fixture, service, titles, advance }; +} + +describe('ui-toast-container queue', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('shows a queued message once a slot frees, with its full life', async () => { + const { fixture, service, titles, advance } = await setup(); + + service.add({ title: 'Premier' }); + service.add({ title: 'Second' }); + fixture.detectChanges(); + expect(titles()).toEqual(['Second']); + + advance(1000); + expect(titles()).toEqual(['Premier']); + + advance(999); + expect(titles()).toEqual(['Premier']); + + advance(1); + expect(titles()).toEqual([]); + }); +}); diff --git a/projects/ui-kit/informative/ui-toast/src/lib/ui-toast-container.ts b/projects/ui-kit/informative/ui-toast/src/lib/ui-toast-container.ts index 1faa158..78b3664 100644 --- a/projects/ui-kit/informative/ui-toast/src/lib/ui-toast-container.ts +++ b/projects/ui-kit/informative/ui-toast/src/lib/ui-toast-container.ts @@ -164,21 +164,21 @@ export class UiToastContainer { } } - const liveIds = new Set(keyed.map((m) => m.id)); + const shown = this.items(); + const shownIds = new Set(shown.map((m) => m.id)); - // Drop timer bookkeeping for messages that are gone. + // Drop timer bookkeeping for messages that are gone or no longer shown. for (const id of [...this.timers.keys()]) { - if (!liveIds.has(id)) this.forget(id); + if (!shownIds.has(id)) this.forget(id); } for (const id of [...this.remaining.keys()]) { - if (!liveIds.has(id)) this.remaining.delete(id); + if (!shownIds.has(id)) this.remaining.delete(id); } if (!this.isBrowser) return; - // Arm a countdown for each new, non-sticky message (even ones beyond the - // visible limit, so a queued toast still expires on schedule). - for (const message of keyed) { + // Arm a countdown for each newly shown, non-sticky message. + for (const message of shown) { const id = message.id!; if (message.sticky) continue; if (this.timers.has(id) || this.remaining.has(id)) continue; diff --git a/projects/ui-kit/informative/ui-toast/ui-toast.mdx b/projects/ui-kit/informative/ui-toast/ui-toast.mdx index cba20d8..1621e99 100644 --- a/projects/ui-kit/informative/ui-toast/ui-toast.mdx +++ b/projects/ui-kit/informative/ui-toast/ui-toast.mdx @@ -55,7 +55,7 @@ Champs d'un `UiToastMessage` : `title`, `text`, `level`, `subLevel`, `icon`, `cl motionDisabledbooleanfalseDésactive l'animation d'entrée/sortie. containedbooleanfalseposition: absolute (scopé à un ancêtre positionné) au lieu de fixed. templateTemplateRefundefinedContenu personnalisé : contexte { $implicit: message, closeFn }. - stackVisibleLimitnumber0N'affiche que les N toasts les plus récents (0 = illimité) ; les autres attendent (leur compte à rebours court quand même). + stackVisibleLimitnumber0N'affiche que les N toasts les plus récents (0 = illimité) ; les autres attendent leur tour : leur compte à rebours ne démarre qu'à l'affichage. stackGapnumbertoken --units-smÉcart (px) entre cartes empilées. preventDuplicatesbooleanfalseIgnore un toast dont le contenu (level+title+text) est déjà affiché. autoZIndexbooleantrueEmpile automatiquement au-dessus des piles créées avant. From 64666903799f65b0e41587bf3349f2efb23b4dbb Mon Sep 17 00:00:00 2001 From: Robin Lopez Date: Thu, 24 Sep 2026 11:21:03 +0200 Subject: [PATCH 7/7] FSHSP-224 fix(menu): open a cascade parent on click instead of toggling it --- CHANGELOG.md | 5 ++ .../ui-menu/src/lib/ui-menu.spec.ts | 47 +++++++++++++++++++ .../navigation/ui-menu/src/lib/ui-menu.ts | 4 +- 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 projects/ui-kit/navigation/ui-menu/src/lib/ui-menu.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3981cec..c1f156e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,11 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr 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 diff --git a/projects/ui-kit/navigation/ui-menu/src/lib/ui-menu.spec.ts b/projects/ui-kit/navigation/ui-menu/src/lib/ui-menu.spec.ts new file mode 100644 index 0000000..76c2f3c --- /dev/null +++ b/projects/ui-kit/navigation/ui-menu/src/lib/ui-menu.spec.ts @@ -0,0 +1,47 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it } from 'vitest'; +import { UiMenu, UiMenuItem } from './ui-menu'; + +@Component({ + imports: [UiMenu], + template: ``, +}) +class Host { + readonly items: UiMenuItem[] = [ + { label: 'Copier' }, + { label: 'Partager', items: [{ label: 'Envoyer par e-mail' }, { label: 'Copier le lien' }] }, + ]; +} + +async function setup() { + await TestBed.configureTestingModule({ imports: [Host] }).compileComponents(); + const fixture = TestBed.createComponent(Host); + fixture.detectChanges(); + const parent = fixture.nativeElement.querySelector( + 'button[aria-haspopup="menu"]', + ) as HTMLButtonElement; + return { fixture, parent }; +} + +describe('ui-menu flyout parent', () => { + it('stays open when the click follows the hover that opened it', async () => { + const { fixture, parent } = await setup(); + + parent.dispatchEvent(new MouseEvent('mouseenter')); + fixture.detectChanges(); + expect(parent.getAttribute('aria-expanded')).toBe('true'); + + parent.click(); + fixture.detectChanges(); + expect(parent.getAttribute('aria-expanded')).toBe('true'); + }); + + it('opens on a click alone (keyboard Enter, no hover)', async () => { + const { fixture, parent } = await setup(); + + parent.click(); + fixture.detectChanges(); + expect(parent.getAttribute('aria-expanded')).toBe('true'); + }); +}); diff --git a/projects/ui-kit/navigation/ui-menu/src/lib/ui-menu.ts b/projects/ui-kit/navigation/ui-menu/src/lib/ui-menu.ts index dd7e7d1..ce1b9ae 100644 --- a/projects/ui-kit/navigation/ui-menu/src/lib/ui-menu.ts +++ b/projects/ui-kit/navigation/ui-menu/src/lib/ui-menu.ts @@ -409,11 +409,11 @@ export class UiMenu { // --- Flyout submenus ------------------------------------------------------ - /** @ignore Click on a flyout parent item: toggle its side panel. */ + /** @ignore Click on a flyout parent item: open its side panel (never toggle, hover already opened it). */ protected onFlyoutClick(node: UiMenuNode): void { if (node.item.disabled) return; this.focusedKey.set(node.key); - this.openFlyoutKey.update((key) => (key === node.key ? null : node.key)); + this.openFlyoutKey.set(node.key); } /** @ignore Hovering an entry: open its flyout, or close the sibling one. */