feat(brief): letter composition + two-person approval (teaching slice)
New `brief` context — a letter-composition feature with a drafter/approver approval workflow, built as a teaching vertical slice on the repo's existing FP + Elm + atomic-design patterns (see plan in ~/.claude/plans). Domain (pure): - Rich text as a serialisable value tree (placeholders are first-class nodes), moved to @shared/kernel/rich-text.ts so the shared editor can use it. - lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored. - brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot = deep value copy; derived diagnostics/editability. Full specs. Backend (.NET stub): - BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints, role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards, audit logging. Regenerated typed client via gen:api. +6 backend tests. Seam: - brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the parse boundary (+ spec). UI (atomic): - shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep contenteditable, DOM<->RichTextBlock round-trip tested). - brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments, letter-section, letter-composer, letter-preview, brief.page + /brief route. - Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link. Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared). Also included (same session): - Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap. - src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI). - .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully broken — $localize unresolved — dev + build). Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green, Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
39
src/app/shared/ui/checkbox/checkbox.component.ts
Normal file
39
src/app/shared/ui/checkbox/checkbox.component.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Component, forwardRef, input } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Native
|
||||
input for full keyboard + screen-reader support; the label is the click target. */
|
||||
@Component({
|
||||
selector: 'app-checkbox',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
label{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md);cursor:pointer}
|
||||
input{inline-size:1.1rem;block-size:1.1rem}
|
||||
`],
|
||||
template: `
|
||||
<label>
|
||||
<input type="checkbox" [id]="checkboxId()" [checked]="value" [disabled]="disabled"
|
||||
(change)="onToggle($event)" (blur)="onTouched()" />
|
||||
<span>{{ label() }}</span>
|
||||
</label>
|
||||
`,
|
||||
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxComponent), multi: true }],
|
||||
})
|
||||
export class CheckboxComponent implements ControlValueAccessor {
|
||||
checkboxId = input<string>();
|
||||
label = input('');
|
||||
|
||||
value = false;
|
||||
disabled = false;
|
||||
onChange: (v: boolean) => void = () => {};
|
||||
onTouched: () => void = () => {};
|
||||
|
||||
onToggle(e: Event) {
|
||||
this.value = (e.target as HTMLInputElement).checked;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
writeValue(v: boolean) { this.value = !!v; }
|
||||
registerOnChange(fn: (v: boolean) => void) { this.onChange = fn; }
|
||||
registerOnTouched(fn: () => void) { this.onTouched = fn; }
|
||||
setDisabledState(d: boolean) { this.disabled = d; }
|
||||
}
|
||||
15
src/app/shared/ui/checkbox/checkbox.stories.ts
Normal file
15
src/app/shared/ui/checkbox/checkbox.stories.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { CheckboxComponent } from './checkbox.component';
|
||||
|
||||
const meta: Meta<CheckboxComponent> = {
|
||||
title: 'Atoms/Checkbox',
|
||||
component: CheckboxComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-checkbox [label]="label" [checkboxId]="checkboxId"></app-checkbox>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<CheckboxComponent>;
|
||||
|
||||
export const Default: Story = { args: { label: 'Standaard aanhef', checkboxId: 'cb-1' } };
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
|
||||
/** Atom: a highlighted, non-editable placeholder chip for READ-ONLY rendering
|
||||
(preview, diagnostics). Distinct styling for auto-resolvable vs manual fields and
|
||||
for linter error/warning states. Domain-free and presentational — the caller
|
||||
passes label/state; a11y label announces the field name + its resolution status.
|
||||
(The editor renders its own inline chips inside contenteditable; this atom is for
|
||||
everywhere the letter is shown, not edited.) */
|
||||
@Component({
|
||||
selector: 'app-placeholder-chip',
|
||||
styles: [`
|
||||
.chip{
|
||||
display:inline-flex;align-items:center;gap:0.2em;
|
||||
border-radius:var(--rhc-border-radius-sm);
|
||||
padding:0 0.35em;line-height:1.6;border:1px solid transparent;white-space:nowrap;
|
||||
}
|
||||
.chip::before{content:'⌗';opacity:0.6;font-weight:700}
|
||||
.chip--auto{background:var(--rhc-color-cool-grey-100);color:var(--rhc-color-foreground-default)}
|
||||
.chip--manual{background:var(--rhc-color-geel-100);color:var(--rhc-color-foreground-default)}
|
||||
.chip--warning{background:var(--rhc-color-geel-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
|
||||
.chip--error{background:var(--rhc-color-rood-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
|
||||
`],
|
||||
template: `<span class="chip" [class]="'chip--' + variant()" [attr.aria-label]="ariaLabel()">{{ label() }}</span>`,
|
||||
})
|
||||
export class PlaceholderChipComponent {
|
||||
label = input.required<string>();
|
||||
autoResolvable = input(false);
|
||||
state = input<'ok' | 'warning' | 'error'>('ok');
|
||||
|
||||
// Copy is localizable-by-default per the shared-UI convention (like <app-async>).
|
||||
autoText = input($localize`:@@placeholderChip.auto:wordt automatisch ingevuld`);
|
||||
manualText = input($localize`:@@placeholderChip.manual:handmatig in te vullen`);
|
||||
warningText = input($localize`:@@placeholderChip.warning:let op`);
|
||||
errorText = input($localize`:@@placeholderChip.error:fout`);
|
||||
|
||||
protected variant = computed(() => {
|
||||
const s = this.state();
|
||||
return s !== 'ok' ? s : this.autoResolvable() ? 'auto' : 'manual';
|
||||
});
|
||||
|
||||
protected ariaLabel = computed(() => {
|
||||
const status = { auto: this.autoText(), manual: this.manualText(), warning: this.warningText(), error: this.errorText() }[this.variant()];
|
||||
return $localize`:@@placeholderChip.aria:Veld ${this.label()}:label:, ${status}:status:`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { PlaceholderChipComponent } from './placeholder-chip.component';
|
||||
|
||||
const meta: Meta<PlaceholderChipComponent> = {
|
||||
title: 'Atoms/Placeholder Chip',
|
||||
component: PlaceholderChipComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-placeholder-chip [label]="label" [autoResolvable]="autoResolvable" [state]="state"></app-placeholder-chip>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<PlaceholderChipComponent>;
|
||||
|
||||
export const AutoResolvable: Story = { args: { label: 'Naam zorgverlener', autoResolvable: true, state: 'ok' } };
|
||||
export const Manual: Story = { args: { label: 'Reden besluit', autoResolvable: false, state: 'ok' } };
|
||||
export const Warning: Story = { args: { label: 'Oud kenmerk', autoResolvable: true, state: 'warning' } };
|
||||
export const Error: Story = { args: { label: 'Onbekend veld', autoResolvable: false, state: 'error' } };
|
||||
51
src/app/shared/ui/rich-text-editor/rich-text-dom.spec.ts
Normal file
51
src/app/shared/ui/rich-text-editor/rich-text-dom.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { readBlock, renderInto } from './rich-text-dom';
|
||||
|
||||
const labelFor = (key: string) => ({ naam: 'Naam', datum: 'Datum' })[key] ?? key;
|
||||
|
||||
function roundTrip(block: RichTextBlock): RichTextBlock {
|
||||
const root = document.createElement('div');
|
||||
renderInto(root, block, labelFor);
|
||||
return readBlock(root);
|
||||
}
|
||||
|
||||
describe('rich-text DOM boundary', () => {
|
||||
it('round-trips text, marks, placeholders, line breaks and multiple paragraphs', () => {
|
||||
const block: RichTextBlock = {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Beste ' },
|
||||
{ type: 'placeholder', key: 'naam' },
|
||||
{ type: 'text', text: ' vet', marks: ['bold'] },
|
||||
{ type: 'lineBreak' },
|
||||
{ type: 'text', text: 'nieuwe regel' },
|
||||
],
|
||||
},
|
||||
{ nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }] },
|
||||
],
|
||||
};
|
||||
expect(roundTrip(block)).toEqual(block);
|
||||
});
|
||||
|
||||
it('round-trips an empty paragraph (filler <br> is not a line break)', () => {
|
||||
const empty: RichTextBlock = { paragraphs: [{ nodes: [] }] };
|
||||
expect(roundTrip(empty)).toEqual(empty);
|
||||
});
|
||||
|
||||
it('renders a placeholder as a non-editable chip carrying its key and label', () => {
|
||||
const root = document.createElement('div');
|
||||
renderInto(root, { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] }, labelFor);
|
||||
const chip = root.querySelector('.rte-chip') as HTMLElement;
|
||||
expect(chip.getAttribute('contenteditable')).toBe('false');
|
||||
expect(chip.dataset['phKey']).toBe('naam');
|
||||
expect(chip.textContent).toBe('Naam');
|
||||
});
|
||||
|
||||
it('reads combined marks in canonical order regardless of nesting', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<p><em><strong>x</strong></em></p>'; // italic wrapping bold
|
||||
expect(readBlock(root)).toEqual({ paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }] });
|
||||
});
|
||||
});
|
||||
114
src/app/shared/ui/rich-text-editor/rich-text-dom.ts
Normal file
114
src/app/shared/ui/rich-text-editor/rich-text-dom.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||
|
||||
/**
|
||||
* The quarantined boundary between the imperative `contenteditable` DOM and the
|
||||
* serialisable `RichTextBlock` value. Pure functions (given a DOM they render /
|
||||
* read) so they round-trip losslessly and can be unit-tested without Angular. The
|
||||
* rest of the app only ever sees `RichTextBlock` — this is the one place DOM leaks.
|
||||
*
|
||||
* ponytail: mark detection covers the tags/styles a browser's execCommand emits
|
||||
* (strong/b, em/i, u, and inline font-weight/style/decoration); exotic pasted markup
|
||||
* degrades to plain text rather than crashing.
|
||||
*/
|
||||
|
||||
const ORDER: readonly Mark[] = ['bold', 'italic', 'underline'];
|
||||
const MARK_TAG: Record<Mark, string> = { bold: 'strong', italic: 'em', underline: 'u' };
|
||||
|
||||
export function renderInto(root: HTMLElement, block: RichTextBlock, labelFor: (key: string) => string): void {
|
||||
const doc = root.ownerDocument;
|
||||
root.replaceChildren();
|
||||
for (const para of block.paragraphs) {
|
||||
const p = doc.createElement('p');
|
||||
p.className = 'rte-para';
|
||||
if (para.nodes.length === 0) {
|
||||
p.appendChild(doc.createElement('br')); // keep the empty line focusable
|
||||
} else {
|
||||
for (const node of para.nodes) p.appendChild(renderNode(node, labelFor, doc));
|
||||
}
|
||||
root.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one non-editable placeholder chip element (shared by initial render and
|
||||
live caret insertion). setAttribute reflects reliably in jsdom + browsers. */
|
||||
export function createChip(doc: Document, key: string, label: string): HTMLElement {
|
||||
const span = doc.createElement('span');
|
||||
span.dataset['phKey'] = key;
|
||||
span.setAttribute('contenteditable', 'false');
|
||||
span.className = 'rte-chip';
|
||||
span.textContent = label;
|
||||
return span;
|
||||
}
|
||||
|
||||
function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc: Document): Node {
|
||||
if (node.type === 'lineBreak') return doc.createElement('br');
|
||||
if (node.type === 'placeholder') return createChip(doc, node.key, labelFor(node.key));
|
||||
let el: Node = doc.createTextNode(node.text);
|
||||
// Nest marks in a canonical order so read-back is deterministic.
|
||||
for (const m of ORDER.filter((x) => node.marks?.includes(x))) {
|
||||
const wrap = doc.createElement(MARK_TAG[m]);
|
||||
wrap.appendChild(el);
|
||||
el = wrap;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
export function readBlock(root: HTMLElement): RichTextBlock {
|
||||
const paragraphs: { nodes: RichTextNode[] }[] = [];
|
||||
const blockEls = Array.from(root.children).filter((c) => c.tagName === 'P' || c.tagName === 'DIV');
|
||||
const containers = blockEls.length ? blockEls : [root];
|
||||
for (const el of containers) {
|
||||
const kids = Array.from(el.childNodes);
|
||||
const nodes: RichTextNode[] = [];
|
||||
// A lone <br> is the empty-line filler, not a content line break.
|
||||
if (!(kids.length === 1 && kids[0].nodeName === 'BR')) {
|
||||
for (const child of kids) collect(child, [], nodes);
|
||||
}
|
||||
paragraphs.push({ nodes });
|
||||
}
|
||||
return { paragraphs: paragraphs.length ? paragraphs : [{ nodes: [] }] };
|
||||
}
|
||||
|
||||
function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent ?? '';
|
||||
if (text !== '') out.push(marks.length ? { type: 'text', text, marks: canonical(marks) } : { type: 'text', text });
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName === 'BR') {
|
||||
out.push({ type: 'lineBreak' });
|
||||
return;
|
||||
}
|
||||
const key = el.dataset?.['phKey'];
|
||||
if (key != null) {
|
||||
out.push({ type: 'placeholder', key });
|
||||
return;
|
||||
}
|
||||
const m = markOf(el);
|
||||
const next = m ? [...marks, m] : marks;
|
||||
for (const child of Array.from(el.childNodes)) collect(child, next, out);
|
||||
}
|
||||
|
||||
function markOf(el: HTMLElement): Mark | null {
|
||||
switch (el.tagName) {
|
||||
case 'STRONG':
|
||||
case 'B':
|
||||
return 'bold';
|
||||
case 'EM':
|
||||
case 'I':
|
||||
return 'italic';
|
||||
case 'U':
|
||||
return 'underline';
|
||||
}
|
||||
const s = el.style;
|
||||
if (s.fontWeight === 'bold' || Number(s.fontWeight) >= 600) return 'bold';
|
||||
if (s.fontStyle === 'italic') return 'italic';
|
||||
if (s.textDecoration.includes('underline')) return 'underline';
|
||||
return null;
|
||||
}
|
||||
|
||||
function canonical(marks: Mark[]): Mark[] {
|
||||
return ORDER.filter((m) => marks.includes(m));
|
||||
}
|
||||
137
src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts
Normal file
137
src/app/shared/ui/rich-text-editor/rich-text-editor.component.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';
|
||||
import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||
import { createChip, readBlock, renderInto } from './rich-text-dom';
|
||||
|
||||
/** A menu entry for the insert-placeholder control — a plain {key,label}, so the
|
||||
editor stays domain-free (it never sees the brief's PlaceholderDef). */
|
||||
export interface PlaceholderOption {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.
|
||||
*
|
||||
* It is the single quarantined boundary to the imperative `contenteditable` DOM:
|
||||
* `content` in, `contentChanged` (a `RichTextBlock`) out, holding NO letter state.
|
||||
* Placeholders render as non-editable chips and can only be inserted from the menu
|
||||
* (valid keys only) — never typed as raw braces. Swapping in a real editor library
|
||||
* later (TipTap) means replacing only this component; nothing else sees the DOM.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-rich-text-editor',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.rte-toolbar{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-sm);align-items:center;margin-block-end:var(--rhc-space-max-sm)}
|
||||
.rte-toolbar button{min-inline-size:2.2rem}
|
||||
.rte-sep{inline-size:1px;align-self:stretch;background:var(--rhc-color-border-default)}
|
||||
.rte-editable{
|
||||
border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-sm);
|
||||
padding:var(--rhc-space-max-md);min-block-size:4rem;
|
||||
}
|
||||
.rte-editable[contenteditable='false']{background:var(--rhc-color-cool-grey-100)}
|
||||
.rte-editable :is(p){margin:0 0 var(--rhc-space-max-sm)}
|
||||
/* Placeholder chips inside the editor: one neutral highlight (the read-only
|
||||
preview distinguishes auto/manual/error via app-placeholder-chip). */
|
||||
.rte-editable .rte-chip{
|
||||
background:var(--rhc-color-cool-grey-100);border-radius:var(--rhc-border-radius-sm);
|
||||
padding:0 0.35em;white-space:nowrap;
|
||||
}
|
||||
.rte-editable .rte-chip::before{content:'⌗';opacity:0.6;font-weight:700;margin-inline-end:0.15em}
|
||||
`],
|
||||
template: `
|
||||
@if (editable()) {
|
||||
<div class="rte-toolbar" role="toolbar" [attr.aria-label]="toolbarLabel()">
|
||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('bold')" [attr.aria-label]="boldLabel()"><b>B</b></button>
|
||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('italic')" [attr.aria-label]="italicLabel()"><i>I</i></button>
|
||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('underline')" [attr.aria-label]="underlineLabel()"><u>U</u></button>
|
||||
@if (placeholders().length) {
|
||||
<span class="rte-sep" aria-hidden="true"></span>
|
||||
<label>
|
||||
<span class="app-text-subtle">{{ insertLabel() }}</span>
|
||||
<select #ins (change)="insert(ins.value); ins.value = ''">
|
||||
<option value="" selected>{{ insertPrompt() }}</option>
|
||||
@for (p of placeholders(); track p.key) {
|
||||
<option [value]="p.key">{{ p.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div #editor class="rte-editable" [attr.contenteditable]="editable()" (input)="emit()"
|
||||
role="textbox" aria-multiline="true" [attr.aria-label]="fieldLabel()"></div>
|
||||
`,
|
||||
})
|
||||
export class RichTextEditorComponent {
|
||||
content = input<RichTextBlock>(emptyBlock());
|
||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||
editable = input(true);
|
||||
contentChanged = output<RichTextBlock>();
|
||||
|
||||
// Localizable-by-default copy (shared-UI convention).
|
||||
fieldLabel = input($localize`:@@richTextEditor.field:Tekst`);
|
||||
toolbarLabel = input($localize`:@@richTextEditor.toolbar:Opmaak`);
|
||||
boldLabel = input($localize`:@@richTextEditor.bold:Vet`);
|
||||
italicLabel = input($localize`:@@richTextEditor.italic:Cursief`);
|
||||
underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);
|
||||
insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);
|
||||
insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);
|
||||
|
||||
private editorEl = viewChild<ElementRef<HTMLElement>>('editor');
|
||||
private lastEmitted = '';
|
||||
|
||||
private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;
|
||||
|
||||
constructor() {
|
||||
// Render when content arrives/changes from OUTSIDE. Skip our own emitted value
|
||||
// flowing back (structural compare) so the caret isn't reset while typing.
|
||||
effect(() => {
|
||||
const content = this.content();
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const serialized = JSON.stringify(content);
|
||||
if (serialized === this.lastEmitted) return;
|
||||
renderInto(el, content, this.labelFor);
|
||||
this.lastEmitted = serialized;
|
||||
});
|
||||
}
|
||||
|
||||
protected emit() {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const block = readBlock(el);
|
||||
this.lastEmitted = JSON.stringify(block);
|
||||
this.contentChanged.emit(block);
|
||||
}
|
||||
|
||||
protected format(cmd: 'bold' | 'italic' | 'underline') {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
// ponytail: execCommand is deprecated but universally supported and zero-dependency;
|
||||
// if a browser drops it, this component is the one place to swap in a range-based impl.
|
||||
el.ownerDocument.execCommand(cmd);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
protected insert(key: string) {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!key || !el) return;
|
||||
el.focus();
|
||||
const chip = createChip(el.ownerDocument, key, this.labelFor(key));
|
||||
const sel = el.ownerDocument.getSelection();
|
||||
if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(chip);
|
||||
range.setStartAfter(chip);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} else {
|
||||
(el.lastElementChild ?? el).appendChild(chip);
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { RichTextEditorComponent } from './rich-text-editor.component';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
|
||||
const sample: RichTextBlock = {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte heer/mevrouw ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
{ nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }, { type: 'text', text: ' hebben wij besloten.' }] },
|
||||
],
|
||||
};
|
||||
|
||||
const placeholders = [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener' },
|
||||
{ key: 'datum', label: 'Datum' },
|
||||
{ key: 'big_nummer', label: 'BIG-nummer' },
|
||||
];
|
||||
|
||||
const meta: Meta<RichTextEditorComponent> = {
|
||||
title: 'Molecules/Rich Text Editor',
|
||||
component: RichTextEditorComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-rich-text-editor [content]="content" [placeholders]="placeholders" [editable]="editable"></app-rich-text-editor>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<RichTextEditorComponent>;
|
||||
|
||||
export const Editing: Story = { args: { content: sample, placeholders, editable: true } };
|
||||
export const Empty: Story = { args: { content: { paragraphs: [{ nodes: [] }] }, placeholders, editable: true } };
|
||||
export const ReadOnly: Story = { args: { content: sample, placeholders, editable: false } };
|
||||
Reference in New Issue
Block a user