diff --git a/docs/backlog/WP-10-button-fidelity.md b/docs/backlog/WP-10-button-fidelity.md index 5af7c2b..e483ff2 100644 --- a/docs/backlog/WP-10-button-fidelity.md +++ b/docs/backlog/WP-10-button-fidelity.md @@ -1,8 +1,17 @@ # WP-10 — CIBG button fidelity -Status: todo +Status: done () Phase: 2 — CIBG fidelity +> **Deviation:** file-input's label-button was already reworked to `.btn-primary +> .btn-upload` by the earlier out-of-order "CIBG UI fidelity pass" (WP-11/12) — the +> vendored upload vocabulary (`.btn-upload`) supersedes this WP's original +> `.btn-secondary` assumption, so no change was needed there. Icon affordances +> (chevron/pijl classes) are verified present in the vendored CSS, but no in-scope +> button (atom, file-input, RTE toolbar) currently has a next/previous affordance to +> attach one to — skipped as not applicable, not recorded as a gap (nothing hand-rolled +> to mark). + ## Why The vendored CIBG build ships `.btn-primary / .btn-secondary / .btn-danger / .btn-ghost / @@ -46,9 +55,9 @@ and render as unstyled Bootstrap defaults instead of CIBG buttons. ## Acceptance criteria -- [ ] `grep -rn "btn-outline\|btn-sm" src/app` → empty. -- [ ] Button story shows all CIBG variants incl. ghost; visuals match the design system. -- [ ] Axe still green (contrast can change with real button styles). +- [x] `grep -rn "btn-outline\|btn-sm" src/app` → empty. +- [x] Button story shows all CIBG variants incl. ghost; visuals match the design system. +- [x] Axe still green (contrast can change with real button styles). ## Verification diff --git a/documentation.json b/documentation.json index 691dc00..dea4214 100644 --- a/documentation.json +++ b/documentation.json @@ -3949,12 +3949,12 @@ }, { "name": "PlaceholderOption", - "id": "interface-PlaceholderOption-7a8150178a706e0b19a2f28e917beb9f8356eb77f0f6aeba7498d02bd759a1170553ab47aeba53fee1c23368d1d945395a0fe6a35afa83821f650b5a0226fd9c", + "id": "interface-PlaceholderOption-16efb1db84d6d2b05eab88e5b28311d531702eb0dc436f253d64a3d2b97ffef684b970354106e73042f9e90cacec7c3fc5c0ecb7fcadaf4d8a01c5d89fe8c617", "file": "src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts", "deprecated": false, "deprecationMessage": "", "type": "interface", - "sourceCode": "import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';\nimport { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';\nimport { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom';\n\n/** A menu entry for the insert-placeholder control — a plain {key,label}, so the\n editor stays domain-free (it never sees the brief's PlaceholderDef). */\nexport interface PlaceholderOption {\n readonly key: string;\n readonly label: string;\n // Auto-resolvable fields are filled server-side at send; manual fields need a value.\n // Drives the chip's styling so the two read apart at a glance.\n readonly autoResolvable?: boolean;\n}\n\n/**\n * Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.\n *\n * It is the single quarantined boundary to the imperative `contenteditable` DOM:\n * `content` in, `contentChanged` (a `RichTextBlock`) out, holding NO letter state.\n * Placeholders render as non-editable chips and can only be inserted from the menu\n * (valid keys only) — never typed as raw braces. Swapping in a real editor library\n * later (TipTap) means replacing only this component; nothing else sees the DOM.\n */\n@Component({\n selector: 'app-rich-text-editor',\n styles: [\n `\n :host {\n display: block;\n }\n .rte-toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-sm);\n align-items: center;\n margin-block-end: var(--rhc-space-max-sm);\n }\n .rte-toolbar button {\n min-inline-size: 2.2rem;\n }\n .rte-sep {\n inline-size: 1px;\n align-self: stretch;\n background: var(--rhc-color-border-default);\n }\n .rte-editable {\n border: 1px solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: var(--rhc-space-max-md);\n min-block-size: 4rem;\n }\n .rte-editable[contenteditable='false'] {\n background: var(--rhc-color-cool-grey-100);\n }\n .rte-editable :is(p) {\n margin: 0 0 var(--rhc-space-max-sm);\n }\n .rte-editable :is(ul, ol) {\n margin: 0 0 var(--rhc-space-max-sm);\n padding-inline-start: 1.4em;\n }\n /* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side)\n vs manual (yellow, still needs a value). The read-only preview adds error/warning states.\n Chips are created imperatively (createChip) inside contenteditable, so they never receive\n Angular's _ngcontent scoping attribute — ::ng-deep is required or the rules won't match them.\n Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */\n :host ::ng-deep .rte-chip {\n border: 1px dashed var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0 0.3em;\n white-space: nowrap;\n }\n :host ::ng-deep .rte-chip[data-auto='true'] {\n background: var(--rhc-color-cool-grey-100);\n }\n :host ::ng-deep .rte-chip[data-auto='false'] {\n background: var(--rhc-color-geel-100);\n }\n :host ::ng-deep .rte-chip::before {\n content: '\\\\7B';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-end: 0.1em;\n }\n :host ::ng-deep .rte-chip::after {\n content: '\\\\7D';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-start: 0.1em;\n }\n `,\n ],\n template: `\n @if (editable()) {\n
\n \n B\n \n \n I\n \n \n U\n \n \n \n •\n \n \n 1.\n \n @if (placeholders().length) {\n \n \n }\n
\n }\n \n `,\n})\nexport class RichTextEditorComponent {\n content = input(emptyBlock());\n placeholders = input([]);\n editable = input(true);\n contentChanged = output();\n\n // Localizable-by-default copy (shared-UI convention).\n fieldLabel = input($localize`:@@richTextEditor.field:Tekst`);\n toolbarLabel = input($localize`:@@richTextEditor.toolbar:Opmaak`);\n boldLabel = input($localize`:@@richTextEditor.bold:Vet`);\n italicLabel = input($localize`:@@richTextEditor.italic:Cursief`);\n underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);\n insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);\n insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);\n bulletListLabel = input($localize`:@@richTextEditor.bulletList:Opsomming`);\n numberListLabel = input($localize`:@@richTextEditor.numberList:Genummerde lijst`);\n\n private editorEl = viewChild>('editor');\n private lastEmitted = '';\n\n private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;\n private autoFor = (key: string) =>\n this.placeholders().find((p) => p.key === key)?.autoResolvable ?? false;\n\n constructor() {\n // Render when content arrives/changes from OUTSIDE. Skip our own emitted value\n // flowing back (structural compare) so the caret isn't reset while typing.\n effect(() => {\n const content = this.content();\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const serialized = JSON.stringify(content);\n if (serialized === this.lastEmitted) return;\n renderInto(el, content, this.labelFor, this.autoFor);\n this.lastEmitted = serialized;\n });\n }\n\n protected emit() {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const block = readBlock(el);\n this.lastEmitted = JSON.stringify(block);\n this.contentChanged.emit(block);\n }\n\n protected format(cmd: 'bold' | 'italic' | 'underline') {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n el.focus();\n // ponytail: execCommand is deprecated but universally supported and zero-dependency;\n // if a browser drops it, this component is the one place to swap in a range-based impl.\n el.ownerDocument.execCommand(cmd);\n this.emit();\n }\n\n /** Bullet / numbered lists via execCommand — same deprecated-but-universal path as\n bold/italic (ponytail-noted on `format`); readBlock reads the resulting
    /
      . */\n protected list(kind: 'bullet' | 'number') {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n el.focus();\n el.ownerDocument.execCommand(kind === 'bullet' ? 'insertUnorderedList' : 'insertOrderedList');\n this.emit();\n }\n\n /** Ctrl/Cmd+B/I/U → our format() (+ preventDefault) so serialization runs and behaviour\n is consistent across browsers rather than relying on the native handler. */\n protected onKeydown(e: KeyboardEvent) {\n if (e.key === 'Backspace' || e.key === 'Delete') {\n this.deleteAdjacentChip(e);\n return;\n }\n if (!(e.ctrlKey || e.metaKey) || e.altKey) return;\n const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key.toLowerCase()] as\n | 'bold'\n | 'italic'\n | 'underline'\n | undefined;\n if (!cmd) return;\n e.preventDefault();\n this.format(cmd);\n }\n\n /** Backspace/Delete next to a contenteditable=false chip removes it ourselves —\n browsers otherwise leave these atomic chips undeletable. Selections fall through. */\n private deleteAdjacentChip(e: KeyboardEvent) {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const sel = el.ownerDocument.getSelection();\n if (!sel || !sel.isCollapsed || !sel.rangeCount) return;\n const range = sel.getRangeAt(0);\n if (!el.contains(range.startContainer)) return;\n const chip = adjacentChip(\n range.startContainer,\n range.startOffset,\n e.key === 'Backspace' ? -1 : 1,\n );\n if (!chip) return;\n e.preventDefault();\n chip.remove();\n this.emit();\n }\n\n protected insert(key: string) {\n const el = this.editorEl()?.nativeElement;\n if (!key || !el) return;\n el.focus();\n const chip = createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key));\n const sel = el.ownerDocument.getSelection();\n if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {\n const range = sel.getRangeAt(0);\n range.deleteContents();\n range.insertNode(chip);\n range.setStartAfter(chip);\n range.collapse(true);\n sel.removeAllRanges();\n sel.addRange(range);\n } else {\n (el.lastElementChild ?? el).appendChild(chip);\n }\n this.emit();\n }\n}\n", + "sourceCode": "import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';\nimport { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';\nimport { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom';\n\n/** A menu entry for the insert-placeholder control — a plain {key,label}, so the\n editor stays domain-free (it never sees the brief's PlaceholderDef). */\nexport interface PlaceholderOption {\n readonly key: string;\n readonly label: string;\n // Auto-resolvable fields are filled server-side at send; manual fields need a value.\n // Drives the chip's styling so the two read apart at a glance.\n readonly autoResolvable?: boolean;\n}\n\n/**\n * Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.\n *\n * It is the single quarantined boundary to the imperative `contenteditable` DOM:\n * `content` in, `contentChanged` (a `RichTextBlock`) out, holding NO letter state.\n * Placeholders render as non-editable chips and can only be inserted from the menu\n * (valid keys only) — never typed as raw braces. Swapping in a real editor library\n * later (TipTap) means replacing only this component; nothing else sees the DOM.\n */\n@Component({\n selector: 'app-rich-text-editor',\n styles: [\n `\n :host {\n display: block;\n }\n .rte-toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-sm);\n align-items: center;\n margin-block-end: var(--rhc-space-max-sm);\n }\n .rte-toolbar button {\n min-inline-size: 2.2rem;\n }\n .rte-sep {\n inline-size: 1px;\n align-self: stretch;\n background: var(--rhc-color-border-default);\n }\n .rte-editable {\n border: 1px solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: var(--rhc-space-max-md);\n min-block-size: 4rem;\n }\n .rte-editable[contenteditable='false'] {\n background: var(--rhc-color-cool-grey-100);\n }\n .rte-editable :is(p) {\n margin: 0 0 var(--rhc-space-max-sm);\n }\n .rte-editable :is(ul, ol) {\n margin: 0 0 var(--rhc-space-max-sm);\n padding-inline-start: 1.4em;\n }\n /* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side)\n vs manual (yellow, still needs a value). The read-only preview adds error/warning states.\n Chips are created imperatively (createChip) inside contenteditable, so they never receive\n Angular's _ngcontent scoping attribute — ::ng-deep is required or the rules won't match them.\n Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */\n :host ::ng-deep .rte-chip {\n border: 1px dashed var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0 0.3em;\n white-space: nowrap;\n }\n :host ::ng-deep .rte-chip[data-auto='true'] {\n background: var(--rhc-color-cool-grey-100);\n }\n :host ::ng-deep .rte-chip[data-auto='false'] {\n background: var(--rhc-color-geel-100);\n }\n :host ::ng-deep .rte-chip::before {\n content: '\\\\7B';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-end: 0.1em;\n }\n :host ::ng-deep .rte-chip::after {\n content: '\\\\7D';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-start: 0.1em;\n }\n `,\n ],\n template: `\n @if (editable()) {\n
      \n \n B\n \n \n I\n \n \n U\n \n \n \n •\n \n \n 1.\n \n @if (placeholders().length) {\n \n \n }\n
      \n }\n \n `,\n})\nexport class RichTextEditorComponent {\n content = input(emptyBlock());\n placeholders = input([]);\n editable = input(true);\n contentChanged = output();\n\n // Localizable-by-default copy (shared-UI convention).\n fieldLabel = input($localize`:@@richTextEditor.field:Tekst`);\n toolbarLabel = input($localize`:@@richTextEditor.toolbar:Opmaak`);\n boldLabel = input($localize`:@@richTextEditor.bold:Vet`);\n italicLabel = input($localize`:@@richTextEditor.italic:Cursief`);\n underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);\n insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);\n insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);\n bulletListLabel = input($localize`:@@richTextEditor.bulletList:Opsomming`);\n numberListLabel = input($localize`:@@richTextEditor.numberList:Genummerde lijst`);\n\n private editorEl = viewChild>('editor');\n private lastEmitted = '';\n\n private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;\n private autoFor = (key: string) =>\n this.placeholders().find((p) => p.key === key)?.autoResolvable ?? false;\n\n constructor() {\n // Render when content arrives/changes from OUTSIDE. Skip our own emitted value\n // flowing back (structural compare) so the caret isn't reset while typing.\n effect(() => {\n const content = this.content();\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const serialized = JSON.stringify(content);\n if (serialized === this.lastEmitted) return;\n renderInto(el, content, this.labelFor, this.autoFor);\n this.lastEmitted = serialized;\n });\n }\n\n protected emit() {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const block = readBlock(el);\n this.lastEmitted = JSON.stringify(block);\n this.contentChanged.emit(block);\n }\n\n protected format(cmd: 'bold' | 'italic' | 'underline') {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n el.focus();\n // ponytail: execCommand is deprecated but universally supported and zero-dependency;\n // if a browser drops it, this component is the one place to swap in a range-based impl.\n el.ownerDocument.execCommand(cmd);\n this.emit();\n }\n\n /** Bullet / numbered lists via execCommand — same deprecated-but-universal path as\n bold/italic (ponytail-noted on `format`); readBlock reads the resulting
        /
          . */\n protected list(kind: 'bullet' | 'number') {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n el.focus();\n el.ownerDocument.execCommand(kind === 'bullet' ? 'insertUnorderedList' : 'insertOrderedList');\n this.emit();\n }\n\n /** Ctrl/Cmd+B/I/U → our format() (+ preventDefault) so serialization runs and behaviour\n is consistent across browsers rather than relying on the native handler. */\n protected onKeydown(e: KeyboardEvent) {\n if (e.key === 'Backspace' || e.key === 'Delete') {\n this.deleteAdjacentChip(e);\n return;\n }\n if (!(e.ctrlKey || e.metaKey) || e.altKey) return;\n const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key.toLowerCase()] as\n | 'bold'\n | 'italic'\n | 'underline'\n | undefined;\n if (!cmd) return;\n e.preventDefault();\n this.format(cmd);\n }\n\n /** Backspace/Delete next to a contenteditable=false chip removes it ourselves —\n browsers otherwise leave these atomic chips undeletable. Selections fall through. */\n private deleteAdjacentChip(e: KeyboardEvent) {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const sel = el.ownerDocument.getSelection();\n if (!sel || !sel.isCollapsed || !sel.rangeCount) return;\n const range = sel.getRangeAt(0);\n if (!el.contains(range.startContainer)) return;\n const chip = adjacentChip(\n range.startContainer,\n range.startOffset,\n e.key === 'Backspace' ? -1 : 1,\n );\n if (!chip) return;\n e.preventDefault();\n chip.remove();\n this.emit();\n }\n\n protected insert(key: string) {\n const el = this.editorEl()?.nativeElement;\n if (!key || !el) return;\n el.focus();\n const chip = createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key));\n const sel = el.ownerDocument.getSelection();\n if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {\n const range = sel.getRangeAt(0);\n range.deleteContents();\n range.insertNode(chip);\n range.setStartAfter(chip);\n range.collapse(true);\n sel.removeAllRanges();\n sel.addRange(range);\n } else {\n (el.lastElementChild ?? el).appendChild(chip);\n }\n this.emit();\n }\n}\n", "properties": [ { "name": "autoResolvable", @@ -12770,7 +12770,7 @@ }, { "name": "ButtonComponent", - "id": "component-ButtonComponent-e82cab2e9fd29ef5d0df810f6729e2bff8b52dc0c798c7aba6be9a542854fe3f5ace7a9b95e93adeabf895687d2a5d7b4dbfe81bb1f6a17a9708f6cf2ddede47", + "id": "component-ButtonComponent-17005a0b46d23a4f489b295ede7cc53386589e7b09176ec1d0ed7565043e231b1d36d6799cf9b53021b4ea8bd3a9c285195728cc3f9355d49c830bd75e42c8c8", "file": "src/app/shared/ui/button/button.component.ts", "encapsulation": [], "entryComponents": [], @@ -12780,7 +12780,7 @@ "selector": "app-button", "styleUrls": [], "styles": [], - "template": "\n \n\n", + "template": "\n \n\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -12793,7 +12793,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 25, + "line": 26, "required": false }, { @@ -12805,7 +12805,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 24, + "line": 25, "required": false }, { @@ -12817,7 +12817,7 @@ "indexKey": "", "optional": false, "description": "", - "line": 23, + "line": 24, "required": false } ], @@ -12833,7 +12833,7 @@ "description": "

          Atom: button. Thin wrapper over the CIBG/Bootstrap button CSS.

          \n", "rawdescription": "\nAtom: button. Thin wrapper over the CIBG/Bootstrap button CSS.", "type": "component", - "sourceCode": "import { Component, input } from '@angular/core';\n\ntype Variant = 'primary' | 'secondary' | 'subtle' | 'danger';\n\n/** Atom: button. Thin wrapper over the CIBG/Bootstrap button CSS. */\n@Component({\n selector: 'app-button',\n template: `\n \n \n \n `,\n})\nexport class ButtonComponent {\n variant = input('primary');\n type = input<'button' | 'submit'>('button');\n disabled = input(false);\n}\n", + "sourceCode": "import { Component, input } from '@angular/core';\n\ntype Variant = 'primary' | 'secondary' | 'subtle' | 'danger' | 'ghost';\n\n/** Atom: button. Thin wrapper over the CIBG/Bootstrap button CSS. */\n@Component({\n selector: 'app-button',\n template: `\n \n \n \n `,\n})\nexport class ButtonComponent {\n variant = input('primary');\n type = input<'button' | 'submit'>('button');\n disabled = input(false);\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "", @@ -20418,7 +20418,7 @@ }, { "name": "RichTextEditorComponent", - "id": "component-RichTextEditorComponent-7a8150178a706e0b19a2f28e917beb9f8356eb77f0f6aeba7498d02bd759a1170553ab47aeba53fee1c23368d1d945395a0fe6a35afa83821f650b5a0226fd9c", + "id": "component-RichTextEditorComponent-16efb1db84d6d2b05eab88e5b28311d531702eb0dc436f253d64a3d2b97ffef684b970354106e73042f9e90cacec7c3fc5c0ecb7fcadaf4d8a01c5d89fe8c617", "file": "src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts", "encapsulation": [], "entryComponents": [], @@ -20430,7 +20430,7 @@ "styles": [ "\n :host {\n display: block;\n }\n .rte-toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-sm);\n align-items: center;\n margin-block-end: var(--rhc-space-max-sm);\n }\n .rte-toolbar button {\n min-inline-size: 2.2rem;\n }\n .rte-sep {\n inline-size: 1px;\n align-self: stretch;\n background: var(--rhc-color-border-default);\n }\n .rte-editable {\n border: 1px solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: var(--rhc-space-max-md);\n min-block-size: 4rem;\n }\n .rte-editable[contenteditable='false'] {\n background: var(--rhc-color-cool-grey-100);\n }\n .rte-editable :is(p) {\n margin: 0 0 var(--rhc-space-max-sm);\n }\n .rte-editable :is(ul, ol) {\n margin: 0 0 var(--rhc-space-max-sm);\n padding-inline-start: 1.4em;\n }\n /* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side)\n vs manual (yellow, still needs a value). The read-only preview adds error/warning states.\n Chips are created imperatively (createChip) inside contenteditable, so they never receive\n Angular's _ngcontent scoping attribute — ::ng-deep is required or the rules won't match them.\n Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */\n :host ::ng-deep .rte-chip {\n border: 1px dashed var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0 0.3em;\n white-space: nowrap;\n }\n :host ::ng-deep .rte-chip[data-auto='true'] {\n background: var(--rhc-color-cool-grey-100);\n }\n :host ::ng-deep .rte-chip[data-auto='false'] {\n background: var(--rhc-color-geel-100);\n }\n :host ::ng-deep .rte-chip::before {\n content: '\\7B';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-end: 0.1em;\n }\n :host ::ng-deep .rte-chip::after {\n content: '\\7D';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-start: 0.1em;\n }\n " ], - "template": "@if (editable()) {\n
          \n \n B\n \n \n I\n \n \n U\n \n \n \n •\n \n \n 1.\n \n @if (placeholders().length) {\n \n \n }\n
          \n}\n\n", + "template": "@if (editable()) {\n
          \n \n B\n \n \n I\n \n \n U\n \n \n \n •\n \n \n 1.\n \n @if (placeholders().length) {\n \n \n }\n
          \n}\n\n", "templateUrl": [], "viewProviders": [], "hostDirectives": [], @@ -20846,7 +20846,7 @@ "description": "

          Molecule: a minimal no-dependency WYSIWYG editor over a RichTextBlock.

          \n

          It is the single quarantined boundary to the imperative contenteditable DOM:\ncontent in, contentChanged (a RichTextBlock) out, holding NO letter state.\nPlaceholders render as non-editable chips and can only be inserted from the menu\n(valid keys only) — never typed as raw braces. Swapping in a real editor library\nlater (TipTap) means replacing only this component; nothing else sees the DOM.

          \n", "rawdescription": "\n\nMolecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.\n\nIt is the single quarantined boundary to the imperative `contenteditable` DOM:\n`content` in, `contentChanged` (a `RichTextBlock`) out, holding NO letter state.\nPlaceholders render as non-editable chips and can only be inserted from the menu\n(valid keys only) — never typed as raw braces. Swapping in a real editor library\nlater (TipTap) means replacing only this component; nothing else sees the DOM.\n", "type": "component", - "sourceCode": "import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';\nimport { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';\nimport { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom';\n\n/** A menu entry for the insert-placeholder control — a plain {key,label}, so the\n editor stays domain-free (it never sees the brief's PlaceholderDef). */\nexport interface PlaceholderOption {\n readonly key: string;\n readonly label: string;\n // Auto-resolvable fields are filled server-side at send; manual fields need a value.\n // Drives the chip's styling so the two read apart at a glance.\n readonly autoResolvable?: boolean;\n}\n\n/**\n * Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.\n *\n * It is the single quarantined boundary to the imperative `contenteditable` DOM:\n * `content` in, `contentChanged` (a `RichTextBlock`) out, holding NO letter state.\n * Placeholders render as non-editable chips and can only be inserted from the menu\n * (valid keys only) — never typed as raw braces. Swapping in a real editor library\n * later (TipTap) means replacing only this component; nothing else sees the DOM.\n */\n@Component({\n selector: 'app-rich-text-editor',\n styles: [\n `\n :host {\n display: block;\n }\n .rte-toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-sm);\n align-items: center;\n margin-block-end: var(--rhc-space-max-sm);\n }\n .rte-toolbar button {\n min-inline-size: 2.2rem;\n }\n .rte-sep {\n inline-size: 1px;\n align-self: stretch;\n background: var(--rhc-color-border-default);\n }\n .rte-editable {\n border: 1px solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: var(--rhc-space-max-md);\n min-block-size: 4rem;\n }\n .rte-editable[contenteditable='false'] {\n background: var(--rhc-color-cool-grey-100);\n }\n .rte-editable :is(p) {\n margin: 0 0 var(--rhc-space-max-sm);\n }\n .rte-editable :is(ul, ol) {\n margin: 0 0 var(--rhc-space-max-sm);\n padding-inline-start: 1.4em;\n }\n /* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side)\n vs manual (yellow, still needs a value). The read-only preview adds error/warning states.\n Chips are created imperatively (createChip) inside contenteditable, so they never receive\n Angular's _ngcontent scoping attribute — ::ng-deep is required or the rules won't match them.\n Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */\n :host ::ng-deep .rte-chip {\n border: 1px dashed var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0 0.3em;\n white-space: nowrap;\n }\n :host ::ng-deep .rte-chip[data-auto='true'] {\n background: var(--rhc-color-cool-grey-100);\n }\n :host ::ng-deep .rte-chip[data-auto='false'] {\n background: var(--rhc-color-geel-100);\n }\n :host ::ng-deep .rte-chip::before {\n content: '\\\\7B';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-end: 0.1em;\n }\n :host ::ng-deep .rte-chip::after {\n content: '\\\\7D';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-start: 0.1em;\n }\n `,\n ],\n template: `\n @if (editable()) {\n
          \n \n B\n \n \n I\n \n \n U\n \n \n \n •\n \n \n 1.\n \n @if (placeholders().length) {\n \n \n }\n
          \n }\n \n `,\n})\nexport class RichTextEditorComponent {\n content = input(emptyBlock());\n placeholders = input([]);\n editable = input(true);\n contentChanged = output();\n\n // Localizable-by-default copy (shared-UI convention).\n fieldLabel = input($localize`:@@richTextEditor.field:Tekst`);\n toolbarLabel = input($localize`:@@richTextEditor.toolbar:Opmaak`);\n boldLabel = input($localize`:@@richTextEditor.bold:Vet`);\n italicLabel = input($localize`:@@richTextEditor.italic:Cursief`);\n underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);\n insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);\n insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);\n bulletListLabel = input($localize`:@@richTextEditor.bulletList:Opsomming`);\n numberListLabel = input($localize`:@@richTextEditor.numberList:Genummerde lijst`);\n\n private editorEl = viewChild>('editor');\n private lastEmitted = '';\n\n private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;\n private autoFor = (key: string) =>\n this.placeholders().find((p) => p.key === key)?.autoResolvable ?? false;\n\n constructor() {\n // Render when content arrives/changes from OUTSIDE. Skip our own emitted value\n // flowing back (structural compare) so the caret isn't reset while typing.\n effect(() => {\n const content = this.content();\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const serialized = JSON.stringify(content);\n if (serialized === this.lastEmitted) return;\n renderInto(el, content, this.labelFor, this.autoFor);\n this.lastEmitted = serialized;\n });\n }\n\n protected emit() {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const block = readBlock(el);\n this.lastEmitted = JSON.stringify(block);\n this.contentChanged.emit(block);\n }\n\n protected format(cmd: 'bold' | 'italic' | 'underline') {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n el.focus();\n // ponytail: execCommand is deprecated but universally supported and zero-dependency;\n // if a browser drops it, this component is the one place to swap in a range-based impl.\n el.ownerDocument.execCommand(cmd);\n this.emit();\n }\n\n /** Bullet / numbered lists via execCommand — same deprecated-but-universal path as\n bold/italic (ponytail-noted on `format`); readBlock reads the resulting
            /
              . */\n protected list(kind: 'bullet' | 'number') {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n el.focus();\n el.ownerDocument.execCommand(kind === 'bullet' ? 'insertUnorderedList' : 'insertOrderedList');\n this.emit();\n }\n\n /** Ctrl/Cmd+B/I/U → our format() (+ preventDefault) so serialization runs and behaviour\n is consistent across browsers rather than relying on the native handler. */\n protected onKeydown(e: KeyboardEvent) {\n if (e.key === 'Backspace' || e.key === 'Delete') {\n this.deleteAdjacentChip(e);\n return;\n }\n if (!(e.ctrlKey || e.metaKey) || e.altKey) return;\n const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key.toLowerCase()] as\n | 'bold'\n | 'italic'\n | 'underline'\n | undefined;\n if (!cmd) return;\n e.preventDefault();\n this.format(cmd);\n }\n\n /** Backspace/Delete next to a contenteditable=false chip removes it ourselves —\n browsers otherwise leave these atomic chips undeletable. Selections fall through. */\n private deleteAdjacentChip(e: KeyboardEvent) {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const sel = el.ownerDocument.getSelection();\n if (!sel || !sel.isCollapsed || !sel.rangeCount) return;\n const range = sel.getRangeAt(0);\n if (!el.contains(range.startContainer)) return;\n const chip = adjacentChip(\n range.startContainer,\n range.startOffset,\n e.key === 'Backspace' ? -1 : 1,\n );\n if (!chip) return;\n e.preventDefault();\n chip.remove();\n this.emit();\n }\n\n protected insert(key: string) {\n const el = this.editorEl()?.nativeElement;\n if (!key || !el) return;\n el.focus();\n const chip = createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key));\n const sel = el.ownerDocument.getSelection();\n if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {\n const range = sel.getRangeAt(0);\n range.deleteContents();\n range.insertNode(chip);\n range.setStartAfter(chip);\n range.collapse(true);\n sel.removeAllRanges();\n sel.addRange(range);\n } else {\n (el.lastElementChild ?? el).appendChild(chip);\n }\n this.emit();\n }\n}\n", + "sourceCode": "import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';\nimport { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';\nimport { adjacentChip, createChip, readBlock, renderInto } from './rich-text-dom';\n\n/** A menu entry for the insert-placeholder control — a plain {key,label}, so the\n editor stays domain-free (it never sees the brief's PlaceholderDef). */\nexport interface PlaceholderOption {\n readonly key: string;\n readonly label: string;\n // Auto-resolvable fields are filled server-side at send; manual fields need a value.\n // Drives the chip's styling so the two read apart at a glance.\n readonly autoResolvable?: boolean;\n}\n\n/**\n * Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.\n *\n * It is the single quarantined boundary to the imperative `contenteditable` DOM:\n * `content` in, `contentChanged` (a `RichTextBlock`) out, holding NO letter state.\n * Placeholders render as non-editable chips and can only be inserted from the menu\n * (valid keys only) — never typed as raw braces. Swapping in a real editor library\n * later (TipTap) means replacing only this component; nothing else sees the DOM.\n */\n@Component({\n selector: 'app-rich-text-editor',\n styles: [\n `\n :host {\n display: block;\n }\n .rte-toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-sm);\n align-items: center;\n margin-block-end: var(--rhc-space-max-sm);\n }\n .rte-toolbar button {\n min-inline-size: 2.2rem;\n }\n .rte-sep {\n inline-size: 1px;\n align-self: stretch;\n background: var(--rhc-color-border-default);\n }\n .rte-editable {\n border: 1px solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: var(--rhc-space-max-md);\n min-block-size: 4rem;\n }\n .rte-editable[contenteditable='false'] {\n background: var(--rhc-color-cool-grey-100);\n }\n .rte-editable :is(p) {\n margin: 0 0 var(--rhc-space-max-sm);\n }\n .rte-editable :is(ul, ol) {\n margin: 0 0 var(--rhc-space-max-sm);\n padding-inline-start: 1.4em;\n }\n /* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side)\n vs manual (yellow, still needs a value). The read-only preview adds error/warning states.\n Chips are created imperatively (createChip) inside contenteditable, so they never receive\n Angular's _ngcontent scoping attribute — ::ng-deep is required or the rules won't match them.\n Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */\n :host ::ng-deep .rte-chip {\n border: 1px dashed var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0 0.3em;\n white-space: nowrap;\n }\n :host ::ng-deep .rte-chip[data-auto='true'] {\n background: var(--rhc-color-cool-grey-100);\n }\n :host ::ng-deep .rte-chip[data-auto='false'] {\n background: var(--rhc-color-geel-100);\n }\n :host ::ng-deep .rte-chip::before {\n content: '\\\\7B';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-end: 0.1em;\n }\n :host ::ng-deep .rte-chip::after {\n content: '\\\\7D';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-start: 0.1em;\n }\n `,\n ],\n template: `\n @if (editable()) {\n
              \n \n B\n \n \n I\n \n \n U\n \n \n \n •\n \n \n 1.\n \n @if (placeholders().length) {\n \n \n }\n
              \n }\n \n `,\n})\nexport class RichTextEditorComponent {\n content = input(emptyBlock());\n placeholders = input([]);\n editable = input(true);\n contentChanged = output();\n\n // Localizable-by-default copy (shared-UI convention).\n fieldLabel = input($localize`:@@richTextEditor.field:Tekst`);\n toolbarLabel = input($localize`:@@richTextEditor.toolbar:Opmaak`);\n boldLabel = input($localize`:@@richTextEditor.bold:Vet`);\n italicLabel = input($localize`:@@richTextEditor.italic:Cursief`);\n underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);\n insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);\n insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);\n bulletListLabel = input($localize`:@@richTextEditor.bulletList:Opsomming`);\n numberListLabel = input($localize`:@@richTextEditor.numberList:Genummerde lijst`);\n\n private editorEl = viewChild>('editor');\n private lastEmitted = '';\n\n private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;\n private autoFor = (key: string) =>\n this.placeholders().find((p) => p.key === key)?.autoResolvable ?? false;\n\n constructor() {\n // Render when content arrives/changes from OUTSIDE. Skip our own emitted value\n // flowing back (structural compare) so the caret isn't reset while typing.\n effect(() => {\n const content = this.content();\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const serialized = JSON.stringify(content);\n if (serialized === this.lastEmitted) return;\n renderInto(el, content, this.labelFor, this.autoFor);\n this.lastEmitted = serialized;\n });\n }\n\n protected emit() {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const block = readBlock(el);\n this.lastEmitted = JSON.stringify(block);\n this.contentChanged.emit(block);\n }\n\n protected format(cmd: 'bold' | 'italic' | 'underline') {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n el.focus();\n // ponytail: execCommand is deprecated but universally supported and zero-dependency;\n // if a browser drops it, this component is the one place to swap in a range-based impl.\n el.ownerDocument.execCommand(cmd);\n this.emit();\n }\n\n /** Bullet / numbered lists via execCommand — same deprecated-but-universal path as\n bold/italic (ponytail-noted on `format`); readBlock reads the resulting
                /
                  . */\n protected list(kind: 'bullet' | 'number') {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n el.focus();\n el.ownerDocument.execCommand(kind === 'bullet' ? 'insertUnorderedList' : 'insertOrderedList');\n this.emit();\n }\n\n /** Ctrl/Cmd+B/I/U → our format() (+ preventDefault) so serialization runs and behaviour\n is consistent across browsers rather than relying on the native handler. */\n protected onKeydown(e: KeyboardEvent) {\n if (e.key === 'Backspace' || e.key === 'Delete') {\n this.deleteAdjacentChip(e);\n return;\n }\n if (!(e.ctrlKey || e.metaKey) || e.altKey) return;\n const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key.toLowerCase()] as\n | 'bold'\n | 'italic'\n | 'underline'\n | undefined;\n if (!cmd) return;\n e.preventDefault();\n this.format(cmd);\n }\n\n /** Backspace/Delete next to a contenteditable=false chip removes it ourselves —\n browsers otherwise leave these atomic chips undeletable. Selections fall through. */\n private deleteAdjacentChip(e: KeyboardEvent) {\n const el = this.editorEl()?.nativeElement;\n if (!el) return;\n const sel = el.ownerDocument.getSelection();\n if (!sel || !sel.isCollapsed || !sel.rangeCount) return;\n const range = sel.getRangeAt(0);\n if (!el.contains(range.startContainer)) return;\n const chip = adjacentChip(\n range.startContainer,\n range.startOffset,\n e.key === 'Backspace' ? -1 : 1,\n );\n if (!chip) return;\n e.preventDefault();\n chip.remove();\n this.emit();\n }\n\n protected insert(key: string) {\n const el = this.editorEl()?.nativeElement;\n if (!key || !el) return;\n el.focus();\n const chip = createChip(el.ownerDocument, key, this.labelFor(key), this.autoFor(key));\n const sel = el.ownerDocument.getSelection();\n if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {\n const range = sel.getRangeAt(0);\n range.deleteContents();\n range.insertNode(chip);\n range.setStartAfter(chip);\n range.collapse(true);\n sel.removeAllRanges();\n sel.addRange(range);\n } else {\n (el.lastElementChild ?? el).appendChild(chip);\n }\n this.emit();\n }\n}\n", "assetsDirs": [], "styleUrlsData": "", "stylesData": "\n :host {\n display: block;\n }\n .rte-toolbar {\n display: flex;\n flex-wrap: wrap;\n gap: var(--rhc-space-max-sm);\n align-items: center;\n margin-block-end: var(--rhc-space-max-sm);\n }\n .rte-toolbar button {\n min-inline-size: 2.2rem;\n }\n .rte-sep {\n inline-size: 1px;\n align-self: stretch;\n background: var(--rhc-color-border-default);\n }\n .rte-editable {\n border: 1px solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: var(--rhc-space-max-md);\n min-block-size: 4rem;\n }\n .rte-editable[contenteditable='false'] {\n background: var(--rhc-color-cool-grey-100);\n }\n .rte-editable :is(p) {\n margin: 0 0 var(--rhc-space-max-sm);\n }\n .rte-editable :is(ul, ol) {\n margin: 0 0 var(--rhc-space-max-sm);\n padding-inline-start: 1.4em;\n }\n /* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side)\n vs manual (yellow, still needs a value). The read-only preview adds error/warning states.\n Chips are created imperatively (createChip) inside contenteditable, so they never receive\n Angular's _ngcontent scoping attribute — ::ng-deep is required or the rules won't match them.\n Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */\n :host ::ng-deep .rte-chip {\n border: 1px dashed var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-sm);\n padding: 0 0.3em;\n white-space: nowrap;\n }\n :host ::ng-deep .rte-chip[data-auto='true'] {\n background: var(--rhc-color-cool-grey-100);\n }\n :host ::ng-deep .rte-chip[data-auto='false'] {\n background: var(--rhc-color-geel-100);\n }\n :host ::ng-deep .rte-chip::before {\n content: '\\7B';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-end: 0.1em;\n }\n :host ::ng-deep .rte-chip::after {\n content: '\\7D';\n opacity: 0.6;\n font-weight: 700;\n margin-inline-start: 0.1em;\n }\n \n", @@ -30127,7 +30127,7 @@ "name": "Variant", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "\"primary\" | \"secondary\" | \"subtle\" | \"danger\"", + "rawtype": "\"primary\" | \"secondary\" | \"subtle\" | \"danger\" | \"ghost\"", "file": "src/app/shared/ui/button/button.component.ts", "deprecated": false, "deprecationMessage": "", @@ -37733,7 +37733,7 @@ "name": "Variant", "ctype": "miscellaneous", "subtype": "typealias", - "rawtype": "\"primary\" | \"secondary\" | \"subtle\" | \"danger\"", + "rawtype": "\"primary\" | \"secondary\" | \"subtle\" | \"danger\" | \"ghost\"", "file": "src/app/shared/ui/button/button.component.ts", "deprecated": false, "deprecationMessage": "", diff --git a/src/app/shared/ui/button/button.component.ts b/src/app/shared/ui/button/button.component.ts index 1e54402..e4e2f6f 100644 --- a/src/app/shared/ui/button/button.component.ts +++ b/src/app/shared/ui/button/button.component.ts @@ -1,6 +1,6 @@ import { Component, input } from '@angular/core'; -type Variant = 'primary' | 'secondary' | 'subtle' | 'danger'; +type Variant = 'primary' | 'secondary' | 'subtle' | 'danger' | 'ghost'; /** Atom: button. Thin wrapper over the CIBG/Bootstrap button CSS. */ @Component({ @@ -11,9 +11,10 @@ type Variant = 'primary' | 'secondary' | 'subtle' | 'danger'; [disabled]="disabled()" class="btn" [class.btn-primary]="variant() === 'primary'" - [class.btn-outline-primary]="variant() === 'secondary'" + [class.btn-secondary]="variant() === 'secondary'" [class.btn-link]="variant() === 'subtle'" [class.btn-danger]="variant() === 'danger'" + [class.btn-ghost]="variant() === 'ghost'" > diff --git a/src/app/shared/ui/button/button.stories.ts b/src/app/shared/ui/button/button.stories.ts index 60ecac7..1dd7390 100644 --- a/src/app/shared/ui/button/button.stories.ts +++ b/src/app/shared/ui/button/button.stories.ts @@ -16,4 +16,5 @@ export const Primary: Story = { args: { variant: 'primary' } }; export const Secondary: Story = { args: { variant: 'secondary' } }; export const Subtle: Story = { args: { variant: 'subtle' } }; export const Danger: Story = { args: { variant: 'danger' } }; +export const Ghost: Story = { args: { variant: 'ghost' } }; export const Disabled: Story = { args: { variant: 'primary', disabled: true } }; diff --git a/src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts b/src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts index 0321940..9877bed 100644 --- a/src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts +++ b/src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts @@ -95,7 +95,7 @@ export interface PlaceholderOption {