The folder now equals the layer, as CLAUDE.md decision 2 requires. 33 directories move by git mv (25 flat, plus upload/'s 8 subfolders split across all three layers). 28 distinct @shared/ui/* specifiers rewrite across 73 files, longest-first. Five relative imports inside upload/ become @shared/ui aliases because their sibling now lives in a different layer; two stay relative because both ends stay in the same layer. Four .mdx docs get their seven broken story imports fixed; atomic-design.mdx's page-shell import is untouched, because layout/ does not move. No component, template, story title, or layer-tag comment changes. That is RD-28's job. Verified against the ticket's acceptance commands: the 26 flat directories become exactly 3 layer folders with the counts the ticket names, only three @shared/ui/* prefixes remain (atoms, molecules, organisms), the .mdx import count holds at 7, and the relative-import count inside ui/ drops from 7 to 2 as decision 4 requires. The @shared/ui/ occurrence count moves from 200 to 205: decision 4 mandates turning 5 of those 7 relative imports into @shared/ui/* aliases, which decision 3's "200 before, 200 after" check does not account for. The 5-occurrence gap is exactly the 5 conversions decision 4 names, not a lost or duplicated specifier. npm run ci --full passes: lint, typecheck, dep:check, format, tokens, seam, both apps' + both libraries' tests, both apps' localized build, audit, backend tests, all three generated-artifact drift checks, and both Storybook instances' build + axe-core a11y suite (67+45 suites, 198+112 tests, all green). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
225 lines
8.4 KiB
TypeScript
225 lines
8.4 KiB
TypeScript
import {
|
|
Component,
|
|
ElementRef,
|
|
computed,
|
|
effect,
|
|
input,
|
|
output,
|
|
untracked,
|
|
viewChild,
|
|
} from '@angular/core';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
|
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
|
import { SpinnerComponent } from '@shared/ui/atoms/spinner/spinner.component';
|
|
import { StepperComponent } from '@shared/ui/molecules/stepper/stepper.component';
|
|
import { whenTag } from '@shared/kernel/fp';
|
|
|
|
/** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk".
|
|
Shared so every wizard's `primaryLabel` reads the same way. */
|
|
export const naarStapLabel = (stepNumber: number, stepLabel: string) =>
|
|
$localize`:@@wizard.naarStap:Naar stap ${stepNumber}:nummer: - ${stepLabel}:label:`;
|
|
|
|
/** A flat validation error pointing at a field: `id` matches the field's anchor. */
|
|
export interface WizardError {
|
|
readonly id: string;
|
|
readonly message: string;
|
|
}
|
|
|
|
/** The wizard shell's lifecycle union. The `Failed` variant carries the localized
|
|
message intact, so the shell needs no separate input to say what went wrong. */
|
|
export type WizardPhase =
|
|
| { tag: 'Editing' }
|
|
| { tag: 'Submitting' }
|
|
| { tag: 'Submitted' }
|
|
| { tag: 'Failed'; message: string };
|
|
|
|
/**
|
|
* Template: the canonical shell every wizard renders into, so they cannot drift.
|
|
* It owns the consistent outline — CIBG stappenindicator (title merged in) + error
|
|
* summary + the horizontal <form> + the CIBG procesnavigatie button row + the
|
|
* submitting/submitted/failed states — and the a11y focus management.
|
|
*
|
|
* Presentational and unidirectional: all state stays in the wizard container
|
|
* (the Elm-style store). Inputs flow down; the container reacts to the outputs
|
|
* and dispatches messages. The step's own fields are projected as the default
|
|
* slot; the success screen is projected via [wizardSuccess].
|
|
*/
|
|
@Component({
|
|
selector: 'app-wizard-shell',
|
|
imports: [FormsModule, ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
|
|
// CIBG-GAP EXTENSION: Foutmelding — the vendored build has no error-summary/
|
|
// Veldvalidatie list pattern (verified absent from huisstijl.min.css); the
|
|
// .es-title/.es-list rules below are the hand-rolled surface, see cibg-gaps.mdx.
|
|
// They render inside a vendored `.feedback-error` alert (app-alert).
|
|
styles: [
|
|
`
|
|
.es-title {
|
|
margin: 0 0 var(--rhc-space-max-sm);
|
|
}
|
|
.es-list {
|
|
margin: 0;
|
|
padding-inline-start: var(--rhc-space-max-xl);
|
|
}
|
|
/* Default link color doesn't meet contrast on the error-alert's light-red surface. */
|
|
.es-list a {
|
|
color: var(--rhc-color-lintblauw-700);
|
|
}
|
|
`,
|
|
],
|
|
template: `
|
|
@switch (phase().tag) {
|
|
@case ('Editing') {
|
|
<app-stepper
|
|
class="app-section"
|
|
[steps]="steps()"
|
|
[current]="current()"
|
|
[processName]="processName()"
|
|
[stepTitle]="stepTitle()"
|
|
(stepSelected)="goToStep.emit($event)"
|
|
/>
|
|
@if (errors().length) {
|
|
<div
|
|
#errorSummary
|
|
tabindex="-1"
|
|
role="alert"
|
|
aria-labelledby="wizard-error-title"
|
|
class="app-section"
|
|
>
|
|
<app-alert type="error">
|
|
<h3 id="wizard-error-title" class="es-title" i18n="@@wizard.errorTitle">
|
|
Er ging iets mis met uw invoer
|
|
</h3>
|
|
<ul class="es-list">
|
|
@for (e of errors(); track e.id) {
|
|
<li>
|
|
<a [href]="'#' + e.id" (click)="goToField($event, e.id)">{{ e.message }}</a>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</app-alert>
|
|
</div>
|
|
}
|
|
<form (ngSubmit)="primary.emit()" class="form-horizontal app-section">
|
|
<div class="form-header">
|
|
<div class="form-action">
|
|
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
|
</div>
|
|
</div>
|
|
<!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's
|
|
".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em token-ok: hex named in prose, not a style value
|
|
gap. The shell stays group-agnostic and does NOT add its own fieldset (an
|
|
outer grey fieldset would hide the white gaps between the page groups). -->
|
|
<ng-content />
|
|
<hr />
|
|
<div class="d-flex flex-column flex-sm-row-reverse">
|
|
<div class="m-0">
|
|
<app-button type="submit" variant="primary">{{ primaryLabel() }}</app-button>
|
|
</div>
|
|
@if (canGoBack()) {
|
|
<app-button
|
|
type="button"
|
|
variant="subtle"
|
|
class="me-auto"
|
|
(click)="back.emit()"
|
|
i18n="@@wizard.terugVorige"
|
|
>Terug naar vorige stap</app-button
|
|
>
|
|
}
|
|
</div>
|
|
<div class="app-section">
|
|
<app-button
|
|
type="button"
|
|
variant="subtle"
|
|
(click)="cancel.emit()"
|
|
i18n="@@wizard.annuleren"
|
|
>Annuleren</app-button
|
|
>
|
|
</div>
|
|
</form>
|
|
}
|
|
@case ('Submitting') {
|
|
<app-spinner /> <span>{{ submittingLabel() }}</span>
|
|
}
|
|
@case ('Submitted') {
|
|
<ng-content select="[wizardSuccess]" />
|
|
}
|
|
@case ('Failed') {
|
|
<app-alert type="error">{{ failedMessage() }}</app-alert>
|
|
<div class="app-section">
|
|
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen"
|
|
>Opnieuw proberen</app-button
|
|
>
|
|
</div>
|
|
}
|
|
}
|
|
`,
|
|
})
|
|
export class WizardShellComponent {
|
|
steps = input.required<string[]>();
|
|
current = input.required<number>();
|
|
stepTitle = input.required<string>();
|
|
/** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */
|
|
processName = input('');
|
|
phase = input.required<WizardPhase>();
|
|
primaryLabel = input.required<string>();
|
|
canGoBack = input(false);
|
|
errors = input<readonly WizardError[]>([]);
|
|
submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`);
|
|
|
|
/** The `Failed` message, or '' otherwise. `@switch` can't narrow a union in a
|
|
template, so the narrowing happens here via the shared `whenTag` helper. */
|
|
protected failedMessage = computed(() => whenTag(this.phase(), 'Failed')?.message ?? '');
|
|
|
|
primary = output<void>();
|
|
back = output<void>();
|
|
cancel = output<void>();
|
|
retry = output<void>();
|
|
/** A visited step number was clicked in the stepper — back-navigation only. */
|
|
goToStep = output<number>();
|
|
|
|
/** Error-summary link: focus the field instead of letting the browser navigate.
|
|
A fragment href resolves against <base href="/">, not the current route, so
|
|
a real navigation would reload to "/" and bounce to login. */
|
|
protected goToField(ev: Event, id: string) {
|
|
ev.preventDefault();
|
|
document.getElementById(id)?.focus(); // focus() scrolls the input into view
|
|
}
|
|
|
|
private stepper = viewChild(StepperComponent);
|
|
private errorSummary = viewChild<ElementRef<HTMLElement>>('errorSummary');
|
|
|
|
constructor() {
|
|
// A11y: move focus to the step title when the step changes (skip first run
|
|
// so we don't grab focus on initial load). Tracks current(), which is value-
|
|
// stable across keystrokes, so typing never steals focus.
|
|
let firstStep = true;
|
|
effect(() => {
|
|
this.current();
|
|
if (firstStep) {
|
|
firstStep = false;
|
|
return;
|
|
}
|
|
untracked(() => queueMicrotask(() => this.stepper()?.focusTitle()));
|
|
});
|
|
// A11y: when validation errors first appear (after a failed submit), move
|
|
// focus to the error summary so it's announced. Only on the rising edge
|
|
// (none → some): typing rebuilds the errors array each keystroke, and
|
|
// re-focusing then would scroll the page up mid-edit. The summary keeps
|
|
// role="alert", so content changes are still announced without the jump.
|
|
let firstErr = true;
|
|
let hadErrors = false;
|
|
effect(() => {
|
|
const has = this.errors().length > 0;
|
|
if (firstErr) {
|
|
firstErr = false;
|
|
hadErrors = has;
|
|
return;
|
|
}
|
|
if (has && !hadErrors)
|
|
untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus()));
|
|
hadErrors = has;
|
|
});
|
|
}
|
|
}
|