From 7a582ae2fa2c1562611e7b5bc3dfb8df048d194f Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Sat, 27 Jun 2026 13:21:54 +0200 Subject: [PATCH] Rijkshuisstijl restyle + wizard fixes Chrome: two-tier Rijksoverheid header (white brand bar + lint-blue breadcrumb bar, route-driven), dark multi-column footer, white page surface. Session shown via a shared SESSION_PORT token (keeps shared/ free of the auth context). Overview ("Mijn overzicht") rebuilt to the NL Design System #392 pattern: side-nav + "Wat moet ik regelen" task list (derived) + "Mijn registratie" cards. New shared components: card, task-list, side-nav; pure tasksFromProfile (+spec). Wizards: grey form panel, connected numbered stepper, form-field "(verplicht)" markers + styled description/error, full-width inputs. Propagated to login, detail, change-request, address-fields. Bug fixes: - wizard-shell: add FormsModule so NgForm intercepts submit (wizards now advance; no native GET leaking choices into the URL). - wizard-shell: error-summary links focus the field instead of navigating (a fragment href resolved against reloaded to "/" and bounced to login). - wizard-shell: error-summary focus only on the rising edge, so typing while errors are shown no longer scrolls the page up. Co-Authored-By: Claude Opus 4.8 --- src/app/app.config.ts | 3 + .../ui/login-form/login-form.component.ts | 10 +- .../herregistratie-wizard.component.ts | 6 +- .../intake-wizard/intake-wizard.component.ts | 12 +- src/app/registratie/domain/tasks.spec.ts | 41 +++++ src/app/registratie/domain/tasks.ts | 59 ++++++++ .../address-fields.component.ts | 11 +- .../change-request-form.component.ts | 14 +- src/app/registratie/ui/dashboard.page.ts | 140 +++++++++++------- .../registratie-wizard.component.ts | 6 +- src/app/registratie/ui/registratie.page.ts | 3 +- .../ui/registration-detail.page.ts | 2 +- .../registration-summary.component.ts | 7 +- src/app/shared/application/session.port.ts | 14 ++ .../layout/breadcrumb/breadcrumb-trail.ts | 32 ++++ .../layout/breadcrumb/breadcrumb.component.ts | 20 ++- .../layout/page-shell/page-shell.component.ts | 20 +-- .../layout/side-nav/side-nav.component.ts | 50 +++++++ .../layout/side-nav/side-nav.stories.ts | 27 ++++ .../site-footer/site-footer.component.ts | 33 ++++- .../site-header/site-header.component.ts | 76 +++++++--- .../wizard-shell/wizard-shell.component.ts | 29 +++- src/app/shared/ui/card/card.component.ts | 32 ++++ src/app/shared/ui/card/card.stories.ts | 20 +++ .../ui/form-field/form-field.component.ts | 18 ++- .../shared/ui/stepper/stepper.component.ts | 27 +++- .../ui/task-list/task-list.component.ts | 51 +++++++ .../shared/ui/task-list/task-list.stories.ts | 22 +++ .../ui/text-input/text-input.component.ts | 4 + src/styles.scss | 37 ++++- 30 files changed, 677 insertions(+), 149 deletions(-) create mode 100644 src/app/registratie/domain/tasks.spec.ts create mode 100644 src/app/registratie/domain/tasks.ts create mode 100644 src/app/shared/application/session.port.ts create mode 100644 src/app/shared/layout/breadcrumb/breadcrumb-trail.ts create mode 100644 src/app/shared/layout/side-nav/side-nav.component.ts create mode 100644 src/app/shared/layout/side-nav/side-nav.stories.ts create mode 100644 src/app/shared/ui/card/card.component.ts create mode 100644 src/app/shared/ui/card/card.stories.ts create mode 100644 src/app/shared/ui/task-list/task-list.component.ts create mode 100644 src/app/shared/ui/task-list/task-list.stories.ts diff --git a/src/app/app.config.ts b/src/app/app.config.ts index b87f087..c8f6071 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -7,6 +7,8 @@ import localeNl from '@angular/common/locales/nl'; import { routes } from './app.routes'; import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor'; import { provideApiClient } from '@shared/infrastructure/api-client.provider'; +import { SESSION_PORT } from '@shared/application/session.port'; +import { SessionStore } from '@auth/application/session.store'; registerLocaleData(localeNl); @@ -18,6 +20,7 @@ export const appConfig: ApplicationConfig = { // a query param could otherwise force errors on the live app. provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor] : [])), provideApiClient(), + { provide: SESSION_PORT, useExisting: SessionStore }, { provide: LOCALE_ID, useValue: 'nl' }, ] }; diff --git a/src/app/auth/ui/login-form/login-form.component.ts b/src/app/auth/ui/login-form/login-form.component.ts index 50364ce..7314933 100644 --- a/src/app/auth/ui/login-form/login-form.component.ts +++ b/src/app/auth/ui/login-form/login-form.component.ts @@ -9,18 +9,16 @@ import { ButtonComponent } from '@shared/ui/button/button.component'; selector: 'app-login-form', imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent], template: ` -
- + + - + -
- Inloggen met DigiD -
+ Inloggen met DigiD `, }) diff --git a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index b38ee73..46855c8 100644 --- a/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -35,16 +35,16 @@ import { ApiClient } from '@shared/infrastructure/api-client'; (retry)="onRetry()"> @if (step() === 1) { - + - + } @else { - + diff --git a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts index 4ad8ffa..07b1756 100644 --- a/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts +++ b/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts @@ -52,31 +52,31 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }]; @switch (step()) { @case ('buitenland') { - + @if (answers().buitenlandGewerkt === 'ja') { - + - + } } @case ('werk') { - + @if (scholingZichtbaar()) { - + } @if (answers().scholingGevolgd === 'ja') { - + } diff --git a/src/app/registratie/domain/tasks.spec.ts b/src/app/registratie/domain/tasks.spec.ts new file mode 100644 index 0000000..ea28fd7 --- /dev/null +++ b/src/app/registratie/domain/tasks.spec.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { tasksFromProfile } from './tasks'; +import { Registration } from './registration'; + +const base: Registration = { + bigNummer: '12345678901', + naam: 'A. Tester', + beroep: 'arts', + registratiedatum: '2018-01-01', + geboortedatum: '1980-01-01', + status: { tag: 'Geregistreerd', herregistratieDatum: '2026-12-31' }, +}; + +describe('tasksFromProfile', () => { + it('offers herregistratie when within the deadline window', () => { + const tasks = tasksFromProfile(base, new Date('2026-06-27')); + expect(tasks).toHaveLength(1); + expect(tasks[0].to).toBe('/herregistratie'); + expect(tasks[0].description).toContain('31 december 2026'); + }); + + it('offers nothing when the deadline is far away', () => { + const far: Registration = { ...base, status: { tag: 'Geregistreerd', herregistratieDatum: '2030-12-31' } }; + expect(tasksFromProfile(far, new Date('2026-06-27'))).toHaveLength(0); + }); + + it('surfaces a notice for a suspended registration', () => { + const reg: Registration = { ...base, status: { tag: 'Geschorst', geschorstTot: '2027-01-01', reden: 'Onderzoek' } }; + const tasks = tasksFromProfile(reg, new Date('2026-06-27')); + expect(tasks).toHaveLength(1); + expect(tasks[0].title).toContain('geschorst'); + expect(tasks[0].description).toBe('Onderzoek'); + }); + + it('surfaces a notice for a struck-off registration', () => { + const reg: Registration = { ...base, status: { tag: 'Doorgehaald', doorgehaaldOp: '2025-01-01', reden: 'Op eigen verzoek' } }; + const tasks = tasksFromProfile(reg, new Date('2026-06-27')); + expect(tasks).toHaveLength(1); + expect(tasks[0].title).toContain('doorgehaald'); + }); +}); diff --git a/src/app/registratie/domain/tasks.ts b/src/app/registratie/domain/tasks.ts new file mode 100644 index 0000000..bf270aa --- /dev/null +++ b/src/app/registratie/domain/tasks.ts @@ -0,0 +1,59 @@ +import { Registration } from './registration'; +import { herregistratieDeadline, isHerregistratieEligible } from './registration.policy'; + +/** + * What the dashboard's "Wat moet ik regelen" list needs. Pure presentation data + * derived from the registration — no Angular. Mirrors the shared TaskItem shape. + */ +export interface PortalTask { + title: string; + description: string; + to: string; + actionLabel: string; +} + +function formatNL(d: Date): string { + return d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' }); +} + +/** + * Derive the open tasks for a professional from their registration (pure; + * `today` is injected so it's testable). Herregistratie within its window is the + * primary action; a suspended/struck-off registration surfaces as a notice-task. + * Reuses the same rules the detail/summary use (registration.policy). + */ +export function tasksFromProfile(reg: Registration, today: Date): PortalTask[] { + const tasks: PortalTask[] = []; + + if (isHerregistratieEligible(reg, today)) { + const deadline = herregistratieDeadline(reg); + tasks.push({ + title: 'Vraag uw herregistratie aan', + description: deadline + ? `Verleng uw registratie vóór ${formatNL(deadline)}.` + : 'U kunt nu uw herregistratie aanvragen.', + to: '/herregistratie', + actionLabel: 'Herregistratie aanvragen', + }); + } + + if (reg.status.tag === 'Geschorst') { + tasks.push({ + title: 'Uw registratie is geschorst', + description: reg.status.reden, + to: '/registratie', + actionLabel: 'Bekijk uw gegevens', + }); + } + + if (reg.status.tag === 'Doorgehaald') { + tasks.push({ + title: 'Uw registratie is doorgehaald', + description: reg.status.reden, + to: '/registratie', + actionLabel: 'Bekijk uw gegevens', + }); + } + + return tasks; +} diff --git a/src/app/registratie/ui/address-fields/address-fields.component.ts b/src/app/registratie/ui/address-fields/address-fields.component.ts index f01f9f7..69ca78e 100644 --- a/src/app/registratie/ui/address-fields/address-fields.component.ts +++ b/src/app/registratie/ui/address-fields/address-fields.component.ts @@ -19,20 +19,25 @@ export type AdresErrors = Partial>; @Component({ selector: 'app-address-fields', imports: [FormsModule, FormFieldComponent, TextInputComponent], + styles: [` + fieldset{border:0;margin:0;padding:0;min-inline-size:0} + legend{padding:0;font-weight:var(--rhc-text-font-weight-semi-bold);margin-block-end:var(--rhc-space-max-md)} + app-form-field + app-form-field{display:block;margin-block-start:var(--rhc-space-max-lg)} + `], template: `
{{ legend() }} - + - + - + diff --git a/src/app/registratie/ui/change-request-form/change-request-form.component.ts b/src/app/registratie/ui/change-request-form/change-request-form.component.ts index 5b6fc1e..e8816d1 100644 --- a/src/app/registratie/ui/change-request-form/change-request-form.component.ts +++ b/src/app/registratie/ui/change-request-form/change-request-form.component.ts @@ -22,12 +22,12 @@ import { ApiClient } from '@shared/infrastructure/api-client'; Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen bericht. -
+
Nieuwe wijziging doorgeven
} @else { Adreswijziging doorgeven -
+ @if (failedError()) { -
Het indienen is niet gelukt: {{ failedError() }}
+ Het indienen is niet gelukt: {{ failedError() }} } -
- - {{ state().tag === 'Submitting' ? 'Bezig met indienen…' : 'Wijziging indienen' }} - -
+ + {{ state().tag === 'Submitting' ? 'Bezig met indienen…' : 'Wijziging indienen' }} + } `, diff --git a/src/app/registratie/ui/dashboard.page.ts b/src/app/registratie/ui/dashboard.page.ts index 4586777..480e6d2 100644 --- a/src/app/registratie/ui/dashboard.page.ts +++ b/src/app/registratie/ui/dashboard.page.ts @@ -5,82 +5,122 @@ import { LinkComponent } from '@shared/ui/link/link.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; +import { CardComponent } from '@shared/ui/card/card.component'; +import { TaskListComponent } from '@shared/ui/task-list/task-list.component'; +import { SideNavComponent, NavItem } from '@shared/layout/side-nav/side-nav.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component'; import { RegistrationTableComponent } from '@registratie/ui/registration-table/registration-table.component'; import { BigProfileStore } from '@registratie/application/big-profile.store'; +import { Registration } from '@registratie/domain/registration'; +import { tasksFromProfile } from '@registratie/domain/tasks'; +/** Page: "Mijn overzicht" — the portal home, following the NL Design System + "Mijn omgeving" pattern (side nav + "Wat moet ik regelen" + "Mijn zaken"). */ @Component({ selector: 'app-dashboard-page', imports: [ PageShellComponent, HeadingComponent, LinkComponent, AlertComponent, SkeletonComponent, - DataRowComponent, ...ASYNC, RegistrationSummaryComponent, RegistrationTableComponent, + DataRowComponent, CardComponent, TaskListComponent, SideNavComponent, ...ASYNC, + RegistrationSummaryComponent, RegistrationTableComponent, ], template: ` - -
- @if (store.pendingHerregistratie()) { - Uw herregistratie-aanvraag is in behandeling. - } + +
+ - - - - -
- Persoonsgegevens (BRP) -
- - - -
-
-
- - - -
+
+ @if (store.pendingHerregistratie()) { + Uw herregistratie-aanvraag is in behandeling. + } -
- Specialismen en aantekeningen - - - + + + @let tasks = tasksFor($any(p).registration); + +
+ Wat moet ik regelen + @if (tasks.length) { + + } @else { +

U heeft op dit moment niets openstaan.

+ } +
+ +
+ Mijn registratie +
+ +
+ +
+ + + +
+
+
- - - -

U heeft nog geen specialismen of aantekeningen.

+
-
-
- Wat wilt u doen? -
    - @for (a of acties; track a.to) { -
  • - {{ a.titel }} -

    {{ a.tekst }}

    - {{ a.actie }} → -
  • - } -
-
+
+ Specialismen en aantekeningen +
+ + + + + + + + +

U heeft nog geen specialismen of aantekeningen.

+
+
+
+
+ +
+ Wat wilt u doen? +
    + @for (a of acties; track a.to) { +
  • + +

    {{ a.tekst }}

    + {{ a.actie }} → +
    +
  • + } +
+
+
`, }) export class DashboardPage { protected store = inject(BigProfileStore); + private readonly today = new Date(); - /** Primary actions, rendered as an accessible card grid. */ + protected tasksFor(reg: Registration) { + return tasksFromProfile(reg, this.today); + } + + /** Portal sections (navigation, not actions). */ + protected readonly nav: NavItem[] = [ + { label: 'Overzicht', to: '/dashboard' }, + { label: 'Mijn gegevens', to: '/registratie' }, + { label: 'Herregistratie', to: '/herregistratie' }, + { label: 'Inschrijven', to: '/registreren' }, + ]; + + /** Primary transactional actions, as a card grid. */ protected readonly acties = [ { to: '/registreren', titel: 'Inschrijven', tekst: 'Schrijf u in in het BIG-register via de registratiewizard.', actie: 'Start inschrijving' }, - { to: '/registratie', titel: 'Mijn gegevens', tekst: 'Bekijk uw gegevens of geef een wijziging door.', actie: 'Bekijk gegevens' }, - { to: '/herregistratie', titel: 'Herregistratie', tekst: 'Vraag uw herregistratie aan.', actie: 'Vraag aan' }, - { to: '/intake', titel: 'Herregistratie-intake', tekst: 'Vragenlijst met vertakkingen.', actie: 'Start intake' }, - { to: '/concepts', titel: 'Functionele patronen', tekst: 'Onmogelijke toestanden onmogelijk maken.', actie: 'Bekijk patronen' }, + { to: '/herregistratie', titel: 'Herregistratie aanvragen', tekst: 'Verleng uw registratie voor de komende periode.', actie: 'Vraag aan' }, + { to: '/registratie', titel: 'Gegevens wijzigen', tekst: 'Bekijk uw gegevens of geef een wijziging door.', actie: 'Bekijk gegevens' }, ]; } diff --git a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index 5f7aba9..00563eb 100644 --- a/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -83,12 +83,12 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed" [value]="{ straat: draft().straat ?? '', postcode: draft().postcode ?? '', woonplaats: draft().woonplaats ?? '' }" [errors]="{ straat: err('straat'), postcode: err('postcode'), woonplaats: err('woonplaats') }" (fieldChange)="set($event.key, $event.value)" /> - + @if (draft().correspondentie === 'email') { - + } @@ -97,7 +97,7 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed" @case ('beroep') { - + diff --git a/src/app/registratie/ui/registratie.page.ts b/src/app/registratie/ui/registratie.page.ts index 3d3510a..d20fa43 100644 --- a/src/app/registratie/ui/registratie.page.ts +++ b/src/app/registratie/ui/registratie.page.ts @@ -11,8 +11,7 @@ import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/r template: ` + backLink="/dashboard"> In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard diff --git a/src/app/registratie/ui/registration-detail.page.ts b/src/app/registratie/ui/registration-detail.page.ts index 60aa606..222193c 100644 --- a/src/app/registratie/ui/registration-detail.page.ts +++ b/src/app/registratie/ui/registration-detail.page.ts @@ -23,7 +23,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store'; -
+
diff --git a/src/app/registratie/ui/registration-summary/registration-summary.component.ts b/src/app/registratie/ui/registration-summary/registration-summary.component.ts index 4c7ee64..83b1ff9 100644 --- a/src/app/registratie/ui/registration-summary/registration-summary.component.ts +++ b/src/app/registratie/ui/registration-summary/registration-summary.component.ts @@ -4,13 +4,14 @@ import { Registration } from '@registratie/domain/registration'; import { statusColor, statusLabel } from '@registratie/domain/registration.policy'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component'; +import { CardComponent } from '@shared/ui/card/card.component'; /** Organism: registration summary card. Composes data-row molecules + status-badge atom. */ @Component({ selector: 'app-registration-summary', - imports: [DatePipe, DataRowComponent, StatusBadgeComponent], + imports: [DatePipe, DataRowComponent, StatusBadgeComponent, CardComponent], template: ` -
+
@@ -34,7 +35,7 @@ import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.compo } }
-
+ `, }) export class RegistrationSummaryComponent { diff --git a/src/app/shared/application/session.port.ts b/src/app/shared/application/session.port.ts new file mode 100644 index 0000000..da78cad --- /dev/null +++ b/src/app/shared/application/session.port.ts @@ -0,0 +1,14 @@ +import { InjectionToken, Signal } from '@angular/core'; + +/** + * A shared seam for the chrome to show "who is logged in" + log out, WITHOUT + * shared/ depending on the auth context (the import-direction rule forbids that). + * Auth provides this token at the app root (see app.config.ts); the shared header + * injects it. SessionStore satisfies this shape structurally. + */ +export interface SessionPort { + readonly session: Signal<{ naam: string } | null>; + logout(): void; +} + +export const SESSION_PORT = new InjectionToken('SESSION_PORT'); diff --git a/src/app/shared/layout/breadcrumb/breadcrumb-trail.ts b/src/app/shared/layout/breadcrumb/breadcrumb-trail.ts new file mode 100644 index 0000000..6eb9838 --- /dev/null +++ b/src/app/shared/layout/breadcrumb/breadcrumb-trail.ts @@ -0,0 +1,32 @@ +import { BreadcrumbItem } from './breadcrumb.component'; + +/** Route → breadcrumb label + parent. The app has a small fixed route set + (see app.routes.ts), so a static map is enough — no per-page wiring. + ponytail: static map, not a breadcrumb service; revisit if routes go dynamic. */ +interface Crumb { label: string; parent?: string } + +const ROUTES: Record = { + '/dashboard': { label: 'Mijn overzicht' }, + '/registratie': { label: 'Mijn gegevens', parent: '/dashboard' }, + '/registreren': { label: 'Inschrijven', parent: '/dashboard' }, + '/herregistratie': { label: 'Herregistratie', parent: '/dashboard' }, + '/intake': { label: 'Herregistratie-intake', parent: '/dashboard' }, + '/concepts': { label: 'Functionele patronen', parent: '/dashboard' }, +}; + +/** Build the breadcrumb trail for a router url (query/fragment stripped). + Returns [] for unknown routes (e.g. /login) so the bar can hide itself. */ +export function trailFor(url: string): BreadcrumbItem[] { + const path = url.split(/[?#]/)[0]; + const trail: BreadcrumbItem[] = []; + let cursor: string | undefined = path; + while (cursor) { + const node: Crumb | undefined = ROUTES[cursor]; + if (!node) break; + trail.unshift({ label: node.label, link: cursor }); + cursor = node.parent; + } + // The current (last) page is not a link. + if (trail.length) delete trail[trail.length - 1].link; + return trail; +} diff --git a/src/app/shared/layout/breadcrumb/breadcrumb.component.ts b/src/app/shared/layout/breadcrumb/breadcrumb.component.ts index 7c17e3d..286d4d3 100644 --- a/src/app/shared/layout/breadcrumb/breadcrumb.component.ts +++ b/src/app/shared/layout/breadcrumb/breadcrumb.component.ts @@ -14,19 +14,27 @@ export interface BreadcrumbItem { imports: [RouterLink], styles: [` :host{display:block} - .list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0} + .list{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md);align-items:center;list-style:none;margin:0;padding:0;font-size:var(--rhc-text-font-size-sm)} + .crumb{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)} + a{color:var(--rhc-color-foreground-link);text-decoration:underline} + a:hover{color:var(--rhc-color-foreground-link-hover)} + .current{color:var(--rhc-color-foreground-subtle)} .sep{color:var(--rhc-color-foreground-subtle)} + /* Inverse: on the blue header bar, everything is white. */ + :host(.inverse) a, + :host(.inverse) .current, + :host(.inverse) .sep { color: var(--rhc-color-foreground-on-primary); } `], template: `
} -
+
@if (canGoBack()) { @@ -89,6 +90,14 @@ export class WizardShellComponent { cancel = output(); retry = output(); + /** Error-summary link: focus the field instead of letting the browser navigate. + A fragment href resolves against , 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 stepHeading = viewChild>('stepHeading'); private errorSummary = viewChild>('errorSummary'); @@ -102,14 +111,18 @@ export class WizardShellComponent { if (firstStep) { firstStep = false; return; } untracked(() => queueMicrotask(() => this.stepHeading()?.nativeElement.focus())); }); - // A11y: when validation errors appear (after a failed submit), move focus to - // the error summary so it's announced. The reducer returns a fresh errors - // object per attempt, so resubmitting the same invalid step re-announces. + // 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; return; } - if (has) untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus())); + if (firstErr) { firstErr = false; hadErrors = has; return; } + if (has && !hadErrors) untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus())); + hadErrors = has; }); } } diff --git a/src/app/shared/ui/card/card.component.ts b/src/app/shared/ui/card/card.component.ts new file mode 100644 index 0000000..447512c --- /dev/null +++ b/src/app/shared/ui/card/card.component.ts @@ -0,0 +1,32 @@ +import { Component, input } from '@angular/core'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; + +/** Molecule: a content card. Standardises the repeated card surface (white, + subtle border, rounded, padded) so pages compose cards instead of hand-rolling + `
`. Optional heading; the rest is projected. */ +@Component({ + selector: 'app-card', + imports: [HeadingComponent], + styles: [` + :host{display:block;block-size:100%} + .card{ + background:var(--rhc-color-wit); + border:var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle); + border-radius:var(--rhc-border-radius-md); + padding:var(--rhc-space-max-xl); + block-size:100%; + box-sizing:border-box; + } + .card > * + *{margin-block-start:var(--rhc-space-max-md)} + `], + template: ` +
+ @if (heading()) { {{ heading() }} } + +
+ `, +}) +export class CardComponent { + heading = input(); + level = input<1 | 2 | 3 | 4 | 5>(3); +} diff --git a/src/app/shared/ui/card/card.stories.ts b/src/app/shared/ui/card/card.stories.ts new file mode 100644 index 0000000..281d554 --- /dev/null +++ b/src/app/shared/ui/card/card.stories.ts @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { CardComponent } from './card.component'; + +const meta: Meta = { + title: 'Molecules/Card', + component: CardComponent, + render: (args) => ({ + props: args, + template: ` + +

Een kaart groepeert samenhangende inhoud op een schone, omkaderde vlak.

+
`, + }), + args: { heading: 'Persoonsgegevens (BRP)', level: 3 }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const ZonderKop: Story = { args: { heading: undefined } }; diff --git a/src/app/shared/ui/form-field/form-field.component.ts b/src/app/shared/ui/form-field/form-field.component.ts index f0fc0dc..93838c7 100644 --- a/src/app/shared/ui/form-field/form-field.component.ts +++ b/src/app/shared/ui/form-field/form-field.component.ts @@ -1,18 +1,27 @@ -import { Component, input } from '@angular/core'; +import { Component, booleanAttribute, input } from '@angular/core'; /** Molecule: form field = label + projected control + optional error/description. Reused by both the login form and the change-request form. */ @Component({ selector: 'app-form-field', + styles: [` + :host{display:block} + .label{display:block;font-weight:var(--rhc-text-font-weight-semi-bold);margin-block-end:var(--rhc-space-max-sm)} + .req{font-weight:var(--rhc-text-font-weight-regular);color:var(--rhc-color-foreground-subtle)} + .desc{color:var(--rhc-color-foreground-subtle);font-size:var(--rhc-text-font-size-sm);margin-block-end:var(--rhc-space-max-sm)} + .error{color:var(--rhc-color-foreground-default);font-weight:var(--rhc-text-font-weight-semi-bold);margin-block-start:var(--rhc-space-max-sm);border-inline-start:var(--rhc-border-width-md) solid var(--rhc-color-rood-500);padding-inline-start:var(--rhc-space-max-md)} + `], template: `
- + @if (description()) { -
{{ description() }}
+
{{ description() }}
} @if (error()) { - + }
`, @@ -22,4 +31,5 @@ export class FormFieldComponent { fieldId = input.required(); description = input(); error = input(); + required = input(false, { transform: booleanAttribute }); } diff --git a/src/app/shared/ui/stepper/stepper.component.ts b/src/app/shared/ui/stepper/stepper.component.ts index c7e7a22..8e9ab00 100644 --- a/src/app/shared/ui/stepper/stepper.component.ts +++ b/src/app/shared/ui/stepper/stepper.component.ts @@ -8,18 +8,33 @@ import { Component, input } from '@angular/core'; styles: [` :host{display:block} .sr-only{position:absolute;inline-size:1px;block-size:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} - .steps{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-xl);list-style:none;margin:0;padding:0} - .step{display:flex;align-items:center;gap:var(--rhc-space-max-md);color:var(--rhc-color-foreground-subtle)} + .steps{display:flex;list-style:none;margin:0;padding:0} + .step{ + flex:1 1 0;min-inline-size:0;position:relative; + display:flex;flex-direction:column;align-items:center;gap:var(--rhc-space-max-sm); + text-align:center;color:var(--rhc-color-foreground-subtle); + } + /* connector line to the previous step, drawn behind the circles */ + .step::before{ + content:'';position:absolute;z-index:0; + inset-block-start:calc(var(--rhc-space-max-4xl) / 2); + inset-inline-start:-50%;inline-size:100%;block-size:var(--rhc-border-width-md); + background:var(--rhc-color-cool-grey-300); + } + .step:first-child::before{display:none} + .step--done::before,.step--current::before{background:var(--rhc-color-lintblauw-500)} .num{ - display:grid;place-items:center; + position:relative;z-index:1;display:grid;place-items:center; inline-size:var(--rhc-space-max-4xl);block-size:var(--rhc-space-max-4xl); border-radius:var(--rhc-border-radius-round); - border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-400); + border:var(--rhc-border-width-md) solid var(--rhc-color-cool-grey-300); + background:var(--rhc-color-wit); font-weight:var(--rhc-text-font-weight-bold); } - .step--done .num{background:var(--rhc-color-lintblauw-500);border-color:var(--rhc-color-lintblauw-500);color:var(--rhc-color-wit)} + .label{font-size:var(--rhc-text-font-size-sm);padding-inline:var(--rhc-space-max-sm)} + .step--done .num{background:var(--rhc-color-lintblauw-500);border-color:var(--rhc-color-lintblauw-500);color:var(--rhc-color-foreground-on-primary)} .step--current{color:var(--rhc-color-foreground-default)} - .step--current .num{background:var(--rhc-color-lintblauw-700);border-color:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit)} + .step--current .num{background:var(--rhc-color-lintblauw-700);border-color:var(--rhc-color-lintblauw-700);color:var(--rhc-color-foreground-on-primary)} .step--current .label{font-weight:var(--rhc-text-font-weight-semi-bold)} `], template: ` diff --git a/src/app/shared/ui/task-list/task-list.component.ts b/src/app/shared/ui/task-list/task-list.component.ts new file mode 100644 index 0000000..90f47f1 --- /dev/null +++ b/src/app/shared/ui/task-list/task-list.component.ts @@ -0,0 +1,51 @@ +import { Component, input } from '@angular/core'; +import { LinkComponent } from '@shared/ui/link/link.component'; + +/** Presentational task shape — what "Wat moet ik regelen" renders. Domain-free so + shared/ stays independent of any context (a context's task type that has these + fields is structurally assignable). */ +export interface TaskItem { + readonly title: string; + readonly description: string; + readonly to: string; + readonly actionLabel: string; +} + +/** Molecule: the "Wat moet ik regelen" action list (NL Design System "Mijn + omgeving" pattern). Each task is a titled row with a call to action. */ +@Component({ + selector: 'app-task-list', + imports: [LinkComponent], + styles: [` + :host{display:block} + .tasks{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--rhc-space-max-md)} + .task{ + display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-md) var(--rhc-space-max-xl); + align-items:center;justify-content:space-between; + background:var(--rhc-color-wit); + border:var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle); + border-inline-start:var(--rhc-border-width-md) solid var(--rhc-color-lintblauw-700); + border-radius:var(--rhc-border-radius-md); + padding:var(--rhc-space-max-lg) var(--rhc-space-max-xl); + } + .title{margin:0 0 var(--rhc-space-max-xs);font-weight:var(--rhc-text-font-weight-semi-bold)} + .desc{margin:0;color:var(--rhc-color-foreground-subtle);font-size:var(--rhc-text-font-size-sm)} + .cta{flex:0 0 auto} + `], + template: ` +
    + @for (t of tasks(); track t.title) { +
  • +
    +

    {{ t.title }}

    +

    {{ t.description }}

    +
    + {{ t.actionLabel }} → +
  • + } +
+ `, +}) +export class TaskListComponent { + tasks = input.required(); +} diff --git a/src/app/shared/ui/task-list/task-list.stories.ts b/src/app/shared/ui/task-list/task-list.stories.ts new file mode 100644 index 0000000..e0e0320 --- /dev/null +++ b/src/app/shared/ui/task-list/task-list.stories.ts @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/angular'; +import { applicationConfig } from '@storybook/angular'; +import { provideRouter } from '@angular/router'; +import { TaskListComponent } from './task-list.component'; + +const meta: Meta = { + title: 'Molecules/Task List', + component: TaskListComponent, + decorators: [applicationConfig({ providers: [provideRouter([])] })], + render: (args) => ({ props: args, template: `` }), +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + tasks: [ + { title: 'Vraag uw herregistratie aan', description: 'Verleng uw registratie vóór 31 december 2026.', to: '/herregistratie', actionLabel: 'Herregistratie aanvragen' }, + { title: 'Controleer uw adresgegevens', description: 'Uw adres is langer dan een jaar niet bevestigd.', to: '/registratie', actionLabel: 'Bekijk uw gegevens' }, + ], + }, +}; diff --git a/src/app/shared/ui/text-input/text-input.component.ts b/src/app/shared/ui/text-input/text-input.component.ts index c575db8..11d8425 100644 --- a/src/app/shared/ui/text-input/text-input.component.ts +++ b/src/app/shared/ui/text-input/text-input.component.ts @@ -4,6 +4,10 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; /** Atom: text input. Utrecht textbox wired up as a form control (ngModel/reactive). */ @Component({ selector: 'app-text-input', + styles: [` + :host{display:block} + input{inline-size:100%;box-sizing:border-box} + `], template: ` * + * { margin-block-start: var(--rhc-space-max-xl); } + +/* Bordered info box (outline, no fill) — e.g. a "Vragen?" contact block. */ +.app-info-box { + border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default); + border-radius: var(--rhc-border-radius-md); + padding: var(--rhc-space-max-xl); +} + /* Route transitions (withViewTransitions): cross-fade the routed CONTENT only. The chrome gets its own stable view-transition-name so it's lifted out of the `root` snapshot and stays put while the content fades. */