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 <base href="/"> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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' },
|
||||
]
|
||||
};
|
||||
|
||||
@@ -9,18 +9,16 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
selector: 'app-login-form',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
|
||||
template: `
|
||||
<form (ngSubmit)="submitted.emit(bsn)">
|
||||
<app-form-field label="BSN" fieldId="bsn" description="9 cijfers (demo: vul iets in)">
|
||||
<form (ngSubmit)="submitted.emit(bsn)" class="app-form app-form-panel">
|
||||
<app-form-field label="BSN" fieldId="bsn" required description="9 cijfers (demo: vul iets in)">
|
||||
<app-text-input inputId="bsn" [(ngModel)]="bsn" name="bsn" placeholder="123456789" />
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field label="Wachtwoord" fieldId="pw">
|
||||
<app-form-field label="Wachtwoord" fieldId="pw" required>
|
||||
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
|
||||
</app-form-field>
|
||||
|
||||
<div style="margin-top:1rem">
|
||||
<app-button type="submit" variant="primary">Inloggen met DigiD</app-button>
|
||||
</div>
|
||||
<app-button type="submit" variant="primary">Inloggen met DigiD</app-button>
|
||||
</form>
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -35,16 +35,16 @@ import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
(retry)="onRetry()">
|
||||
|
||||
@if (step() === 1) {
|
||||
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()">
|
||||
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" required [error]="errUren()">
|
||||
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
|
||||
name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Aantal jaren werkzaam" fieldId="jaren" [error]="errJaren()">
|
||||
<app-form-field label="Aantal jaren werkzaam" fieldId="jaren" required [error]="errJaren()">
|
||||
<app-text-input inputId="jaren" [ngModel]="draft().jaren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'jaren', value: $event })"
|
||||
name="jaren" [invalid]="!!errJaren()" placeholder="bijv. 5" />
|
||||
</app-form-field>
|
||||
} @else {
|
||||
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
|
||||
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" required [error]="errPunten()">
|
||||
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
|
||||
name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" />
|
||||
</app-form-field>
|
||||
|
||||
@@ -52,31 +52,31 @@ const JA_NEE = [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }];
|
||||
|
||||
@switch (step()) {
|
||||
@case ('buitenland') {
|
||||
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenlandGewerkt" [error]="err('buitenlandGewerkt')">
|
||||
<app-form-field label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?" fieldId="buitenlandGewerkt" required [error]="err('buitenlandGewerkt')">
|
||||
<app-radio-group name="buitenlandGewerkt" [options]="jaNee"
|
||||
[ngModel]="answers().buitenlandGewerkt ?? ''" (ngModelChange)="set('buitenlandGewerkt', $event)" />
|
||||
</app-form-field>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<app-form-field label="In welk land?" fieldId="land" [error]="err('land')">
|
||||
<app-form-field label="In welk land?" fieldId="land" required [error]="err('land')">
|
||||
<app-text-input inputId="land" [ngModel]="answers().land ?? ''" (ngModelChange)="set('land', $event)" name="land" placeholder="bijv. België" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" [error]="err('buitenlandseUren')">
|
||||
<app-form-field label="Hoeveel uur heeft u daar gewerkt?" fieldId="buitenlandseUren" required [error]="err('buitenlandseUren')">
|
||||
<app-text-input inputId="buitenlandseUren" [ngModel]="answers().buitenlandseUren ?? ''" (ngModelChange)="set('buitenlandseUren', $event)" name="buitenlandseUren" placeholder="bijv. 800" />
|
||||
</app-form-field>
|
||||
}
|
||||
}
|
||||
@case ('werk') {
|
||||
<app-form-field label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" [error]="err('uren')">
|
||||
<app-form-field label="Gewerkte uren in Nederland (afgelopen 5 jaar)" fieldId="uren" required [error]="err('uren')">
|
||||
<app-text-input inputId="uren" [ngModel]="answers().uren ?? ''" (ngModelChange)="set('uren', $event)" name="uren" placeholder="bijv. 4160" />
|
||||
</app-form-field>
|
||||
@if (scholingZichtbaar()) {
|
||||
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholingGevolgd" [error]="err('scholingGevolgd')">
|
||||
<app-form-field label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?" fieldId="scholingGevolgd" required [error]="err('scholingGevolgd')">
|
||||
<app-radio-group name="scholingGevolgd" [options]="jaNee"
|
||||
[ngModel]="answers().scholingGevolgd ?? ''" (ngModelChange)="set('scholingGevolgd', $event)" />
|
||||
</app-form-field>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="err('punten')">
|
||||
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" required [error]="err('punten')">
|
||||
<app-text-input inputId="punten" [ngModel]="answers().punten ?? ''" (ngModelChange)="set('punten', $event)" name="punten" placeholder="bijv. 200" />
|
||||
</app-form-field>
|
||||
}
|
||||
|
||||
41
src/app/registratie/domain/tasks.spec.ts
Normal file
41
src/app/registratie/domain/tasks.spec.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
59
src/app/registratie/domain/tasks.ts
Normal file
59
src/app/registratie/domain/tasks.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -19,20 +19,25 @@ export type AdresErrors = Partial<Record<keyof AdresValue, string>>;
|
||||
@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: `
|
||||
<fieldset class="utrecht-form-fieldset utrecht-form-fieldset--html-fieldset">
|
||||
<legend class="utrecht-form-fieldset__legend utrecht-form-fieldset__legend--html-legend">{{ legend() }}</legend>
|
||||
<app-form-field label="Straat en huisnummer" [fieldId]="idPrefix() + '-straat'" [error]="errors().straat">
|
||||
<app-form-field label="Straat en huisnummer" [fieldId]="idPrefix() + '-straat'" required [error]="errors().straat">
|
||||
<app-text-input [inputId]="idPrefix() + '-straat'" [invalid]="!!errors().straat"
|
||||
[ngModel]="value().straat" (ngModelChange)="fieldChange.emit({ key: 'straat', value: $event })"
|
||||
name="straat" [ngModelOptions]="{ standalone: true }" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Postcode" [fieldId]="idPrefix() + '-postcode'" [error]="errors().postcode">
|
||||
<app-form-field label="Postcode" [fieldId]="idPrefix() + '-postcode'" required [error]="errors().postcode">
|
||||
<app-text-input [inputId]="idPrefix() + '-postcode'" [invalid]="!!errors().postcode"
|
||||
[ngModel]="value().postcode" (ngModelChange)="fieldChange.emit({ key: 'postcode', value: $event })"
|
||||
name="postcode" placeholder="1234 AB" [ngModelOptions]="{ standalone: true }" />
|
||||
</app-form-field>
|
||||
<app-form-field label="Woonplaats" [fieldId]="idPrefix() + '-woonplaats'" [error]="errors().woonplaats">
|
||||
<app-form-field label="Woonplaats" [fieldId]="idPrefix() + '-woonplaats'" required [error]="errors().woonplaats">
|
||||
<app-text-input [inputId]="idPrefix() + '-woonplaats'" [invalid]="!!errors().woonplaats"
|
||||
[ngModel]="value().woonplaats" (ngModelChange)="fieldChange.emit({ key: 'woonplaats', value: $event })"
|
||||
name="woonplaats" [ngModelOptions]="{ standalone: true }" />
|
||||
|
||||
@@ -22,12 +22,12 @@ import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
<app-alert type="ok">
|
||||
Uw adreswijziging is ontvangen (referentie {{ referentie() }}). U ontvangt binnen 5 werkdagen bericht.
|
||||
</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="dispatch({ tag: 'Reset' })">Nieuwe wijziging doorgeven</app-button>
|
||||
</div>
|
||||
} @else {
|
||||
<app-heading [level]="2">Adreswijziging doorgeven</app-heading>
|
||||
<form (ngSubmit)="onSubmit()" style="max-width:28rem">
|
||||
<form (ngSubmit)="onSubmit()" class="app-form app-form-panel app-section">
|
||||
<app-address-fields
|
||||
idPrefix="cr"
|
||||
[value]="adres()"
|
||||
@@ -35,14 +35,12 @@ import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
(fieldChange)="dispatch({ tag: 'SetField', key: $event.key, value: $event.value })" />
|
||||
|
||||
@if (failedError()) {
|
||||
<div style="margin-top:1rem"><app-alert type="error">Het indienen is niet gelukt: {{ failedError() }}</app-alert></div>
|
||||
<app-alert type="error">Het indienen is niet gelukt: {{ failedError() }}</app-alert>
|
||||
}
|
||||
|
||||
<div style="margin-top:1rem">
|
||||
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
|
||||
{{ state().tag === 'Submitting' ? 'Bezig met indienen…' : 'Wijziging indienen' }}
|
||||
</app-button>
|
||||
</div>
|
||||
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
|
||||
{{ state().tag === 'Submitting' ? 'Bezig met indienen…' : 'Wijziging indienen' }}
|
||||
</app-button>
|
||||
</form>
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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: `
|
||||
<app-page-shell heading="Mijn BIG-registratie">
|
||||
<div class="app-stack">
|
||||
@if (store.pendingHerregistratie()) {
|
||||
<app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert>
|
||||
}
|
||||
<app-page-shell heading="Mijn overzicht" intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken.">
|
||||
<div class="app-overview">
|
||||
<app-side-nav [items]="nav" />
|
||||
|
||||
<!-- ONE state for two combined services (BIG-register + BRP). -->
|
||||
<app-async [data]="store.profile()">
|
||||
<ng-template appAsyncLoaded let-p>
|
||||
<app-registration-summary [reg]="$any(p).registration" />
|
||||
<div class="rhc-card rhc-card--default app-section">
|
||||
<app-heading [level]="2">Persoonsgegevens (BRP)</app-heading>
|
||||
<dl class="rhc-data-summary rhc-data-summary--row">
|
||||
<app-data-row key="Straat" [value]="$any(p).person.adres.straat" />
|
||||
<app-data-row key="Postcode" [value]="$any(p).person.adres.postcode" />
|
||||
<app-data-row key="Woonplaats" [value]="$any(p).person.adres.woonplaats" />
|
||||
</dl>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
<app-skeleton height="2.5rem" [count]="6" />
|
||||
</ng-template>
|
||||
</app-async>
|
||||
<div class="app-stack">
|
||||
@if (store.pendingHerregistratie()) {
|
||||
<app-alert type="info">Uw herregistratie-aanvraag is in behandeling.</app-alert>
|
||||
}
|
||||
|
||||
<section>
|
||||
<app-heading [level]="2">Specialismen en aantekeningen</app-heading>
|
||||
<app-async [data]="store.aantekeningen()">
|
||||
<ng-template appAsyncLoaded let-r>
|
||||
<app-registration-table [rows]="$any(r)" />
|
||||
<app-async [data]="store.profile()">
|
||||
<ng-template appAsyncLoaded let-p>
|
||||
@let tasks = tasksFor($any(p).registration);
|
||||
|
||||
<section>
|
||||
<app-heading [level]="2">Wat moet ik regelen</app-heading>
|
||||
@if (tasks.length) {
|
||||
<app-task-list class="app-section" [tasks]="tasks" />
|
||||
} @else {
|
||||
<p class="rhc-paragraph app-text-subtle">U heeft op dit moment niets openstaan.</p>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<app-heading [level]="2">Mijn registratie</app-heading>
|
||||
<div class="app-section">
|
||||
<app-registration-summary [reg]="$any(p).registration" />
|
||||
</div>
|
||||
<app-card class="app-section" heading="Persoonsgegevens (BRP)">
|
||||
<dl class="rhc-data-summary rhc-data-summary--row">
|
||||
<app-data-row key="Straat" [value]="$any(p).person.adres.straat" />
|
||||
<app-data-row key="Postcode" [value]="$any(p).person.adres.postcode" />
|
||||
<app-data-row key="Woonplaats" [value]="$any(p).person.adres.woonplaats" />
|
||||
</dl>
|
||||
</app-card>
|
||||
</section>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
<app-skeleton height="2.5rem" [count]="3" />
|
||||
</ng-template>
|
||||
<ng-template appAsyncEmpty>
|
||||
<p class="rhc-paragraph">U heeft nog geen specialismen of aantekeningen.</p>
|
||||
<app-skeleton height="2.5rem" [count]="6" />
|
||||
</ng-template>
|
||||
</app-async>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<app-heading [level]="2">Wat wilt u doen?</app-heading>
|
||||
<ul class="app-card-grid app-section">
|
||||
@for (a of acties; track a.to) {
|
||||
<li class="rhc-card rhc-card--default">
|
||||
<app-heading [level]="3">{{ a.titel }}</app-heading>
|
||||
<p class="rhc-paragraph">{{ a.tekst }}</p>
|
||||
<app-link [to]="a.to">{{ a.actie }} →</app-link>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<app-heading [level]="2">Specialismen en aantekeningen</app-heading>
|
||||
<div class="app-section">
|
||||
<app-async [data]="store.aantekeningen()">
|
||||
<ng-template appAsyncLoaded let-r>
|
||||
<app-registration-table [rows]="$any(r)" />
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading>
|
||||
<app-skeleton height="2.5rem" [count]="3" />
|
||||
</ng-template>
|
||||
<ng-template appAsyncEmpty>
|
||||
<p class="rhc-paragraph app-text-subtle">U heeft nog geen specialismen of aantekeningen.</p>
|
||||
</ng-template>
|
||||
</app-async>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<app-heading [level]="2">Wat wilt u doen?</app-heading>
|
||||
<ul class="app-card-grid app-section">
|
||||
@for (a of acties; track a.to) {
|
||||
<li>
|
||||
<app-card [heading]="a.titel">
|
||||
<p class="rhc-paragraph">{{ a.tekst }}</p>
|
||||
<app-link [to]="a.to">{{ a.actie }} →</app-link>
|
||||
</app-card>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
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' },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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)" />
|
||||
<app-form-field label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" [error]="err('correspondentie')">
|
||||
<app-form-field label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" required [error]="err('correspondentie')">
|
||||
<app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')"
|
||||
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" />
|
||||
</app-form-field>
|
||||
@if (draft().correspondentie === 'email') {
|
||||
<app-form-field label="E-mailadres" fieldId="email" [error]="err('email')">
|
||||
<app-form-field label="E-mailadres" fieldId="email" required [error]="err('email')">
|
||||
<app-text-input inputId="email" type="email" [invalid]="!!err('email')" [ngModel]="draft().email ?? ''" (ngModelChange)="set('email', $event)" name="email" placeholder="naam@voorbeeld.nl" />
|
||||
</app-form-field>
|
||||
}
|
||||
@@ -97,7 +97,7 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
||||
@case ('beroep') {
|
||||
<app-async [data]="lookupRd()">
|
||||
<ng-template appAsyncLoaded let-data>
|
||||
<app-form-field label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" [error]="err('diploma')">
|
||||
<app-form-field label="Kies het diploma waarmee u zich wilt registreren" fieldId="diploma" required [error]="err('diploma')">
|
||||
<app-radio-group name="diploma" [options]="diplomaOptions($any(data))" [invalid]="!!err('diploma')"
|
||||
[ngModel]="diplomaKeuze()" (ngModelChange)="onDiplomaKeuze($any(data), $event)" />
|
||||
</app-form-field>
|
||||
|
||||
@@ -11,8 +11,7 @@ import { RegistratieWizardComponent } from '@registratie/ui/registratie-wizard/r
|
||||
template: `
|
||||
<app-page-shell
|
||||
heading="Inschrijven in het BIG-register"
|
||||
backLink="/dashboard"
|
||||
[breadcrumb]="[{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Inschrijven in het BIG-register' }]">
|
||||
backLink="/dashboard">
|
||||
<app-alert type="info">
|
||||
In drie stappen schrijft u zich in: uw adres en correspondentievoorkeur, het
|
||||
diploma waarmee u zich registreert, en een controle. Uw gegevens blijven bewaard
|
||||
|
||||
@@ -23,7 +23,7 @@ import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
</ng-template>
|
||||
</app-async>
|
||||
|
||||
<div style="margin-top:2rem">
|
||||
<div class="app-section">
|
||||
<app-change-request-form />
|
||||
</div>
|
||||
</app-page-shell>
|
||||
|
||||
@@ -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: `
|
||||
<div class="rhc-card rhc-card--default">
|
||||
<app-card>
|
||||
<dl class="rhc-data-summary rhc-data-summary--row">
|
||||
<app-data-row key="BIG-nummer" [value]="reg().bigNummer" />
|
||||
<app-data-row key="Naam" [value]="reg().naam" />
|
||||
@@ -34,7 +35,7 @@ import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.compo
|
||||
}
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</app-card>
|
||||
`,
|
||||
})
|
||||
export class RegistrationSummaryComponent {
|
||||
|
||||
14
src/app/shared/application/session.port.ts
Normal file
14
src/app/shared/application/session.port.ts
Normal file
@@ -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<SessionPort>('SESSION_PORT');
|
||||
32
src/app/shared/layout/breadcrumb/breadcrumb-trail.ts
Normal file
32
src/app/shared/layout/breadcrumb/breadcrumb-trail.ts
Normal file
@@ -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<string, Crumb> = {
|
||||
'/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;
|
||||
}
|
||||
@@ -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: `
|
||||
<nav class="rhc-breadcrumb-nav utrecht-breadcrumb-nav" aria-label="Kruimelpad">
|
||||
<ol class="utrecht-breadcrumb-nav__list list">
|
||||
@for (item of items(); track item.label; let last = $last) {
|
||||
<li class="utrecht-breadcrumb-nav__item">
|
||||
@for (item of items(); track item.label; let last = $last; let first = $first) {
|
||||
<li class="utrecht-breadcrumb-nav__item crumb">
|
||||
@if (!first) { <span class="sep" aria-hidden="true">›</span> }
|
||||
@if (item.link && !last) {
|
||||
<a class="utrecht-breadcrumb-nav__link rhc-link nl-link" [routerLink]="item.link">{{ item.label }}</a>
|
||||
<span class="sep" aria-hidden="true">/</span>
|
||||
<a [routerLink]="item.link">{{ item.label }}</a>
|
||||
} @else {
|
||||
<span class="utrecht-breadcrumb-nav__link rhc-breadcrumb-nav__link--current" aria-current="page">{{ item.label }}</span>
|
||||
<span class="current" aria-current="page">{{ item.label }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { LinkComponent } from '@shared/ui/link/link.component';
|
||||
import { BreadcrumbComponent, BreadcrumbItem } from '@shared/layout/breadcrumb/breadcrumb.component';
|
||||
|
||||
/** Template: standard page body — optional breadcrumb, optional back-link, a
|
||||
heading, optional intro, and projected content. Rendered inside the persistent
|
||||
ShellComponent via the router outlet, so it owns only the content (not chrome). */
|
||||
/** Template: standard page body — optional back-link, a heading, optional intro,
|
||||
and projected content. The breadcrumb lives in the site header (blue bar), so
|
||||
it's not repeated here. Rendered inside the persistent ShellComponent via the
|
||||
router outlet, so it owns only the content (not chrome). */
|
||||
@Component({
|
||||
selector: 'app-page-shell',
|
||||
imports: [HeadingComponent, LinkComponent, BreadcrumbComponent],
|
||||
imports: [HeadingComponent, LinkComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.body--narrow{max-inline-size:var(--app-form-narrow)}
|
||||
.crumb{margin-block-end:var(--rhc-space-max-xl)}
|
||||
.intro{margin-block-end:var(--rhc-space-max-2xl)}
|
||||
.back{margin:0 0 var(--rhc-space-max-lg)}
|
||||
.intro{margin-block:var(--rhc-space-max-md) var(--rhc-space-max-2xl);max-inline-size:42rem;color:var(--rhc-color-foreground-subtle)}
|
||||
`],
|
||||
template: `
|
||||
<div [class.body--narrow]="width() === 'narrow'">
|
||||
@if (breadcrumb()) {
|
||||
<app-breadcrumb class="crumb" [items]="breadcrumb()!" />
|
||||
}
|
||||
@if (backLink()) {
|
||||
<p><app-link [to]="backLink()!">← {{ backLabel() }}</app-link></p>
|
||||
<p class="back"><app-link [to]="backLink()!">← {{ backLabel() }}</app-link></p>
|
||||
}
|
||||
<app-heading [level]="1">{{ heading() }}</app-heading>
|
||||
@if (intro()) {
|
||||
@@ -37,5 +34,4 @@ export class PageShellComponent {
|
||||
backLink = input<string>();
|
||||
backLabel = input('Terug naar overzicht');
|
||||
width = input<'default' | 'narrow'>('default');
|
||||
breadcrumb = input<BreadcrumbItem[]>();
|
||||
}
|
||||
|
||||
50
src/app/shared/layout/side-nav/side-nav.component.ts
Normal file
50
src/app/shared/layout/side-nav/side-nav.component.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
|
||||
export interface NavItem {
|
||||
readonly label: string;
|
||||
readonly to: string;
|
||||
}
|
||||
|
||||
/** Organism: vertical side navigation for the "Mijn omgeving" portal. The active
|
||||
route is marked with `routerLinkActive`. Domain-free — the caller supplies the
|
||||
items (English-named shared chrome). */
|
||||
@Component({
|
||||
selector: 'app-side-nav',
|
||||
imports: [RouterLink, RouterLinkActive],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--rhc-space-max-xs)}
|
||||
.item{
|
||||
display:block;
|
||||
padding:var(--rhc-space-max-md) var(--rhc-space-max-lg);
|
||||
color:var(--rhc-color-foreground-default);
|
||||
text-decoration:none;
|
||||
border-radius:var(--rhc-border-radius-sm);
|
||||
border-inline-start:var(--rhc-border-width-md) solid transparent;
|
||||
}
|
||||
.item:hover{background:var(--rhc-color-cool-grey-100)}
|
||||
.item.active{
|
||||
background:var(--rhc-color-lintblauw-100);
|
||||
border-inline-start-color:var(--rhc-color-lintblauw-700);
|
||||
color:var(--rhc-color-lintblauw-700);
|
||||
font-weight:var(--rhc-text-font-weight-semi-bold);
|
||||
}
|
||||
`],
|
||||
template: `
|
||||
<nav [attr.aria-label]="ariaLabel()">
|
||||
<ul class="list">
|
||||
@for (item of items(); track item.to) {
|
||||
<li>
|
||||
<a class="item" [routerLink]="item.to" routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{ exact: true }">{{ item.label }}</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class SideNavComponent {
|
||||
items = input.required<NavItem[]>();
|
||||
ariaLabel = input('Mijn omgeving');
|
||||
}
|
||||
27
src/app/shared/layout/side-nav/side-nav.stories.ts
Normal file
27
src/app/shared/layout/side-nav/side-nav.stories.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { SideNavComponent } from './side-nav.component';
|
||||
|
||||
const meta: Meta<SideNavComponent> = {
|
||||
title: 'Layout/Side Nav',
|
||||
component: SideNavComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<div style="max-inline-size:15rem"><app-side-nav [items]="items" /></div>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<SideNavComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ label: 'Overzicht', to: '/dashboard' },
|
||||
{ label: 'Mijn gegevens', to: '/registratie' },
|
||||
{ label: 'Herregistratie', to: '/herregistratie' },
|
||||
{ label: 'Inschrijven', to: '/registreren' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -1,20 +1,39 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
/** Organism: site footer. */
|
||||
/** Organism: Rijksoverheid-style site footer — dark-blue, with the
|
||||
"De Rijksoverheid. Voor Nederland." tagline, responsible-ministry attribution,
|
||||
and a small "Over deze site" link column. ponytail: links point at the real
|
||||
rijksoverheid.nl pages, not a fabricated dead-link forest. */
|
||||
@Component({
|
||||
selector: 'app-site-footer',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.bar{background:var(--rhc-color-donkerblauw-700);color:var(--rhc-color-wit);margin-block-start:var(--rhc-space-max-5xl);inline-size:100%}
|
||||
.inner{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-2xl);display:flex;gap:var(--rhc-space-max-3xl);flex-wrap:wrap;box-sizing:border-box}
|
||||
.end{margin-inline-start:auto}
|
||||
.bar{background:var(--rhc-color-donkerblauw-700);color:var(--rhc-color-foreground-on-primary);margin-block-start:var(--rhc-space-max-5xl);inline-size:100%}
|
||||
.inner{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);box-sizing:border-box;display:flex;gap:var(--rhc-space-max-3xl);flex-wrap:wrap;justify-content:space-between}
|
||||
.tagline{font-style:italic;font-weight:var(--rhc-text-font-weight-semi-bold);font-size:var(--rhc-text-font-size-lg);max-inline-size:18rem}
|
||||
.ministry{margin-block-start:var(--rhc-space-max-md);font-size:var(--rhc-text-font-size-sm);color:var(--rhc-color-foreground-on-primary)}
|
||||
.col h2{margin:0 0 var(--rhc-space-max-md);font-size:var(--rhc-text-font-size-sm);font-weight:var(--rhc-text-font-weight-bold);text-transform:uppercase;letter-spacing:.04em}
|
||||
.links{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:var(--rhc-space-max-sm);font-size:var(--rhc-text-font-size-sm)}
|
||||
.links a{color:var(--rhc-color-foreground-on-primary);text-decoration:none}
|
||||
.links a:hover{text-decoration:underline}
|
||||
.meta{inline-size:100%;border-block-start:var(--rhc-border-width-sm) solid var(--rhc-color-lintblauw-600);margin-block-start:var(--rhc-space-max-xl);padding-block-start:var(--rhc-space-max-lg);font-size:var(--rhc-text-font-size-sm);opacity:.85}
|
||||
`],
|
||||
template: `
|
||||
<footer class="utrecht-page-footer bar">
|
||||
<div class="inner">
|
||||
<span>BIG-register</span>
|
||||
<span>CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</span>
|
||||
<span class="end">Demo / POC — geen echte gegevens</span>
|
||||
<div>
|
||||
<div class="tagline">De Rijksoverheid. Voor Nederland.</div>
|
||||
<div class="ministry">CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</div>
|
||||
</div>
|
||||
<nav class="col" aria-label="Over deze site">
|
||||
<h2>Over deze site</h2>
|
||||
<ul class="links">
|
||||
<li><a href="https://www.rijksoverheid.nl/privacy" rel="noopener" target="_blank">Privacy</a></li>
|
||||
<li><a href="https://www.rijksoverheid.nl/cookies" rel="noopener" target="_blank">Cookies</a></li>
|
||||
<li><a href="https://www.rijksoverheid.nl/toegankelijkheid" rel="noopener" target="_blank">Toegankelijkheid</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="meta">Demo / POC — geen echte gegevens.</div>
|
||||
</div>
|
||||
</footer>
|
||||
`,
|
||||
|
||||
@@ -1,37 +1,75 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router, RouterLink } from '@angular/router';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
import { SESSION_PORT } from '@shared/application/session.port';
|
||||
import { BreadcrumbComponent } from '@shared/layout/breadcrumb/breadcrumb.component';
|
||||
import { trailFor } from '@shared/layout/breadcrumb/breadcrumb-trail';
|
||||
|
||||
/** Organism: site header with Rijksoverheid-style wordmark + title.
|
||||
ponytail: text wordmark instead of the licensed Rijksoverheid logo. */
|
||||
/** Organism: Rijksoverheid-style site header. Two tiers, like rijksoverheid.nl:
|
||||
a white brand bar (wordmark + session/logout) over a lint-blue bar carrying
|
||||
the breadcrumb. ponytail: text wordmark, not the licensed Rijksoverheid logo;
|
||||
no search box (no search feature yet). */
|
||||
@Component({
|
||||
selector: 'app-site-header',
|
||||
imports: [RouterLink],
|
||||
// :host display:block so the full-bleed bar fills the flex column (custom
|
||||
// elements default to display:inline, which collapsed the bar to its content).
|
||||
imports: [RouterLink, BreadcrumbComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.bar{background:var(--rhc-color-lintblauw-700);color:var(--rhc-color-wit);inline-size:100%}
|
||||
.inner{display:flex;align-items:center;gap:var(--rhc-space-max-xl);max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-xl) var(--rhc-space-max-2xl);box-sizing:border-box}
|
||||
.brand{display:flex;align-items:center;gap:var(--rhc-space-max-lg);color:inherit;text-decoration:none}
|
||||
.mark{display:inline-block;inline-size:var(--rhc-space-max-md);block-size:var(--rhc-space-max-4xl);background:var(--rhc-color-wit)}
|
||||
/* Tier 1 — white brand bar */
|
||||
.brandbar{background:var(--rhc-color-wit);border-block-end:var(--rhc-border-width-sm) solid var(--rhc-color-border-subtle);inline-size:100%}
|
||||
.brandbar .inner{display:flex;align-items:center;gap:var(--rhc-space-max-xl);max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-lg) var(--rhc-space-max-2xl);box-sizing:border-box}
|
||||
.brand{display:flex;align-items:center;gap:var(--rhc-space-max-lg);color:var(--rhc-color-foreground-default);text-decoration:none}
|
||||
.mark{display:inline-block;inline-size:var(--rhc-space-max-md);block-size:var(--rhc-space-max-4xl);background:var(--rhc-color-lintblauw-700)}
|
||||
.name{font-weight:var(--rhc-text-font-weight-bold);line-height:var(--rhc-text-line-height-sm)}
|
||||
.sub{font-weight:var(--rhc-text-font-weight-regular);font-size:var(--rhc-text-font-size-sm)}
|
||||
.portal{margin-inline-start:auto;font-weight:var(--rhc-text-font-weight-semi-bold)}
|
||||
.sub{font-weight:var(--rhc-text-font-weight-regular);font-size:var(--rhc-text-font-size-sm);color:var(--rhc-color-foreground-subtle)}
|
||||
.session{margin-inline-start:auto;display:flex;align-items:center;gap:var(--rhc-space-max-lg);font-size:var(--rhc-text-font-size-sm)}
|
||||
.user{color:var(--rhc-color-foreground-subtle)}
|
||||
.logout{background:none;border:0;padding:0;cursor:pointer;color:var(--rhc-color-foreground-link);text-decoration:underline;font:inherit}
|
||||
.logout:hover{color:var(--rhc-color-foreground-link-hover)}
|
||||
/* Tier 2 — blue breadcrumb bar */
|
||||
.navbar{background:var(--rhc-color-lintblauw-700);color:var(--rhc-color-foreground-on-primary);inline-size:100%}
|
||||
.navbar .inner{max-inline-size:var(--app-content-max);margin-inline:auto;padding:var(--rhc-space-max-md) var(--rhc-space-max-2xl);box-sizing:border-box}
|
||||
`],
|
||||
template: `
|
||||
<header class="utrecht-page-header bar">
|
||||
<div class="brandbar">
|
||||
<div class="inner">
|
||||
<a routerLink="/dashboard" class="brand">
|
||||
<span aria-hidden="true" class="mark"></span>
|
||||
<span class="name">
|
||||
Rijksoverheid<br><span class="sub">BIG-register</span>
|
||||
</span>
|
||||
<span class="name">Rijksoverheid<br><span class="sub">BIG-register</span></span>
|
||||
</a>
|
||||
<span class="portal">{{ subtitle() }}</span>
|
||||
<span class="portal sub">{{ subtitle() }}</span>
|
||||
@if (session(); as s) {
|
||||
<span class="session">
|
||||
<span class="user">Ingelogd als {{ s.naam }}</span>
|
||||
<button type="button" class="logout" (click)="logout()">Uitloggen</button>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
@if (trail().length) {
|
||||
<div class="navbar">
|
||||
<div class="inner">
|
||||
<app-breadcrumb class="inverse" [items]="trail()" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SiteHeaderComponent {
|
||||
subtitle = input('Mijn omgeving');
|
||||
|
||||
private router = inject(Router);
|
||||
private sessionPort = inject(SESSION_PORT, { optional: true });
|
||||
|
||||
readonly session = computed(() => this.sessionPort?.session() ?? null);
|
||||
private url = toSignal(
|
||||
this.router.events.pipe(filter((e) => e instanceof NavigationEnd), map(() => this.router.url)),
|
||||
{ initialValue: this.router.url },
|
||||
);
|
||||
protected trail = computed(() => trailFor(this.url()));
|
||||
|
||||
logout() {
|
||||
this.sessionPort?.logout();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||
@@ -25,7 +26,7 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-wizard-shell',
|
||||
imports: [ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
|
||||
imports: [FormsModule, ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
|
||||
styles: [`
|
||||
.es-title{margin:0 0 var(--rhc-space-max-sm)}
|
||||
.es-list{margin:0;padding-inline-start:var(--rhc-space-max-xl)}
|
||||
@@ -41,13 +42,13 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
||||
<h3 id="wizard-error-title" class="rhc-heading nl-heading--level-3 es-title">Er ging iets mis met uw invoer</h3>
|
||||
<ul class="es-list">
|
||||
@for (e of errors(); track e.id) {
|
||||
<li><a [href]="'#' + e.id">{{ e.message }}</a></li>
|
||||
<li><a [href]="'#' + e.id" (click)="goToField($event, e.id)">{{ e.message }}</a></li>
|
||||
}
|
||||
</ul>
|
||||
</app-alert>
|
||||
</div>
|
||||
}
|
||||
<form (ngSubmit)="primary.emit()" class="app-form">
|
||||
<form (ngSubmit)="primary.emit()" class="app-form app-form-panel">
|
||||
<ng-content />
|
||||
<div class="app-button-row app-button-row--spaced">
|
||||
@if (canGoBack()) {
|
||||
@@ -89,6 +90,14 @@ export class WizardShellComponent {
|
||||
cancel = output<void>();
|
||||
retry = output<void>();
|
||||
|
||||
/** 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 stepHeading = viewChild<ElementRef<HTMLElement>>('stepHeading');
|
||||
private errorSummary = viewChild<ElementRef<HTMLElement>>('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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
32
src/app/shared/ui/card/card.component.ts
Normal file
32
src/app/shared/ui/card/card.component.ts
Normal file
@@ -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
|
||||
`<div class="rhc-card">`. 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: `
|
||||
<section class="card">
|
||||
@if (heading()) { <app-heading [level]="level()">{{ heading() }}</app-heading> }
|
||||
<ng-content />
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class CardComponent {
|
||||
heading = input<string>();
|
||||
level = input<1 | 2 | 3 | 4 | 5>(3);
|
||||
}
|
||||
20
src/app/shared/ui/card/card.stories.ts
Normal file
20
src/app/shared/ui/card/card.stories.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { CardComponent } from './card.component';
|
||||
|
||||
const meta: Meta<CardComponent> = {
|
||||
title: 'Molecules/Card',
|
||||
component: CardComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-card [heading]="heading" [level]="level">
|
||||
<p class="rhc-paragraph">Een kaart groepeert samenhangende inhoud op een schone, omkaderde vlak.</p>
|
||||
</app-card>`,
|
||||
}),
|
||||
args: { heading: 'Persoonsgegevens (BRP)', level: 3 },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<CardComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const ZonderKop: Story = { args: { heading: undefined } };
|
||||
@@ -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: `
|
||||
<div class="rhc-form-field utrecht-form-field" [class.utrecht-form-field--invalid]="!!error()">
|
||||
<label class="utrecht-form-label" [id]="fieldId() + '-label'" [for]="fieldId()">{{ label() }}</label>
|
||||
<label class="utrecht-form-label label" [id]="fieldId() + '-label'" [for]="fieldId()">
|
||||
{{ label() }}@if (required()) { <span class="req">(verplicht)</span> }
|
||||
</label>
|
||||
@if (description()) {
|
||||
<div class="utrecht-form-field-description" [id]="fieldId() + '-desc'">{{ description() }}</div>
|
||||
<div class="utrecht-form-field-description desc" [id]="fieldId() + '-desc'">{{ description() }}</div>
|
||||
}
|
||||
<ng-content />
|
||||
@if (error()) {
|
||||
<div class="utrecht-form-field-error-message" [id]="fieldId() + '-error'" role="alert">{{ error() }}</div>
|
||||
<div class="utrecht-form-field-error-message error" [id]="fieldId() + '-error'" role="alert">{{ error() }}</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
@@ -22,4 +31,5 @@ export class FormFieldComponent {
|
||||
fieldId = input.required<string>();
|
||||
description = input<string>();
|
||||
error = input<string>();
|
||||
required = input(false, { transform: booleanAttribute });
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
|
||||
51
src/app/shared/ui/task-list/task-list.component.ts
Normal file
51
src/app/shared/ui/task-list/task-list.component.ts
Normal file
@@ -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: `
|
||||
<ul class="tasks">
|
||||
@for (t of tasks(); track t.title) {
|
||||
<li class="task">
|
||||
<div>
|
||||
<p class="title">{{ t.title }}</p>
|
||||
<p class="desc">{{ t.description }}</p>
|
||||
</div>
|
||||
<span class="cta"><app-link [to]="t.to">{{ t.actionLabel }} →</app-link></span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
`,
|
||||
})
|
||||
export class TaskListComponent {
|
||||
tasks = input.required<readonly TaskItem[]>();
|
||||
}
|
||||
22
src/app/shared/ui/task-list/task-list.stories.ts
Normal file
22
src/app/shared/ui/task-list/task-list.stories.ts
Normal file
@@ -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<TaskListComponent> = {
|
||||
title: 'Molecules/Task List',
|
||||
component: TaskListComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({ props: args, template: `<app-task-list [tasks]="tasks" />` }),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<TaskListComponent>;
|
||||
|
||||
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' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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: `
|
||||
<input
|
||||
class="utrecht-textbox rhc-text-input"
|
||||
|
||||
@@ -6,15 +6,18 @@
|
||||
@import '@rijkshuisstijl-community/components-css/dist/index.css'; /* component classes */
|
||||
|
||||
html, body { margin: 0; min-height: 100%; }
|
||||
/* Rijkshuisstijl pages sit on white; cards/panels carry the subtle grey. */
|
||||
body { background: var(--rhc-color-wit); color: var(--rhc-color-foreground-default); }
|
||||
|
||||
/* App theme layer: a few app-specific measures RHC has no token for (content/form
|
||||
widths), defined ONCE here and mapped onto RHC where possible. Components reference
|
||||
these tokens instead of raw values. */
|
||||
:root {
|
||||
--app-content-max: 64rem; /* readable page column */
|
||||
--app-form-max: 30rem; /* default wizard form width */
|
||||
--app-content-max: 67rem; /* readable page column */
|
||||
--app-form-max: 32rem; /* default wizard form width */
|
||||
--app-form-narrow: 32rem; /* page-shell narrow variant */
|
||||
--app-skip-link-offset: -999px; /* off-screen skip link */
|
||||
--app-sidebar-width: 15rem; /* overview side navigation */
|
||||
}
|
||||
|
||||
/* App utility classes: centralise the repeated inline layout idioms so components stay
|
||||
@@ -41,6 +44,36 @@ html, body { margin: 0; min-height: 100%; }
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Overview ("Mijn omgeving") layout: side navigation + main column. Collapses to
|
||||
a single column on narrow viewports (nav stacks above the content). */
|
||||
.app-overview {
|
||||
display: grid;
|
||||
grid-template-columns: var(--app-sidebar-width) minmax(0, 1fr);
|
||||
gap: var(--rhc-space-max-3xl);
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 48rem) {
|
||||
.app-overview { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* Rijkshuisstijl form panel: the subtle-grey block a form sits in (see the
|
||||
rijksoverheid.nl contact-form reference). */
|
||||
.app-form-panel {
|
||||
background: var(--rhc-color-cool-grey-100);
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-2xl);
|
||||
}
|
||||
/* Vertical rhythm between fields projected into a form panel. Global (not
|
||||
component-scoped) so it reaches projected content past view encapsulation. */
|
||||
.app-form-panel > * + * { 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. */
|
||||
|
||||
Reference in New Issue
Block a user