style: format frontend, docs and skills with prettier; add .prettierignore
One-time prettier --write so the new format:check CI gate starts green. .prettierignore excludes generated (api-client.ts, documentation.json), vendored (public/cibg-huisstijl), and backend (dotnet format owns it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -78,7 +78,11 @@ export function map3<E, A, B, C, R>(
|
||||
c: RemoteData<E, C>,
|
||||
f: (a: A, b: B, c: C) => R,
|
||||
): RemoteData<E, R> {
|
||||
return map2(map2(a, b, (x, y) => [x, y] as const), c, ([x, y], z) => f(x, y, z));
|
||||
return map2(
|
||||
map2(a, b, (x, y) => [x, y] as const),
|
||||
c,
|
||||
([x, y], z) => f(x, y, z),
|
||||
);
|
||||
}
|
||||
|
||||
/** Chain a second source that depends on the first one's value. */
|
||||
|
||||
@@ -7,7 +7,10 @@ import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
* its own payload mapping. The backend re-validates and returns a 422
|
||||
* ProblemDetails on rejection, surfaced here as the error string.
|
||||
*/
|
||||
export async function runSubmit<T>(fn: () => Promise<T>, fallback: string): Promise<Result<string, T>> {
|
||||
export async function runSubmit<T>(
|
||||
fn: () => Promise<T>,
|
||||
fallback: string,
|
||||
): Promise<Result<string, T>> {
|
||||
try {
|
||||
return ok(await fn());
|
||||
} catch (e) {
|
||||
|
||||
@@ -3,7 +3,9 @@ import { problemDetail, problemFieldErrors } from './api-error';
|
||||
|
||||
describe('problemDetail', () => {
|
||||
it('extracts the detail from an RFC-7807 ProblemDetails', () => {
|
||||
expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe('Afgewezen: 0 uren.');
|
||||
expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe(
|
||||
'Afgewezen: 0 uren.',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back when there is no detail', () => {
|
||||
@@ -15,8 +17,9 @@ describe('problemDetail', () => {
|
||||
|
||||
describe('problemFieldErrors (G4 seam)', () => {
|
||||
it('maps a ValidationProblemDetails errors dict to first-message-per-field', () => {
|
||||
expect(problemFieldErrors({ errors: { straat: ['Verplicht.'], postcode: ['Ongeldig.', 'x'] } }))
|
||||
.toEqual({ straat: 'Verplicht.', postcode: 'Ongeldig.' });
|
||||
expect(
|
||||
problemFieldErrors({ errors: { straat: ['Verplicht.'], postcode: ['Ongeldig.', 'x'] } }),
|
||||
).toEqual({ straat: 'Verplicht.', postcode: 'Ongeldig.' });
|
||||
});
|
||||
|
||||
it('returns {} when there is no errors envelope (the current backend shape)', () => {
|
||||
|
||||
@@ -9,5 +9,7 @@ import { Role } from '@shared/domain/role';
|
||||
* the FE derives `editable` from it.
|
||||
*/
|
||||
export function currentRole(): Role {
|
||||
return new URLSearchParams(window.location.search).get('role') === 'approver' ? 'approver' : 'drafter';
|
||||
return new URLSearchParams(window.location.search).get('role') === 'approver'
|
||||
? 'approver'
|
||||
: 'drafter';
|
||||
}
|
||||
|
||||
@@ -21,8 +21,11 @@ export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return of(new HttpResponse({ status: 200, body: '[]' })).pipe(delay(400));
|
||||
case 'error':
|
||||
return timer(400).pipe(
|
||||
switchMap(() => throwError(() =>
|
||||
new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }))),
|
||||
switchMap(() =>
|
||||
throwError(
|
||||
() => new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }),
|
||||
),
|
||||
),
|
||||
);
|
||||
default:
|
||||
return next(req);
|
||||
|
||||
@@ -9,7 +9,15 @@ export type Scenario =
|
||||
| 'upload-slow'
|
||||
| 'upload-fail';
|
||||
|
||||
const VALID: Scenario[] = ['default', 'slow', 'loading', 'empty', 'error', 'upload-slow', 'upload-fail'];
|
||||
const VALID: Scenario[] = [
|
||||
'default',
|
||||
'slow',
|
||||
'loading',
|
||||
'empty',
|
||||
'error',
|
||||
'upload-slow',
|
||||
'upload-fail',
|
||||
];
|
||||
|
||||
/** Reads ?scenario= from the URL so a demo can force each async state. */
|
||||
export function currentScenario(): Scenario {
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RichTextBlock, deepCopyBlock, emptyBlock, isBlockEmpty, placeholderKeysIn } from './rich-text';
|
||||
import {
|
||||
RichTextBlock,
|
||||
deepCopyBlock,
|
||||
emptyBlock,
|
||||
isBlockEmpty,
|
||||
placeholderKeysIn,
|
||||
} from './rich-text';
|
||||
|
||||
const block = (): RichTextBlock => ({
|
||||
paragraphs: [
|
||||
{ nodes: [{ type: 'text', text: 'Beste ' }, { type: 'placeholder', key: 'naam' }] },
|
||||
{ nodes: [{ type: 'placeholder', key: 'datum' }, { type: 'placeholder', key: 'naam' }] },
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Beste ' },
|
||||
{ type: 'placeholder', key: 'naam' },
|
||||
],
|
||||
},
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'placeholder', key: 'datum' },
|
||||
{ type: 'placeholder', key: 'naam' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -16,7 +32,9 @@ describe('rich-text', () => {
|
||||
|
||||
it('isBlockEmpty is false when any placeholder or non-blank text exists', () => {
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: ' ' }] }] })).toBe(true);
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'x' }] }] })).toBe(false);
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'x' }] }] })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: 'hoi' }] }] })).toBe(false);
|
||||
});
|
||||
|
||||
@@ -31,7 +49,10 @@ describe('rich-text', () => {
|
||||
expect(copy).not.toBe(original);
|
||||
expect(copy.paragraphs[0]).not.toBe(original.paragraphs[0]);
|
||||
// Mutating the copy must not touch the original — proves no shared reference.
|
||||
(copy.paragraphs[0].nodes as { type: 'text'; text: string }[])[0] = { type: 'text', text: 'CHANGED' };
|
||||
(copy.paragraphs[0].nodes as { type: 'text'; text: string }[])[0] = {
|
||||
type: 'text',
|
||||
text: 'CHANGED',
|
||||
};
|
||||
expect(placeholderKeysIn(original)).toEqual(['naam', 'datum', 'naam']);
|
||||
expect((original.paragraphs[0].nodes[0] as { text: string }).text).toBe('Beste ');
|
||||
});
|
||||
|
||||
@@ -3,13 +3,19 @@ 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 }
|
||||
interface Crumb {
|
||||
label: string;
|
||||
parent?: string;
|
||||
}
|
||||
|
||||
const ROUTES: Record<string, Crumb> = {
|
||||
'/dashboard': { label: $localize`:@@crumb.dashboard:Mijn overzicht` },
|
||||
'/registratie': { label: $localize`:@@crumb.registratie:Mijn gegevens`, parent: '/dashboard' },
|
||||
'/registreren': { label: $localize`:@@crumb.registreren:Inschrijven`, parent: '/dashboard' },
|
||||
'/herregistratie': { label: $localize`:@@crumb.herregistratie:Herregistratie`, parent: '/dashboard' },
|
||||
'/herregistratie': {
|
||||
label: $localize`:@@crumb.herregistratie:Herregistratie`,
|
||||
parent: '/dashboard',
|
||||
},
|
||||
'/intake': { label: $localize`:@@crumb.intake:Herregistratie-intake`, parent: '/dashboard' },
|
||||
'/concepts': { label: $localize`:@@crumb.concepts:Functionele patronen`, parent: '/dashboard' },
|
||||
};
|
||||
|
||||
@@ -16,7 +16,13 @@ export interface BreadcrumbItem {
|
||||
// — including this one, wherever it's mounted. Override it so the breadcrumb
|
||||
// never carries its own background (it should show whatever's behind it, e.g.
|
||||
// the titlebar's robijn fill).
|
||||
styles: [`nav{background:none}`],
|
||||
styles: [
|
||||
`
|
||||
nav {
|
||||
background: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<nav i18n-aria-label="@@breadcrumb.aria" aria-label="Kruimelpad">
|
||||
<span class="visually-hidden" i18n="@@breadcrumb.hier">U bevindt zich hier:</span>
|
||||
|
||||
@@ -17,8 +17,19 @@ export default meta;
|
||||
type Story = StoryObj<BreadcrumbComponent>;
|
||||
|
||||
export const TweeNiveaus: Story = {
|
||||
args: { items: [{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Inschrijven in het BIG-register' }] },
|
||||
args: {
|
||||
items: [
|
||||
{ label: 'Mijn omgeving', link: '/dashboard' },
|
||||
{ label: 'Inschrijven in het BIG-register' },
|
||||
],
|
||||
},
|
||||
};
|
||||
export const DrieNiveaus: Story = {
|
||||
args: { items: [{ label: 'Mijn omgeving', link: '/dashboard' }, { label: 'Registratie', link: '/registratie' }, { label: 'Inschrijven' }] },
|
||||
args: {
|
||||
items: [
|
||||
{ label: 'Mijn omgeving', link: '/dashboard' },
|
||||
{ label: 'Registratie', link: '/registratie' },
|
||||
{ label: 'Inschrijven' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,16 +9,30 @@ import { LinkComponent } from '@shared/ui/link/link.component';
|
||||
@Component({
|
||||
selector: 'app-page-shell',
|
||||
imports: [HeadingComponent, LinkComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.body--narrow{max-inline-size:var(--app-form-narrow)}
|
||||
.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)}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.body--narrow {
|
||||
max-inline-size: var(--app-form-narrow);
|
||||
}
|
||||
.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 (backLink()) {
|
||||
<p class="back"><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()) {
|
||||
|
||||
@@ -9,14 +9,41 @@ import { DebugStateComponent } from '@shared/ui/debug-state/debug-state.componen
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
imports: [RouterOutlet, SiteHeaderComponent, SiteFooterComponent, DebugStateComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.skip{position:absolute;left:var(--app-skip-link-offset);z-index:1030}
|
||||
.skip:focus{left:var(--rhc-space-max-md);top:var(--rhc-space-max-md);background:var(--rhc-color-wit);padding:var(--rhc-space-max-sm) var(--rhc-space-max-md);border-radius:var(--rhc-border-radius-sm)}
|
||||
.layout{display:flex;flex-direction:column;min-block-size:100vh}
|
||||
.main{flex:1;inline-size:100%;box-sizing:border-box}
|
||||
.content{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}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: var(--app-skip-link-offset);
|
||||
z-index: 1030;
|
||||
}
|
||||
.skip:focus {
|
||||
left: var(--rhc-space-max-md);
|
||||
top: var(--rhc-space-max-md);
|
||||
background: var(--rhc-color-wit);
|
||||
padding: var(--rhc-space-max-sm) var(--rhc-space-max-md);
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
}
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-block-size: 100vh;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
inline-size: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.content {
|
||||
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;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<a href="#main" class="skip" i18n="@@shell.skipLink">Naar de inhoud</a>
|
||||
<div class="layout">
|
||||
|
||||
@@ -6,35 +6,117 @@ import { Component } from '@angular/core';
|
||||
rijksoverheid.nl pages, not a fabricated dead-link forest. */
|
||||
@Component({
|
||||
selector: 'app-site-footer',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.bar{background:var(--rhc-color-layout);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)}
|
||||
/* CIBG's vendored h2 tag rule (dark navy, for light backgrounds) beats inherited
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.bar {
|
||||
background: var(--rhc-color-layout);
|
||||
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);
|
||||
}
|
||||
/* CIBG's vendored h2 tag rule (dark navy, for light backgrounds) beats inherited
|
||||
color regardless of specificity — restate on-primary explicitly for this dark bar. */
|
||||
.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;color:var(--rhc-color-foreground-on-primary)}
|
||||
.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}
|
||||
/* CIBG's vendored .meta class (unrelated component, coincidental name) sets a
|
||||
.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: 0.04em;
|
||||
color: var(--rhc-color-foreground-on-primary);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
/* CIBG's vendored .meta class (unrelated component, coincidental name) sets a
|
||||
dark grey — override rather than rename to keep the CIBG-mirroring class name. */
|
||||
.meta{inline-size:100%;border-block-start:var(--rhc-border-width-sm) solid color-mix(in srgb, var(--rhc-color-foreground-on-primary) 25%, transparent);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;color:var(--rhc-color-foreground-on-primary)}
|
||||
`],
|
||||
.meta {
|
||||
inline-size: 100%;
|
||||
border-block-start: var(--rhc-border-width-sm) solid
|
||||
color-mix(in srgb, var(--rhc-color-foreground-on-primary) 25%, transparent);
|
||||
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: 0.85;
|
||||
color: var(--rhc-color-foreground-on-primary);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<footer class="bar">
|
||||
<div class="inner">
|
||||
<div>
|
||||
<div class="tagline" i18n="@@footer.tagline">De Rijksoverheid. Voor Nederland.</div>
|
||||
<div class="ministry" i18n="@@footer.ministry">CIBG — Ministerie van Volksgezondheid, Welzijn en Sport</div>
|
||||
<div class="ministry" i18n="@@footer.ministry">
|
||||
CIBG — Ministerie van Volksgezondheid, Welzijn en Sport
|
||||
</div>
|
||||
</div>
|
||||
<nav class="col" i18n-aria-label="@@footer.overSiteAria" aria-label="Over deze site">
|
||||
<h2 i18n="@@footer.overSite">Over deze site</h2>
|
||||
<ul class="links">
|
||||
<li><a href="https://www.rijksoverheid.nl/privacy" rel="noopener" target="_blank" i18n="@@footer.privacy">Privacy</a></li>
|
||||
<li><a href="https://www.rijksoverheid.nl/cookies" rel="noopener" target="_blank" i18n="@@footer.cookies">Cookies</a></li>
|
||||
<li><a href="https://www.rijksoverheid.nl/toegankelijkheid" rel="noopener" target="_blank" i18n="@@footer.toegankelijkheid">Toegankelijkheid</a></li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.rijksoverheid.nl/privacy"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
i18n="@@footer.privacy"
|
||||
>Privacy</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.rijksoverheid.nl/cookies"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
i18n="@@footer.cookies"
|
||||
>Cookies</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.rijksoverheid.nl/toegankelijkheid"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
i18n="@@footer.toegankelijkheid"
|
||||
>Toegankelijkheid</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="meta" i18n="@@footer.demo">Demo / POC — geen echte gegevens.</div>
|
||||
|
||||
@@ -24,13 +24,25 @@ const NAV_ITEMS: readonly HeaderNavItem[] = [
|
||||
@Component({
|
||||
selector: 'app-site-header',
|
||||
imports: [RouterLink, RouterLinkActive, BreadcrumbComponent],
|
||||
styles: [`
|
||||
.logout{background:none;border:0;padding:0;cursor:pointer;text-decoration:underline;font:inherit;color:inherit}
|
||||
/* CIBG's header nav has no bg by default in this build — the grey bar is ours.
|
||||
styles: [
|
||||
`
|
||||
.logout {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
/* CIBG's header nav has no bg by default in this build — the grey bar is ours.
|
||||
(.titlebar keeps its own robijn fill — --ro-layout — untouched; the breadcrumb
|
||||
inside it has no background of its own, so the bar's colour shows through.) */
|
||||
nav{background-color:var(--rhc-color-cool-grey-200)}
|
||||
`],
|
||||
nav {
|
||||
background-color: var(--rhc-color-cool-grey-200);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<header>
|
||||
<div class="logo">
|
||||
@@ -39,7 +51,9 @@ const NAV_ITEMS: readonly HeaderNavItem[] = [
|
||||
<figure class="logo__figure">
|
||||
<figcaption class="logo__text">
|
||||
<span class="logo__sender" i18n="@@header.sender">BIG-register</span>
|
||||
<span class="logo__ministry" i18n="@@header.ministry">Ministerie van Volksgezondheid, Welzijn en Sport</span>
|
||||
<span class="logo__ministry" i18n="@@header.ministry"
|
||||
>Ministerie van Volksgezondheid, Welzijn en Sport</span
|
||||
>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</a>
|
||||
@@ -49,12 +63,20 @@ const NAV_ITEMS: readonly HeaderNavItem[] = [
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="title col-md-7">
|
||||
@if (trail().length) { <app-breadcrumb [items]="trail()" /> }
|
||||
@if (trail().length) {
|
||||
<app-breadcrumb [items]="trail()" />
|
||||
}
|
||||
</div>
|
||||
<div class="user-menu col-md-5">
|
||||
@if (session(); as s) {
|
||||
<div><span class="login-name">{{ s.naam }}</span></div>
|
||||
<div><button type="button" class="logout" (click)="logout()" i18n="@@header.uitloggen">Uitloggen</button></div>
|
||||
<div>
|
||||
<span class="login-name">{{ s.naam }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="logout" (click)="logout()" i18n="@@header.uitloggen">
|
||||
Uitloggen
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +104,10 @@ export class SiteHeaderComponent {
|
||||
|
||||
readonly session = computed(() => this.sessionPort?.session() ?? null);
|
||||
private url = toSignal(
|
||||
this.router.events.pipe(filter((e) => e instanceof NavigationEnd), map(() => this.router.url)),
|
||||
this.router.events.pipe(
|
||||
filter((e) => e instanceof NavigationEnd),
|
||||
map(() => this.router.url),
|
||||
),
|
||||
{ initialValue: this.router.url },
|
||||
);
|
||||
protected trail = computed(() => trailFor(this.url()));
|
||||
|
||||
@@ -32,24 +32,49 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
||||
@Component({
|
||||
selector: 'app-wizard-shell',
|
||||
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)}
|
||||
/* Default link color doesn't meet contrast on the error-alert's light-red surface. */
|
||||
.es-list a{color:var(--rhc-color-lintblauw-700)}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
.es-title {
|
||||
margin: 0 0 var(--rhc-space-max-sm);
|
||||
}
|
||||
.es-list {
|
||||
margin: 0;
|
||||
padding-inline-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
/* Default link color doesn't meet contrast on the error-alert's light-red surface. */
|
||||
.es-list a {
|
||||
color: var(--rhc-color-lintblauw-700);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@switch (status()) {
|
||||
@case ('editing') {
|
||||
<app-stepper class="app-section" [steps]="steps()" [current]="current()"
|
||||
[processName]="processName()" [stepTitle]="stepTitle()" (stepSelected)="goToStep.emit($event)" />
|
||||
<app-stepper
|
||||
class="app-section"
|
||||
[steps]="steps()"
|
||||
[current]="current()"
|
||||
[processName]="processName()"
|
||||
[stepTitle]="stepTitle()"
|
||||
(stepSelected)="goToStep.emit($event)"
|
||||
/>
|
||||
@if (errors().length) {
|
||||
<div #errorSummary tabindex="-1" role="alert" aria-labelledby="wizard-error-title" class="app-section">
|
||||
<div
|
||||
#errorSummary
|
||||
tabindex="-1"
|
||||
role="alert"
|
||||
aria-labelledby="wizard-error-title"
|
||||
class="app-section"
|
||||
>
|
||||
<app-alert type="error">
|
||||
<h3 id="wizard-error-title" class="es-title" i18n="@@wizard.errorTitle">Er ging iets mis met uw invoer</h3>
|
||||
<h3 id="wizard-error-title" class="es-title" i18n="@@wizard.errorTitle">
|
||||
Er ging iets mis met uw invoer
|
||||
</h3>
|
||||
<ul class="es-list">
|
||||
@for (e of errors(); track e.id) {
|
||||
<li><a [href]="'#' + e.id" (click)="goToField($event, e.id)">{{ e.message }}</a></li>
|
||||
<li>
|
||||
<a [href]="'#' + e.id" (click)="goToField($event, e.id)">{{ e.message }}</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</app-alert>
|
||||
@@ -57,18 +82,35 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
||||
}
|
||||
<form (ngSubmit)="primary.emit()" class="form-horizontal app-section">
|
||||
<div class="form-header">
|
||||
<div class="form-action"><span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span></div>
|
||||
<div class="form-action">
|
||||
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
||||
</div>
|
||||
</div>
|
||||
<ng-content />
|
||||
<hr />
|
||||
<div class="d-flex flex-column flex-sm-row-reverse">
|
||||
<div class="m-0"><app-button type="submit" variant="primary">{{ primaryLabel() }}</app-button></div>
|
||||
<div class="m-0">
|
||||
<app-button type="submit" variant="primary">{{ primaryLabel() }}</app-button>
|
||||
</div>
|
||||
@if (canGoBack()) {
|
||||
<app-button type="button" variant="subtle" class="me-auto" (click)="back.emit()" i18n="@@wizard.terugVorige">Terug naar vorige stap</app-button>
|
||||
<app-button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
class="me-auto"
|
||||
(click)="back.emit()"
|
||||
i18n="@@wizard.terugVorige"
|
||||
>Terug naar vorige stap</app-button
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<div class="app-section">
|
||||
<app-button type="button" variant="subtle" (click)="cancel.emit()" i18n="@@wizard.annuleren">Annuleren</app-button>
|
||||
<app-button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
(click)="cancel.emit()"
|
||||
i18n="@@wizard.annuleren"
|
||||
>Annuleren</app-button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
@@ -81,7 +123,9 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
||||
@case ('failed') {
|
||||
<app-alert type="error">{{ errorMessage() }}</app-alert>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen">Opnieuw proberen</app-button>
|
||||
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen"
|
||||
>Opnieuw proberen</app-button
|
||||
>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -125,7 +169,10 @@ export class WizardShellComponent {
|
||||
let firstStep = true;
|
||||
effect(() => {
|
||||
this.current();
|
||||
if (firstStep) { firstStep = false; return; }
|
||||
if (firstStep) {
|
||||
firstStep = false;
|
||||
return;
|
||||
}
|
||||
untracked(() => queueMicrotask(() => this.stepper()?.focusTitle()));
|
||||
});
|
||||
// A11y: when validation errors first appear (after a failed submit), move
|
||||
@@ -137,8 +184,13 @@ export class WizardShellComponent {
|
||||
let hadErrors = false;
|
||||
effect(() => {
|
||||
const has = this.errors().length > 0;
|
||||
if (firstErr) { firstErr = false; hadErrors = has; return; }
|
||||
if (has && !hadErrors) untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus()));
|
||||
if (firstErr) {
|
||||
firstErr = false;
|
||||
hadErrors = has;
|
||||
return;
|
||||
}
|
||||
if (has && !hadErrors)
|
||||
untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus()));
|
||||
hadErrors = has;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,8 +21,15 @@ type Story = StoryObj<WizardShellComponent>;
|
||||
|
||||
const steps = ['Adres', 'Beroep', 'Controle'];
|
||||
const base = {
|
||||
steps, current: 1, stepTitle: 'Beroep op basis van uw diploma', processName: 'Inschrijven in het BIG-register',
|
||||
primaryLabel: 'Volgende', canGoBack: true, errors: [], errorMessage: '', goToStep: () => {},
|
||||
steps,
|
||||
current: 1,
|
||||
stepTitle: 'Beroep op basis van uw diploma',
|
||||
processName: 'Inschrijven in het BIG-register',
|
||||
primaryLabel: 'Volgende',
|
||||
canGoBack: true,
|
||||
errors: [],
|
||||
errorMessage: '',
|
||||
goToStep: () => {},
|
||||
};
|
||||
|
||||
export const Editing: Story = { args: { ...base, status: 'editing' } };
|
||||
@@ -38,4 +45,6 @@ export const EditingMetFouten: Story = {
|
||||
};
|
||||
export const Submitting: Story = { args: { ...base, status: 'submitting' } };
|
||||
export const Submitted: Story = { args: { ...base, status: 'submitted' } };
|
||||
export const Failed: Story = { args: { ...base, status: 'failed', errorMessage: 'Het indienen is niet gelukt: netwerkfout.' } };
|
||||
export const Failed: Story = {
|
||||
args: { ...base, status: 'failed', errorMessage: 'Het indienen is niet gelukt: netwerkfout.' },
|
||||
};
|
||||
|
||||
@@ -16,7 +16,14 @@ const ICON_LABELS: Record<AlertType, string> = {
|
||||
only the icon's a11y label and a content wrapper (`.feedback` is a flex row). */
|
||||
@Component({
|
||||
selector: 'app-alert',
|
||||
styles: [`.feedback>div{flex:1 1 auto;min-width:0}`],
|
||||
styles: [
|
||||
`
|
||||
.feedback > div {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div
|
||||
class="feedback"
|
||||
@@ -25,8 +32,11 @@ const ICON_LABELS: Record<AlertType, string> = {
|
||||
[class.feedback-warning]="type() === 'warning'"
|
||||
[class.feedback-error]="type() === 'error'"
|
||||
role="status"
|
||||
aria-atomic="true">
|
||||
<span class="icon"><span class="visually-hidden">{{ iconLabels[type()] }}</span></span>
|
||||
aria-atomic="true"
|
||||
>
|
||||
<span class="icon"
|
||||
><span class="visually-hidden">{{ iconLabels[type()] }}</span></span
|
||||
>
|
||||
<div><ng-content /></div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -14,13 +14,26 @@ import { RouterLink } from '@angular/router';
|
||||
@Component({
|
||||
selector: 'li[app-application-link]',
|
||||
imports: [RouterLink, NgTemplateOutlet],
|
||||
styles: [`
|
||||
/* The vendored .applications li a surface only styles <a>; mirror it from tokens
|
||||
styles: [
|
||||
`
|
||||
/* The vendored .applications li a surface only styles <a>; mirror it from tokens
|
||||
for a non-navigating (informational) row so the card looks consistent. */
|
||||
.static-row{display:flex;background:var(--rhc-color-wit);border-block-end:.065rem solid var(--rhc-color-border-subtle);padding:.75rem 2rem .75rem 1rem}
|
||||
.content{flex:1 1 auto;min-inline-size:0}
|
||||
.cta{margin-inline-start:auto;align-self:center}
|
||||
`],
|
||||
.static-row {
|
||||
display: flex;
|
||||
background: var(--rhc-color-wit);
|
||||
border-block-end: 0.065rem solid var(--rhc-color-border-subtle);
|
||||
padding: 0.75rem 2rem 0.75rem 1rem;
|
||||
}
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.cta {
|
||||
margin-inline-start: auto;
|
||||
align-self: center;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (to()) {
|
||||
<a [routerLink]="to()"><ng-container [ngTemplateOutlet]="body" /></a>
|
||||
@@ -33,10 +46,16 @@ import { RouterLink } from '@angular/router';
|
||||
<ng-template #body>
|
||||
<div class="content">
|
||||
<h3 class="h3">{{ heading() }}</h3>
|
||||
@if (subtitle()) { <div class="subtitle">{{ subtitle() }}</div> }
|
||||
@if (status()) { <div class="status">{{ status() }}</div> }
|
||||
@if (subtitle()) {
|
||||
<div class="subtitle">{{ subtitle() }}</div>
|
||||
}
|
||||
@if (status()) {
|
||||
<div class="status">{{ status() }}</div>
|
||||
}
|
||||
</div>
|
||||
@if (cta()) { <div class="cta">{{ cta() }}</div> }
|
||||
@if (cta()) {
|
||||
<div class="cta">{{ cta() }}</div>
|
||||
}
|
||||
</ng-template>
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -17,7 +17,11 @@ export default meta;
|
||||
type Story = StoryObj<ApplicationLinkComponent>;
|
||||
|
||||
export const Navigatie: Story = {
|
||||
args: { heading: 'Inschrijven', subtitle: 'Schrijf u in in het BIG-register.', to: '/registreren' },
|
||||
args: {
|
||||
heading: 'Inschrijven',
|
||||
subtitle: 'Schrijf u in in het BIG-register.',
|
||||
to: '/registreren',
|
||||
},
|
||||
};
|
||||
export const Actie: Story = {
|
||||
args: { heading: 'Inschrijving', status: 'Stap 2 van 3', cta: 'Verder gaan', clickable: true },
|
||||
|
||||
@@ -36,31 +36,41 @@ export class AsyncErrorDirective {
|
||||
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
|
||||
template: `
|
||||
<div aria-live="polite" [attr.aria-busy]="rd().tag === 'Loading' ? 'true' : null">
|
||||
@switch (rd().tag) {
|
||||
@case ('Loading') {
|
||||
@if (loadingTpl()) { <ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" /> }
|
||||
@else { <app-spinner /> }
|
||||
}
|
||||
@case ('Failure') {
|
||||
@if (errorTpl()) {
|
||||
<ng-container [ngTemplateOutlet]="errorTpl()!.tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: error(), retry: retry }" />
|
||||
} @else {
|
||||
<app-alert type="error">{{ errorText() }}</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
<app-button variant="secondary" (click)="retry()">{{ retryText() }}</app-button>
|
||||
</div>
|
||||
@switch (rd().tag) {
|
||||
@case ('Loading') {
|
||||
@if (loadingTpl()) {
|
||||
<ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" />
|
||||
} @else {
|
||||
<app-spinner />
|
||||
}
|
||||
}
|
||||
@case ('Failure') {
|
||||
@if (errorTpl()) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="errorTpl()!.tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: error(), retry: retry }"
|
||||
/>
|
||||
} @else {
|
||||
<app-alert type="error">{{ errorText() }}</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
<app-button variant="secondary" (click)="retry()">{{ retryText() }}</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@case ('Empty') {
|
||||
@if (emptyTpl()) {
|
||||
<ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" />
|
||||
} @else {
|
||||
<p>{{ emptyText() }}</p>
|
||||
}
|
||||
}
|
||||
@case ('Success') {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="loadedTpl().tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: value() }"
|
||||
/>
|
||||
}
|
||||
}
|
||||
@case ('Empty') {
|
||||
@if (emptyTpl()) { <ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" /> }
|
||||
@else { <p>{{ emptyText() }}</p> }
|
||||
}
|
||||
@case ('Success') {
|
||||
<ng-container [ngTemplateOutlet]="loadedTpl().tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: value() }" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
@@ -94,10 +104,20 @@ export class AsyncComponent<T> {
|
||||
// value/error are pulled out via the exhaustive fold — only Success carries a
|
||||
// value, only Failure carries an error, so these can't lie.
|
||||
protected value = computed(() =>
|
||||
foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),
|
||||
foldRemote(this.rd(), {
|
||||
loading: () => undefined,
|
||||
empty: () => undefined,
|
||||
failure: () => undefined,
|
||||
success: (v) => v,
|
||||
}),
|
||||
);
|
||||
protected error = computed(() =>
|
||||
foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),
|
||||
foldRemote(this.rd(), {
|
||||
loading: () => undefined,
|
||||
empty: () => undefined,
|
||||
failure: (e) => e,
|
||||
success: () => undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
retry = () => {
|
||||
@@ -110,6 +130,9 @@ export class AsyncComponent<T> {
|
||||
|
||||
/** Convenience: import this array to get the wrapper + all slot directives. */
|
||||
export const ASYNC = [
|
||||
AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,
|
||||
AsyncEmptyDirective, AsyncErrorDirective,
|
||||
AsyncComponent,
|
||||
AsyncLoadedDirective,
|
||||
AsyncLoadingDirective,
|
||||
AsyncEmptyDirective,
|
||||
AsyncErrorDirective,
|
||||
] as const;
|
||||
|
||||
@@ -37,7 +37,11 @@ const meta: Meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Loaded: Story = { args: { resource: fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']) } };
|
||||
export const Loaded: Story = {
|
||||
args: { resource: fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']) },
|
||||
};
|
||||
export const Loading: Story = { args: { resource: fakeResource('loading') } };
|
||||
export const Empty: Story = { args: { resource: fakeResource('resolved', [] as string[]) } };
|
||||
export const ErrorState: Story = { args: { resource: fakeResource('error', undefined, new Error('Demo')) } };
|
||||
export const ErrorState: Story = {
|
||||
args: { resource: fakeResource('error', undefined, new Error('Demo')) },
|
||||
};
|
||||
|
||||
@@ -13,7 +13,8 @@ type Variant = 'primary' | 'secondary' | 'subtle' | 'danger';
|
||||
[class.btn-primary]="variant() === 'primary'"
|
||||
[class.btn-outline-primary]="variant() === 'secondary'"
|
||||
[class.btn-link]="variant() === 'subtle'"
|
||||
[class.btn-danger]="variant() === 'danger'">
|
||||
[class.btn-danger]="variant() === 'danger'"
|
||||
>
|
||||
<ng-content />
|
||||
</button>
|
||||
`,
|
||||
|
||||
@@ -8,21 +8,30 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
@Component({
|
||||
selector: 'app-card',
|
||||
imports: [HeadingComponent],
|
||||
styles: [`
|
||||
:host{display:block;block-size:100%}
|
||||
.app-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;
|
||||
}
|
||||
.app-card > * + *{margin-block-start:var(--rhc-space-max-md)}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
block-size: 100%;
|
||||
}
|
||||
.app-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;
|
||||
}
|
||||
.app-card > * + * {
|
||||
margin-block-start: var(--rhc-space-max-md);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<section class="app-card">
|
||||
@if (heading()) { <app-heading [level]="level()">{{ heading() }}</app-heading> }
|
||||
@if (heading()) {
|
||||
<app-heading [level]="level()">{{ heading() }}</app-heading>
|
||||
}
|
||||
<ng-content />
|
||||
</section>
|
||||
`,
|
||||
|
||||
@@ -8,12 +8,21 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
selector: 'app-checkbox',
|
||||
template: `
|
||||
<div class="form-check styled">
|
||||
<input class="form-check-input" type="checkbox" [id]="checkboxId()" [checked]="value" [disabled]="disabled"
|
||||
(change)="onToggle($event)" (blur)="onTouched()" />
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
[id]="checkboxId()"
|
||||
[checked]="value"
|
||||
[disabled]="disabled"
|
||||
(change)="onToggle($event)"
|
||||
(blur)="onTouched()"
|
||||
/>
|
||||
<label class="form-check-label" [for]="checkboxId()">{{ label() }}</label>
|
||||
</div>
|
||||
`,
|
||||
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxComponent), multi: true }],
|
||||
providers: [
|
||||
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxComponent), multi: true },
|
||||
],
|
||||
})
|
||||
export class CheckboxComponent implements ControlValueAccessor {
|
||||
checkboxId = input<string>();
|
||||
@@ -28,8 +37,16 @@ export class CheckboxComponent implements ControlValueAccessor {
|
||||
this.value = (e.target as HTMLInputElement).checked;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
writeValue(v: boolean) { this.value = !!v; }
|
||||
registerOnChange(fn: (v: boolean) => void) { this.onChange = fn; }
|
||||
registerOnTouched(fn: () => void) { this.onTouched = fn; }
|
||||
setDisabledState(d: boolean) { this.disabled = d; }
|
||||
writeValue(v: boolean) {
|
||||
this.value = !!v;
|
||||
}
|
||||
registerOnChange(fn: (v: boolean) => void) {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void) {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
setDisabledState(d: boolean) {
|
||||
this.disabled = d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,27 @@ import { RouterLink } from '@angular/router';
|
||||
@Component({
|
||||
selector: 'app-choice-link',
|
||||
imports: [RouterLink],
|
||||
styles: [`
|
||||
:host{display:contents}
|
||||
.keuzelijst__link{position:relative}
|
||||
.keuzelijst__link:focus-within{background-color:var(--rhc-color-cool-grey-100);box-shadow:inset 4px 0 0 0 var(--rhc-color-lintblauw-500)}
|
||||
.keuzelijst__link--static::after{content:none}
|
||||
.keuzelijst__link--static:hover{background-color:var(--rhc-color-cool-grey-200);box-shadow:none}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
.keuzelijst__link {
|
||||
position: relative;
|
||||
}
|
||||
.keuzelijst__link:focus-within {
|
||||
background-color: var(--rhc-color-cool-grey-100);
|
||||
box-shadow: inset 4px 0 0 0 var(--rhc-color-lintblauw-500);
|
||||
}
|
||||
.keuzelijst__link--static::after {
|
||||
content: none;
|
||||
}
|
||||
.keuzelijst__link--static:hover {
|
||||
background-color: var(--rhc-color-cool-grey-200);
|
||||
box-shadow: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<li class="keuzelijst__list-item">
|
||||
<div class="keuzelijst__link" [class.keuzelijst__link--static]="!to() && !clickable()">
|
||||
@@ -40,7 +54,9 @@ import { RouterLink } from '@angular/router';
|
||||
{{ heading() }}
|
||||
}
|
||||
</h3>
|
||||
@if (instructions()) { <p class="keuzelijst__instructions">{{ instructions() }}</p> }
|
||||
@if (instructions()) {
|
||||
<p class="keuzelijst__instructions">{{ instructions() }}</p>
|
||||
}
|
||||
<ng-content select="[choiceActions]" />
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -23,11 +23,18 @@ export default meta;
|
||||
type Story = StoryObj<ChoiceLinkComponent>;
|
||||
|
||||
export const Navigatie: Story = {
|
||||
args: { heading: 'Ik heb een Nederlands diploma', instructions: 'U kunt direct uw registratie aanvragen.', to: '/registreren' },
|
||||
args: {
|
||||
heading: 'Ik heb een Nederlands diploma',
|
||||
instructions: 'U kunt direct uw registratie aanvragen.',
|
||||
to: '/registreren',
|
||||
},
|
||||
};
|
||||
export const Actie: Story = {
|
||||
args: { heading: 'Inschrijving', instructions: 'Stap 2 van 3', clickable: true },
|
||||
};
|
||||
export const NietInteractief: Story = {
|
||||
args: { heading: 'Herregistratie', instructions: 'Referentie 2024-00123 · ingediend op 12 mei 2024' },
|
||||
args: {
|
||||
heading: 'Herregistratie',
|
||||
instructions: 'Referentie 2024-00123 · ingediend op 12 mei 2024',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,12 +8,19 @@ import { Component, input } from '@angular/core';
|
||||
selector: 'app-confirmation',
|
||||
template: `
|
||||
<div class="confirmation">
|
||||
<svg class="confirmation__checkmark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 52 52" height="52" width="52">
|
||||
<svg
|
||||
class="confirmation__checkmark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 52 52"
|
||||
height="52"
|
||||
width="52"
|
||||
>
|
||||
<circle class="confirmation__checkmark-circle" cx="26" cy="26" r="18" fill="none" />
|
||||
<path class="confirmation__checkmark-check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8" />
|
||||
</svg>
|
||||
<div class="confirmation__title">
|
||||
<span class="visually-hidden">{{ successPrefix() }}</span>{{ title() }}
|
||||
<span class="visually-hidden">{{ successPrefix() }}</span
|
||||
>{{ title() }}
|
||||
</div>
|
||||
</div>
|
||||
<ng-content />
|
||||
|
||||
@@ -12,7 +12,9 @@ import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
selector: 'app-data-block',
|
||||
imports: [HeadingComponent],
|
||||
template: `
|
||||
@if (heading()) { <app-heading [level]="level()">{{ heading() }}</app-heading> }
|
||||
@if (heading()) {
|
||||
<app-heading [level]="level()">{{ heading() }}</app-heading>
|
||||
}
|
||||
<div class="data-block" [class.data-block--stacked]="stacked()">
|
||||
<div class="block-wrapper">
|
||||
<dl class="mb-0" [attr.aria-label]="ariaLabel() || null">
|
||||
|
||||
@@ -15,10 +15,18 @@ import { Component, input } from '@angular/core';
|
||||
// The CIBG datablock draws a separator between entries. It ships that border on
|
||||
// dt/dd with a `:last-of-type` reset, but our one-row-per-<div> grouping (for axe)
|
||||
// makes every dt/dd a last-of-type — so we carry the separator on the row instead.
|
||||
styles: [`:host:not(:last-of-type){border-block-end:var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200)}`],
|
||||
styles: [
|
||||
`
|
||||
:host:not(:last-of-type) {
|
||||
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<dt class="col-md-4">{{ key() }}</dt>
|
||||
<dd class="col-md-8"><ng-content>{{ value() }}</ng-content></dd>
|
||||
<dd class="col-md-8">
|
||||
<ng-content>{{ value() }}</ng-content>
|
||||
</dd>
|
||||
`,
|
||||
})
|
||||
export class DataRowComponent {
|
||||
|
||||
@@ -19,25 +19,53 @@ import { maskBsn, redactProfile } from './mask';
|
||||
@Component({
|
||||
selector: 'app-debug-state',
|
||||
imports: [JsonPipe],
|
||||
styles: [`
|
||||
.fab{position:fixed;bottom:1rem;right:1rem;z-index:9999;font:600 12px/1 monospace;
|
||||
background:var(--app-devpanel-bg);color:var(--app-devpanel-accent);
|
||||
border:var(--rhc-border-width-sm) solid var(--app-devpanel-border);border-radius:4px;
|
||||
padding:.5rem .75rem;cursor:pointer}
|
||||
.panel{position:fixed;bottom:3.25rem;right:1rem;z-index:9999;width:min(90vw,28rem);
|
||||
max-height:70vh;overflow:auto;background:var(--app-devpanel-bg);color:var(--app-devpanel-fg);
|
||||
border:var(--rhc-border-width-sm) solid var(--app-devpanel-border);
|
||||
border-radius:6px;box-shadow:0 6px 24px var(--app-devpanel-shadow)}
|
||||
.panel pre{margin:0;padding:.75rem;font:12px/1.5 monospace;white-space:pre-wrap;
|
||||
word-break:break-word}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 9999;
|
||||
font: 600 12px/1 monospace;
|
||||
background: var(--app-devpanel-bg);
|
||||
color: var(--app-devpanel-accent);
|
||||
border: var(--rhc-border-width-sm) solid var(--app-devpanel-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.panel {
|
||||
position: fixed;
|
||||
bottom: 3.25rem;
|
||||
right: 1rem;
|
||||
z-index: 9999;
|
||||
width: min(90vw, 28rem);
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
background: var(--app-devpanel-bg);
|
||||
color: var(--app-devpanel-fg);
|
||||
border: var(--rhc-border-width-sm) solid var(--app-devpanel-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 24px var(--app-devpanel-shadow);
|
||||
}
|
||||
.panel pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
font: 12px/1.5 monospace;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (isDev) {
|
||||
<button type="button" class="fab" (click)="toggle()">
|
||||
{{ visible() ? '× state' : '⚙ state' }}
|
||||
</button>
|
||||
@if (visible()) {
|
||||
<div class="panel"><pre>{{ snapshot() | json }}</pre></div>
|
||||
<div class="panel">
|
||||
<pre>{{ snapshot() | json }}</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -9,14 +9,18 @@ import { Component, booleanAttribute, input } from '@angular/core';
|
||||
selector: 'app-form-field',
|
||||
template: `
|
||||
<div class="form-group row" [class.required]="required()">
|
||||
<label class="col-md-4 col-form-label" [id]="fieldId() + '-label'" [for]="fieldId()">{{ label() }}</label>
|
||||
<label class="col-md-4 col-form-label" [id]="fieldId() + '-label'" [for]="fieldId()">{{
|
||||
label()
|
||||
}}</label>
|
||||
<div class="col-md-8 col-control">
|
||||
@if (description()) {
|
||||
<div class="form-text" [id]="fieldId() + '-desc'">{{ description() }}</div>
|
||||
}
|
||||
<ng-content />
|
||||
@if (error()) {
|
||||
<div [id]="fieldId() + '-error'" role="alert"><span class="errortext">{{ error() }}</span></div>
|
||||
<div [id]="fieldId() + '-error'" role="alert">
|
||||
<span class="errortext">{{ error() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,5 +25,10 @@ export const Default: Story = {
|
||||
args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true },
|
||||
};
|
||||
export const WithError: Story = {
|
||||
args: { label: 'Straat en huisnummer', fieldId: 'street', error: 'Dit veld is verplicht.', required: true },
|
||||
args: {
|
||||
label: 'Straat en huisnummer',
|
||||
fieldId: 'street',
|
||||
error: 'Dit veld is verplicht.',
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,11 +10,21 @@ import { NgTemplateOutlet } from '@angular/common';
|
||||
template: `
|
||||
<ng-template #content><ng-content /></ng-template>
|
||||
@switch (level()) {
|
||||
@case (1) { <h1><ng-container [ngTemplateOutlet]="content" /></h1> }
|
||||
@case (2) { <h2><ng-container [ngTemplateOutlet]="content" /></h2> }
|
||||
@case (3) { <h3><ng-container [ngTemplateOutlet]="content" /></h3> }
|
||||
@case (4) { <h4><ng-container [ngTemplateOutlet]="content" /></h4> }
|
||||
@default { <h5><ng-container [ngTemplateOutlet]="content" /></h5> }
|
||||
@case (1) {
|
||||
<h1><ng-container [ngTemplateOutlet]="content" /></h1>
|
||||
}
|
||||
@case (2) {
|
||||
<h2><ng-container [ngTemplateOutlet]="content" /></h2>
|
||||
}
|
||||
@case (3) {
|
||||
<h3><ng-container [ngTemplateOutlet]="content" /></h3>
|
||||
}
|
||||
@case (4) {
|
||||
<h4><ng-container [ngTemplateOutlet]="content" /></h4>
|
||||
}
|
||||
@default {
|
||||
<h5><ng-container [ngTemplateOutlet]="content" /></h5>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -8,21 +8,52 @@ import { Component, computed, input } from '@angular/core';
|
||||
everywhere the letter is shown, not edited.) */
|
||||
@Component({
|
||||
selector: 'app-placeholder-chip',
|
||||
styles: [`
|
||||
.chip{
|
||||
display:inline-flex;align-items:center;gap:0.2em;
|
||||
border-radius:var(--rhc-border-radius-sm);
|
||||
padding:0 0.35em;line-height:1.6;border:1px solid transparent;white-space:nowrap;
|
||||
}
|
||||
/* Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */
|
||||
.chip::before{content:'\\7B';opacity:0.6;font-weight:700}
|
||||
.chip::after{content:'\\7D';opacity:0.6;font-weight:700}
|
||||
.chip--auto{background:var(--rhc-color-cool-grey-100);color:var(--rhc-color-foreground-default)}
|
||||
.chip--manual{background:var(--rhc-color-geel-100);color:var(--rhc-color-foreground-default)}
|
||||
.chip--warning{background:var(--rhc-color-geel-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
|
||||
.chip--error{background:var(--rhc-color-rood-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
|
||||
`],
|
||||
template: `<span class="chip" [class]="'chip--' + variant()" [attr.aria-label]="ariaLabel()">{{ label() }}</span>`,
|
||||
styles: [
|
||||
`
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2em;
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
padding: 0 0.35em;
|
||||
line-height: 1.6;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */
|
||||
.chip::before {
|
||||
content: '\\7B';
|
||||
opacity: 0.6;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chip::after {
|
||||
content: '\\7D';
|
||||
opacity: 0.6;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chip--auto {
|
||||
background: var(--rhc-color-cool-grey-100);
|
||||
color: var(--rhc-color-foreground-default);
|
||||
}
|
||||
.chip--manual {
|
||||
background: var(--rhc-color-geel-100);
|
||||
color: var(--rhc-color-foreground-default);
|
||||
}
|
||||
.chip--warning {
|
||||
background: var(--rhc-color-geel-100);
|
||||
border-color: var(--rhc-color-border-default);
|
||||
color: var(--rhc-color-foreground-default);
|
||||
}
|
||||
.chip--error {
|
||||
background: var(--rhc-color-rood-100);
|
||||
border-color: var(--rhc-color-border-default);
|
||||
color: var(--rhc-color-foreground-default);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `<span class="chip" [class]="'chip--' + variant()" [attr.aria-label]="ariaLabel()">{{
|
||||
label()
|
||||
}}</span>`,
|
||||
})
|
||||
export class PlaceholderChipComponent {
|
||||
label = input.required<string>();
|
||||
@@ -41,7 +72,12 @@ export class PlaceholderChipComponent {
|
||||
});
|
||||
|
||||
protected ariaLabel = computed(() => {
|
||||
const status = { auto: this.autoText(), manual: this.manualText(), warning: this.warningText(), error: this.errorText() }[this.variant()];
|
||||
const status = {
|
||||
auto: this.autoText(),
|
||||
manual: this.manualText(),
|
||||
warning: this.warningText(),
|
||||
error: this.errorText(),
|
||||
}[this.variant()];
|
||||
return $localize`:@@placeholderChip.aria:Veld ${this.label()}:label:, ${status}:status:`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,15 @@ const meta: Meta<PlaceholderChipComponent> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<PlaceholderChipComponent>;
|
||||
|
||||
export const AutoResolvable: Story = { args: { label: 'Naam zorgverlener', autoResolvable: true, state: 'ok' } };
|
||||
export const Manual: Story = { args: { label: 'Reden besluit', autoResolvable: false, state: 'ok' } };
|
||||
export const Warning: Story = { args: { label: 'Oud kenmerk', autoResolvable: true, state: 'warning' } };
|
||||
export const Error: Story = { args: { label: 'Onbekend veld', autoResolvable: false, state: 'error' } };
|
||||
export const AutoResolvable: Story = {
|
||||
args: { label: 'Naam zorgverlener', autoResolvable: true, state: 'ok' },
|
||||
};
|
||||
export const Manual: Story = {
|
||||
args: { label: 'Reden besluit', autoResolvable: false, state: 'ok' },
|
||||
};
|
||||
export const Warning: Story = {
|
||||
args: { label: 'Oud kenmerk', autoResolvable: true, state: 'warning' },
|
||||
};
|
||||
export const Error: Story = {
|
||||
args: { label: 'Onbekend veld', autoResolvable: false, state: 'error' },
|
||||
};
|
||||
|
||||
@@ -23,7 +23,8 @@ export const JA_NEE: RadioOption[] = [
|
||||
role="radiogroup"
|
||||
[attr.aria-labelledby]="name() + '-label'"
|
||||
[attr.aria-invalid]="invalid() ? 'true' : null"
|
||||
[attr.aria-describedby]="invalid() ? name() + '-error' : null">
|
||||
[attr.aria-describedby]="invalid() ? name() + '-error' : null"
|
||||
>
|
||||
@for (opt of options(); track opt.value) {
|
||||
<div class="form-check styled">
|
||||
<input
|
||||
@@ -36,13 +37,16 @@ export const JA_NEE: RadioOption[] = [
|
||||
[checked]="value === opt.value"
|
||||
[disabled]="disabled"
|
||||
(change)="select(opt.value)"
|
||||
(blur)="onTouched()" />
|
||||
(blur)="onTouched()"
|
||||
/>
|
||||
<label class="form-check-label" [for]="name() + '-' + opt.value">{{ opt.label }}</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true }],
|
||||
providers: [
|
||||
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RadioGroupComponent), multi: true },
|
||||
],
|
||||
})
|
||||
export class RadioGroupComponent implements ControlValueAccessor {
|
||||
options = input.required<RadioOption[]>();
|
||||
@@ -58,8 +62,16 @@ export class RadioGroupComponent implements ControlValueAccessor {
|
||||
this.value = v;
|
||||
this.onChange(v);
|
||||
}
|
||||
writeValue(v: string) { this.value = v ?? ''; }
|
||||
registerOnChange(fn: (v: string) => void) { this.onChange = fn; }
|
||||
registerOnTouched(fn: () => void) { this.onTouched = fn; }
|
||||
setDisabledState(d: boolean) { this.disabled = d; }
|
||||
writeValue(v: string) {
|
||||
this.value = v ?? '';
|
||||
}
|
||||
registerOnChange(fn: (v: string) => void) {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void) {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
setDisabledState(d: boolean) {
|
||||
this.disabled = d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,11 @@ export default meta;
|
||||
type Story = StoryObj<RadioGroupComponent>;
|
||||
|
||||
export const JaNee: Story = {
|
||||
args: { name: 'voorbeeld', options: [{ value: 'ja', label: 'Ja' }, { value: 'nee', label: 'Nee' }] },
|
||||
args: {
|
||||
name: 'voorbeeld',
|
||||
options: [
|
||||
{ value: 'ja', label: 'Ja' },
|
||||
{ value: 'nee', label: 'Nee' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,7 +13,9 @@ import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
||||
<h2>{{ heading() }}</h2>
|
||||
@if (showEdit()) {
|
||||
<div class="ms-auto">
|
||||
<a href="#" [attr.aria-label]="editAriaLabel() || editLabel()" (click)="onEdit($event)">{{ editLabel() }}</a>
|
||||
<a href="#" [attr.aria-label]="editAriaLabel() || editLabel()" (click)="onEdit($event)">{{
|
||||
editLabel()
|
||||
}}</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,12 @@ describe('rich-text DOM boundary', () => {
|
||||
{ type: 'text', text: 'nieuwe regel' },
|
||||
],
|
||||
},
|
||||
{ nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }] },
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Op ' },
|
||||
{ type: 'placeholder', key: 'datum' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(roundTrip(block)).toEqual(block);
|
||||
@@ -46,7 +51,9 @@ describe('rich-text DOM boundary', () => {
|
||||
it('reads combined marks in canonical order regardless of nesting', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<p><em><strong>x</strong></em></p>'; // italic wrapping bold
|
||||
expect(readBlock(root)).toEqual({ paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }] });
|
||||
expect(readBlock(root)).toEqual({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }],
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips bullet and numbered lists mixed with paragraphs', () => {
|
||||
@@ -106,7 +113,16 @@ describe('rich-text DOM boundary', () => {
|
||||
const root = document.createElement('div');
|
||||
renderInto(
|
||||
root,
|
||||
{ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }, { type: 'placeholder', key: 'reden' }] }] },
|
||||
{
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'placeholder', key: 'naam' },
|
||||
{ type: 'placeholder', key: 'reden' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
labelFor,
|
||||
(key) => key === 'naam',
|
||||
);
|
||||
|
||||
@@ -76,9 +76,15 @@ export function createChip(doc: Document, key: string, label: string, auto = fal
|
||||
return span;
|
||||
}
|
||||
|
||||
function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc: Document, autoFor?: (key: string) => boolean): Node {
|
||||
function renderNode(
|
||||
node: RichTextNode,
|
||||
labelFor: (key: string) => string,
|
||||
doc: Document,
|
||||
autoFor?: (key: string) => boolean,
|
||||
): Node {
|
||||
if (node.type === 'lineBreak') return doc.createElement('br');
|
||||
if (node.type === 'placeholder') return createChip(doc, node.key, labelFor(node.key), autoFor?.(node.key) ?? false);
|
||||
if (node.type === 'placeholder')
|
||||
return createChip(doc, node.key, labelFor(node.key), autoFor?.(node.key) ?? false);
|
||||
let el: Node = doc.createTextNode(node.text);
|
||||
// Nest marks in a canonical order so read-back is deterministic.
|
||||
for (const m of ORDER.filter((x) => node.marks?.includes(x))) {
|
||||
@@ -124,7 +130,10 @@ function readLine(el: HTMLElement): { nodes: RichTextNode[] } {
|
||||
function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent ?? '';
|
||||
if (text !== '') out.push(marks.length ? { type: 'text', text, marks: canonical(marks) } : { type: 'text', text });
|
||||
if (text !== '')
|
||||
out.push(
|
||||
marks.length ? { type: 'text', text, marks: canonical(marks) } : { type: 'text', text },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
@@ -144,7 +153,11 @@ function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
|
||||
}
|
||||
|
||||
function isChip(node: Node | null | undefined): node is HTMLElement {
|
||||
return !!node && node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset?.['phKey'] != null;
|
||||
return (
|
||||
!!node &&
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
(node as HTMLElement).dataset?.['phKey'] != null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,7 +165,11 @@ function isChip(node: Node | null | undefined): node is HTMLElement {
|
||||
* (-1 = before / Backspace, +1 = after / Delete), or null. Chips are contenteditable=false,
|
||||
* which some browsers won't delete on Backspace; the editor uses this to remove them itself.
|
||||
*/
|
||||
export function adjacentChip(container: Node, offset: number, direction: -1 | 1): HTMLElement | null {
|
||||
export function adjacentChip(
|
||||
container: Node,
|
||||
offset: number,
|
||||
direction: -1 | 1,
|
||||
): HTMLElement | null {
|
||||
let sibling: Node | null | undefined;
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
// Only adjacent when the caret sits at the text edge (otherwise there are chars to delete first).
|
||||
|
||||
@@ -23,41 +23,122 @@ export interface PlaceholderOption {
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-rich-text-editor',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.rte-toolbar{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-sm);align-items:center;margin-block-end:var(--rhc-space-max-sm)}
|
||||
.rte-toolbar button{min-inline-size:2.2rem}
|
||||
.rte-sep{inline-size:1px;align-self:stretch;background:var(--rhc-color-border-default)}
|
||||
.rte-editable{
|
||||
border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-sm);
|
||||
padding:var(--rhc-space-max-md);min-block-size:4rem;
|
||||
}
|
||||
.rte-editable[contenteditable='false']{background:var(--rhc-color-cool-grey-100)}
|
||||
.rte-editable :is(p){margin:0 0 var(--rhc-space-max-sm)}
|
||||
.rte-editable :is(ul,ol){margin:0 0 var(--rhc-space-max-sm);padding-inline-start:1.4em}
|
||||
/* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side)
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.rte-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
align-items: center;
|
||||
margin-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.rte-toolbar button {
|
||||
min-inline-size: 2.2rem;
|
||||
}
|
||||
.rte-sep {
|
||||
inline-size: 1px;
|
||||
align-self: stretch;
|
||||
background: var(--rhc-color-border-default);
|
||||
}
|
||||
.rte-editable {
|
||||
border: 1px solid var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
padding: var(--rhc-space-max-md);
|
||||
min-block-size: 4rem;
|
||||
}
|
||||
.rte-editable[contenteditable='false'] {
|
||||
background: var(--rhc-color-cool-grey-100);
|
||||
}
|
||||
.rte-editable :is(p) {
|
||||
margin: 0 0 var(--rhc-space-max-sm);
|
||||
}
|
||||
.rte-editable :is(ul, ol) {
|
||||
margin: 0 0 var(--rhc-space-max-sm);
|
||||
padding-inline-start: 1.4em;
|
||||
}
|
||||
/* Placeholder chips read as fill-in fields: auto-resolvable (grey, filled server-side)
|
||||
vs manual (yellow, still needs a value). The read-only preview adds error/warning states.
|
||||
Chips are created imperatively (createChip) inside contenteditable, so they never receive
|
||||
Angular's _ngcontent scoping attribute — ::ng-deep is required or the rules won't match them.
|
||||
Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */
|
||||
:host ::ng-deep .rte-chip{
|
||||
border:1px dashed var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-sm);
|
||||
padding:0 0.3em;white-space:nowrap;
|
||||
}
|
||||
:host ::ng-deep .rte-chip[data-auto='true']{background:var(--rhc-color-cool-grey-100)}
|
||||
:host ::ng-deep .rte-chip[data-auto='false']{background:var(--rhc-color-geel-100)}
|
||||
:host ::ng-deep .rte-chip::before{content:'\\7B';opacity:0.6;font-weight:700;margin-inline-end:0.1em}
|
||||
:host ::ng-deep .rte-chip::after{content:'\\7D';opacity:0.6;font-weight:700;margin-inline-start:0.1em}
|
||||
`],
|
||||
:host ::ng-deep .rte-chip {
|
||||
border: 1px dashed var(--rhc-color-border-default);
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
padding: 0 0.3em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
:host ::ng-deep .rte-chip[data-auto='true'] {
|
||||
background: var(--rhc-color-cool-grey-100);
|
||||
}
|
||||
:host ::ng-deep .rte-chip[data-auto='false'] {
|
||||
background: var(--rhc-color-geel-100);
|
||||
}
|
||||
:host ::ng-deep .rte-chip::before {
|
||||
content: '\\7B';
|
||||
opacity: 0.6;
|
||||
font-weight: 700;
|
||||
margin-inline-end: 0.1em;
|
||||
}
|
||||
:host ::ng-deep .rte-chip::after {
|
||||
content: '\\7D';
|
||||
opacity: 0.6;
|
||||
font-weight: 700;
|
||||
margin-inline-start: 0.1em;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (editable()) {
|
||||
<div class="rte-toolbar" role="toolbar" [attr.aria-label]="toolbarLabel()">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="format('bold')" [attr.aria-label]="boldLabel()"><b>B</b></button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="format('italic')" [attr.aria-label]="italicLabel()"><i>I</i></button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="format('underline')" [attr.aria-label]="underlineLabel()"><u>U</u></button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(click)="format('bold')"
|
||||
[attr.aria-label]="boldLabel()"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(click)="format('italic')"
|
||||
[attr.aria-label]="italicLabel()"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(click)="format('underline')"
|
||||
[attr.aria-label]="underlineLabel()"
|
||||
>
|
||||
<u>U</u>
|
||||
</button>
|
||||
<span class="rte-sep" aria-hidden="true"></span>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="list('bullet')" [attr.aria-label]="bulletListLabel()">•</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" (mousedown)="$event.preventDefault()" (click)="list('number')" [attr.aria-label]="numberListLabel()">1.</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(click)="list('bullet')"
|
||||
[attr.aria-label]="bulletListLabel()"
|
||||
>
|
||||
•
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
(mousedown)="$event.preventDefault()"
|
||||
(click)="list('number')"
|
||||
[attr.aria-label]="numberListLabel()"
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
@if (placeholders().length) {
|
||||
<span class="rte-sep" aria-hidden="true"></span>
|
||||
<label>
|
||||
@@ -72,8 +153,16 @@ export interface PlaceholderOption {
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div #editor class="rte-editable" [attr.contenteditable]="editable()" (input)="emit()" (keydown)="onKeydown($event)"
|
||||
role="textbox" aria-multiline="true" [attr.aria-label]="fieldLabel()"></div>
|
||||
<div
|
||||
#editor
|
||||
class="rte-editable"
|
||||
[attr.contenteditable]="editable()"
|
||||
(input)="emit()"
|
||||
(keydown)="onKeydown($event)"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
[attr.aria-label]="fieldLabel()"
|
||||
></div>
|
||||
`,
|
||||
})
|
||||
export class RichTextEditorComponent {
|
||||
@@ -97,7 +186,8 @@ export class RichTextEditorComponent {
|
||||
private lastEmitted = '';
|
||||
|
||||
private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;
|
||||
private autoFor = (key: string) => this.placeholders().find((p) => p.key === key)?.autoResolvable ?? false;
|
||||
private autoFor = (key: string) =>
|
||||
this.placeholders().find((p) => p.key === key)?.autoResolvable ?? false;
|
||||
|
||||
constructor() {
|
||||
// Render when content arrives/changes from OUTSIDE. Skip our own emitted value
|
||||
@@ -149,7 +239,11 @@ export class RichTextEditorComponent {
|
||||
return;
|
||||
}
|
||||
if (!(e.ctrlKey || e.metaKey) || e.altKey) return;
|
||||
const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key.toLowerCase()] as 'bold' | 'italic' | 'underline' | undefined;
|
||||
const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key.toLowerCase()] as
|
||||
| 'bold'
|
||||
| 'italic'
|
||||
| 'underline'
|
||||
| undefined;
|
||||
if (!cmd) return;
|
||||
e.preventDefault();
|
||||
this.format(cmd);
|
||||
@@ -164,7 +258,11 @@ export class RichTextEditorComponent {
|
||||
if (!sel || !sel.isCollapsed || !sel.rangeCount) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
if (!el.contains(range.startContainer)) return;
|
||||
const chip = adjacentChip(range.startContainer, range.startOffset, e.key === 'Backspace' ? -1 : 1);
|
||||
const chip = adjacentChip(
|
||||
range.startContainer,
|
||||
range.startOffset,
|
||||
e.key === 'Backspace' ? -1 : 1,
|
||||
);
|
||||
if (!chip) return;
|
||||
e.preventDefault();
|
||||
chip.remove();
|
||||
|
||||
@@ -11,7 +11,13 @@ const sample: RichTextBlock = {
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
{ nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }, { type: 'text', text: ' hebben wij besloten.' }] },
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Op ' },
|
||||
{ type: 'placeholder', key: 'datum' },
|
||||
{ type: 'text', text: ' hebben wij besloten.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -33,5 +39,7 @@ export default meta;
|
||||
type Story = StoryObj<RichTextEditorComponent>;
|
||||
|
||||
export const Editing: Story = { args: { content: sample, placeholders, editable: true } };
|
||||
export const Empty: Story = { args: { content: { paragraphs: [{ nodes: [] }] }, placeholders, editable: true } };
|
||||
export const Empty: Story = {
|
||||
args: { content: { paragraphs: [{ nodes: [] }] }, placeholders, editable: true },
|
||||
};
|
||||
export const ReadOnly: Story = { args: { content: sample, placeholders, editable: false } };
|
||||
|
||||
@@ -4,13 +4,33 @@ import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/
|
||||
on fast responses. Render `count` lines shaped roughly like the content. */
|
||||
@Component({
|
||||
selector: 'app-skeleton',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.sk{background:linear-gradient(90deg,var(--rhc-color-cool-grey-200) 25%,var(--rhc-color-cool-grey-100) 37%,var(--rhc-color-cool-grey-200) 63%);
|
||||
background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:var(--rhc-border-radius-md);
|
||||
margin-block-end:var(--rhc-space-max-lg)}
|
||||
@keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.sk {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--rhc-color-cool-grey-200) 25%,
|
||||
var(--rhc-color-cool-grey-100) 37%,
|
||||
var(--rhc-color-cool-grey-200) 63%
|
||||
);
|
||||
background-size: 400% 100%;
|
||||
animation: sh 1.4s ease infinite;
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
}
|
||||
@keyframes sh {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (visible()) {
|
||||
@for (l of lines(); track $index) {
|
||||
@@ -28,6 +48,10 @@ export class SkeletonComponent implements OnInit, OnDestroy {
|
||||
protected lines = computed(() => Array(this.count()).fill(0));
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }
|
||||
ngOnDestroy() { clearTimeout(this.timer); }
|
||||
ngOnInit() {
|
||||
this.timer = setTimeout(() => this.visible.set(true), this.delay());
|
||||
}
|
||||
ngOnDestroy() {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,35 @@ import { Component, OnDestroy, OnInit, input, signal } from '@angular/core';
|
||||
flash a spinner, slow ones get feedback. */
|
||||
@Component({
|
||||
selector: 'app-spinner',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.sp{inline-size:var(--rhc-space-max-3xl);block-size:var(--rhc-space-max-3xl);border-radius:var(--rhc-border-radius-round);
|
||||
border:var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300);
|
||||
border-block-start-color:var(--rhc-color-lintblauw-700);
|
||||
animation:sp 0.8s linear infinite;margin:var(--rhc-space-max-2xl) auto}
|
||||
@keyframes sp{to{transform:rotate(360deg)}}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.sp {
|
||||
inline-size: var(--rhc-space-max-3xl);
|
||||
block-size: var(--rhc-space-max-3xl);
|
||||
border-radius: var(--rhc-border-radius-round);
|
||||
border: var(--rhc-space-max-xs) solid var(--rhc-color-cool-grey-300);
|
||||
border-block-start-color: var(--rhc-color-lintblauw-700);
|
||||
animation: sp 0.8s linear infinite;
|
||||
margin: var(--rhc-space-max-2xl) auto;
|
||||
}
|
||||
@keyframes sp {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (visible()) {
|
||||
<div class="sp" role="status" i18n-aria-label="@@spinner.aria" aria-label="Bezig met laden"></div>
|
||||
<div
|
||||
class="sp"
|
||||
role="status"
|
||||
i18n-aria-label="@@spinner.aria"
|
||||
aria-label="Bezig met laden"
|
||||
></div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
@@ -23,6 +41,10 @@ export class SpinnerComponent implements OnInit, OnDestroy {
|
||||
protected visible = signal(false);
|
||||
private timer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
ngOnInit() { this.timer = setTimeout(() => this.visible.set(true), this.delay()); }
|
||||
ngOnDestroy() { clearTimeout(this.timer); }
|
||||
ngOnInit() {
|
||||
this.timer = setTimeout(() => this.visible.set(true), this.delay());
|
||||
}
|
||||
ngOnDestroy() {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,21 @@ import { Component, input } from '@angular/core';
|
||||
@Component({
|
||||
selector: 'app-status-badge',
|
||||
// Local class is NOT Bootstrap's .badge (which would add pill padding/colour) — hence .status-badge.
|
||||
styles: [`
|
||||
.status-badge{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md)}
|
||||
.dot{inline-size:0.75rem;block-size:0.75rem;border-radius:var(--rhc-border-radius-round);flex:none}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
}
|
||||
.dot {
|
||||
inline-size: 0.75rem;
|
||||
block-size: 0.75rem;
|
||||
border-radius: var(--rhc-border-radius-round);
|
||||
flex: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<span class="status-badge">
|
||||
<span class="dot" [style.background-color]="color()" aria-hidden="true"></span>
|
||||
|
||||
@@ -8,6 +8,12 @@ const meta: Meta<StatusBadgeComponent> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<StatusBadgeComponent>;
|
||||
|
||||
export const Geregistreerd: Story = { args: { label: 'Geregistreerd', color: 'var(--rhc-color-groen-500)' } };
|
||||
export const Geschorst: Story = { args: { label: 'Geschorst', color: 'var(--rhc-color-oranje-500)' } };
|
||||
export const Doorgehaald: Story = { args: { label: 'Doorgehaald', color: 'var(--rhc-color-rood-500)' } };
|
||||
export const Geregistreerd: Story = {
|
||||
args: { label: 'Geregistreerd', color: 'var(--rhc-color-groen-500)' },
|
||||
};
|
||||
export const Geschorst: Story = {
|
||||
args: { label: 'Geschorst', color: 'var(--rhc-color-oranje-500)' },
|
||||
};
|
||||
export const Doorgehaald: Story = {
|
||||
args: { label: 'Doorgehaald', color: 'var(--rhc-color-rood-500)' },
|
||||
};
|
||||
|
||||
@@ -12,12 +12,21 @@ import { Component, ElementRef, input, output, viewChild } from '@angular/core';
|
||||
@for (label of steps(); track label; let i = $index) {
|
||||
<li>
|
||||
@if (i < current()) {
|
||||
<a href="#" class="step visited" (click)="select($event, i)" [attr.aria-label]="terugNaar(i, label)">
|
||||
<a
|
||||
href="#"
|
||||
class="step visited"
|
||||
(click)="select($event, i)"
|
||||
[attr.aria-label]="terugNaar(i, label)"
|
||||
>
|
||||
<span class="visually-hidden" i18n="@@stepper.stap">Stap</span> {{ i + 1 }}
|
||||
<span class="visually-hidden" i18n="@@stepper.voltooid">Voltooid</span>
|
||||
</a>
|
||||
} @else if (i === current()) {
|
||||
<span class="step active" aria-current="step" [attr.aria-label]="huidigeStap(i, label)">
|
||||
<span
|
||||
class="step active"
|
||||
aria-current="step"
|
||||
[attr.aria-label]="huidigeStap(i, label)"
|
||||
>
|
||||
<span class="visually-hidden" i18n="@@stepper.stap">Stap</span> {{ i + 1 }}
|
||||
<span class="visually-hidden" i18n="@@stepper.huidig">Huidige stap</span>
|
||||
</span>
|
||||
@@ -30,8 +39,13 @@ import { Component, ElementRef, input, output, viewChild } from '@angular/core';
|
||||
}
|
||||
</ol>
|
||||
<h2 #title tabindex="-1" class="h1 order-0">
|
||||
@if (processName()) { <span class="process-name">{{ processName() }}</span> }
|
||||
<ng-container i18n="@@stepper.stapVan">Stap {{ current() + 1 }}<span class="visually-hidden"> van {{ steps().length }}</span>: {{ stepTitle() || steps()[current()] }}</ng-container>
|
||||
@if (processName()) {
|
||||
<span class="process-name">{{ processName() }}</span>
|
||||
}
|
||||
<ng-container i18n="@@stepper.stapVan"
|
||||
>Stap {{ current() + 1 }}<span class="visually-hidden"> van {{ steps().length }}</span
|
||||
>: {{ stepTitle() || steps()[current()] }}</ng-container
|
||||
>
|
||||
</h2>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -13,8 +13,15 @@ export default meta;
|
||||
type Story = StoryObj<StepperComponent>;
|
||||
|
||||
const steps = ['Adres', 'Beroep', 'Controle'];
|
||||
const base = { steps, processName: 'Inschrijven in het BIG-register', stepTitle: '', stepSelected: () => {} };
|
||||
const base = {
|
||||
steps,
|
||||
processName: 'Inschrijven in het BIG-register',
|
||||
stepTitle: '',
|
||||
stepSelected: () => {},
|
||||
};
|
||||
|
||||
export const Eerste: Story = { args: { ...base, current: 0 } };
|
||||
export const Midden: Story = { args: { ...base, current: 1, stepTitle: 'Beroep op basis van uw diploma' } };
|
||||
export const Midden: Story = {
|
||||
args: { ...base, current: 1, stepTitle: 'Beroep op basis van uw diploma' },
|
||||
};
|
||||
export const Laatste: Story = { args: { ...base, current: 2 } };
|
||||
|
||||
@@ -7,7 +7,10 @@ const meta: Meta<TaskListComponent> = {
|
||||
title: 'Molecules/Task List',
|
||||
component: TaskListComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({ props: args, template: `<app-task-list [listHeading]="listHeading" [tasks]="tasks" />` }),
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-task-list [listHeading]="listHeading" [tasks]="tasks" />`,
|
||||
}),
|
||||
parameters: {
|
||||
// Structural: app-choice-link's host sits between the keuzelijst <ul> and its <li>
|
||||
// — axe's list/listitem rule needs them adjacent regardless of `display:contents`.
|
||||
@@ -22,8 +25,18 @@ export const Default: Story = {
|
||||
args: {
|
||||
listHeading: 'Wat moet ik regelen',
|
||||
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' },
|
||||
{
|
||||
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,10 +4,17 @@ 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}
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
input {
|
||||
inline-size: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<input
|
||||
class="form-control"
|
||||
@@ -20,9 +27,12 @@ import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
[disabled]="disabled"
|
||||
[value]="value"
|
||||
(input)="onInput($event)"
|
||||
(blur)="onTouched()" />
|
||||
(blur)="onTouched()"
|
||||
/>
|
||||
`,
|
||||
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), multi: true }],
|
||||
providers: [
|
||||
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => TextInputComponent), multi: true },
|
||||
],
|
||||
})
|
||||
export class TextInputComponent implements ControlValueAccessor {
|
||||
type = input<'text' | 'password' | 'email'>('text');
|
||||
@@ -39,8 +49,16 @@ export class TextInputComponent implements ControlValueAccessor {
|
||||
this.value = (e.target as HTMLInputElement).value;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
writeValue(v: string) { this.value = v ?? ''; }
|
||||
registerOnChange(fn: (v: string) => void) { this.onChange = fn; }
|
||||
registerOnTouched(fn: () => void) { this.onTouched = fn; }
|
||||
setDisabledState(d: boolean) { this.disabled = d; }
|
||||
writeValue(v: string) {
|
||||
this.value = v ?? '';
|
||||
}
|
||||
registerOnChange(fn: (v: string) => void) {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void) {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
setDisabledState(d: boolean) {
|
||||
this.disabled = d;
|
||||
}
|
||||
}
|
||||
|
||||
+17
-11
@@ -5,16 +5,21 @@ import type { DeliveryChannel } from '@shared/upload/upload.machine';
|
||||
Thin wrapper over the Utrecht/RHC radio CSS. Pure UI: emits the chosen channel. */
|
||||
@Component({
|
||||
selector: 'app-delivery-channel-toggle',
|
||||
styles: [`
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
padding-block: var(--rhc-space-max-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.radio-option .form-check-input { margin: 0; float: none; }
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
padding-block: var(--rhc-space-max-sm);
|
||||
margin: 0;
|
||||
}
|
||||
.radio-option .form-check-input {
|
||||
margin: 0;
|
||||
float: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div role="radiogroup">
|
||||
@for (opt of options; track opt.value) {
|
||||
@@ -26,7 +31,8 @@ import type { DeliveryChannel } from '@shared/upload/upload.machine';
|
||||
[value]="opt.value"
|
||||
[checked]="channel() === opt.value"
|
||||
[disabled]="disabled()"
|
||||
(change)="channelChange.emit(opt.value)" />
|
||||
(change)="channelChange.emit(opt.value)"
|
||||
/>
|
||||
{{ opt.label }}
|
||||
</label>
|
||||
}
|
||||
|
||||
@@ -12,5 +12,7 @@ const meta: Meta<DeliveryChannelToggleComponent> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<DeliveryChannelToggleComponent>;
|
||||
|
||||
export const Digital: Story = { args: { channel: 'digital', name: 'diploma-channel', disabled: false } };
|
||||
export const Digital: Story = {
|
||||
args: { channel: 'digital', name: 'diploma-channel', disabled: false },
|
||||
};
|
||||
export const Post: Story = { args: { channel: 'post', name: 'diploma-channel', disabled: false } };
|
||||
|
||||
@@ -11,16 +11,37 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
|
||||
@Component({
|
||||
selector: 'app-document-category',
|
||||
imports: [DeliveryChannelToggleComponent, FileInputComponent, SingleUploadComponent],
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.label { 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-md); }
|
||||
.file-list { list-style: none; padding: 0; margin-block-start: var(--rhc-space-max-md); }
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.label {
|
||||
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-md);
|
||||
}
|
||||
.file-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-block-start: var(--rhc-space-max-md);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="label">
|
||||
{{ category().label }}@if (category().required) { <span class="req" i18n="@@upload.category.required">(verplicht)</span> }
|
||||
{{ category().label }}
|
||||
@if (category().required) {
|
||||
<span class="req" i18n="@@upload.category.required">(verplicht)</span>
|
||||
}
|
||||
</div>
|
||||
@if (category().description) {
|
||||
<div class="desc">{{ category().description }}</div>
|
||||
@@ -30,7 +51,8 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
|
||||
<app-delivery-channel-toggle
|
||||
[channel]="channel()"
|
||||
[name]="category().categoryId + '-channel'"
|
||||
(channelChange)="channelChange.emit($event)" />
|
||||
(channelChange)="channelChange.emit($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (channel() === 'digital') {
|
||||
@@ -47,18 +69,21 @@ import { SingleUploadComponent } from '../single-upload/single-upload.component'
|
||||
[accept]="category().acceptedTypes"
|
||||
[maxSizeMb]="category().maxSizeMb"
|
||||
[multiple]="category().multiple"
|
||||
(filesSelected)="fileSelected.emit($event)" />
|
||||
(filesSelected)="fileSelected.emit($event)"
|
||||
/>
|
||||
|
||||
@if (uploads().length) {
|
||||
<ul class="file-list">
|
||||
@for (u of uploads(); track u.localId) {
|
||||
<li app-single-upload
|
||||
<li
|
||||
app-single-upload
|
||||
animate.enter="app-item-enter"
|
||||
animate.leave="app-item-leave"
|
||||
[upload]="u"
|
||||
[previewUrlFor]="previewUrlFor()"
|
||||
(remove)="onRemove(u)"
|
||||
(retry)="retryUpload.emit(u.localId)"></li>
|
||||
(retry)="retryUpload.emit(u.localId)"
|
||||
></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
@@ -73,7 +98,10 @@ export class DocumentCategoryComponent {
|
||||
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||
|
||||
/** Accessible name for the file picker, e.g. "Bestand kiezen voor Diploma". */
|
||||
protected fileInputLabel = computed(() => $localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`);
|
||||
protected fileInputLabel = computed(
|
||||
() =>
|
||||
$localize`:@@upload.fileInput.labelFor:Bestand kiezen voor ${this.category().label}:category:`,
|
||||
);
|
||||
|
||||
fileSelected = output<File[]>();
|
||||
removeUpload = output<string>();
|
||||
|
||||
@@ -40,5 +40,10 @@ export const Digital: Story = {
|
||||
};
|
||||
|
||||
export const WithRejection: Story = {
|
||||
args: { category, uploads: [], channel: 'digital', rejection: 'Dit bestandstype is niet toegestaan voor deze categorie.' },
|
||||
args: {
|
||||
category,
|
||||
uploads: [],
|
||||
channel: 'digital',
|
||||
rejection: 'Dit bestandstype is niet toegestaan voor deze categorie.',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -19,17 +19,30 @@ const STATUS_LABELS: Record<UploadStatus['type'], string> = {
|
||||
@Component({
|
||||
selector: 'app-document-chip',
|
||||
imports: [UploadStatusIconComponent],
|
||||
styles: [`
|
||||
:host { display: contents; }
|
||||
.file { display: flex; flex-direction: column; gap: var(--rhc-space-max-xs); min-inline-size: 0; }
|
||||
.file-name { word-break: break-all; }
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
.file {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--rhc-space-max-xs);
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.file-name {
|
||||
word-break: break-all;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="file">
|
||||
<span class="d-inline-flex align-items-center gap-2">
|
||||
<app-upload-status-icon [status]="status().type" />
|
||||
@if (previewUrl()) {
|
||||
<a class="file-name" [href]="previewUrl()" target="_blank" rel="noopener">{{ fileName() }}</a>
|
||||
<a class="file-name" [href]="previewUrl()" target="_blank" rel="noopener">{{
|
||||
fileName()
|
||||
}}</a>
|
||||
} @else {
|
||||
<span class="file-name">{{ fileName() }}</span>
|
||||
}
|
||||
|
||||
@@ -15,13 +15,26 @@ export default meta;
|
||||
type Story = StoryObj<DocumentChipComponent>;
|
||||
|
||||
export const Complete: Story = {
|
||||
args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'complete', documentId: 'doc-1' } as UploadStatus },
|
||||
args: {
|
||||
fileName: 'diploma.pdf',
|
||||
fileSizeMb: 1.2,
|
||||
status: { type: 'complete', documentId: 'doc-1' } as UploadStatus,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithPreview: Story = {
|
||||
args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'complete', documentId: 'doc-1' } as UploadStatus, previewUrl: '/api/v1/uploads/doc-1/content' },
|
||||
args: {
|
||||
fileName: 'diploma.pdf',
|
||||
fileSizeMb: 1.2,
|
||||
status: { type: 'complete', documentId: 'doc-1' } as UploadStatus,
|
||||
previewUrl: '/api/v1/uploads/doc-1/content',
|
||||
},
|
||||
};
|
||||
|
||||
export const Failed: Story = {
|
||||
args: { fileName: 'diploma.pdf', fileSizeMb: 1.2, status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus },
|
||||
args: {
|
||||
fileName: 'diploma.pdf',
|
||||
fileSizeMb: 1.2,
|
||||
status: { type: 'failed', reason: 'Netwerkfout' } as UploadStatus,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,9 +9,15 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
|
||||
@Component({
|
||||
selector: 'app-document-upload',
|
||||
imports: [DocumentCategoryComponent, UploadStatusBannerComponent],
|
||||
styles: [`
|
||||
:host { display: flex; flex-direction: column; gap: var(--rhc-space-max-xl); }
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--rhc-space-max-xl);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (state().categoriesError) {
|
||||
<app-upload-status-banner type="error" [message]="state().categoriesError!" />
|
||||
@@ -30,7 +36,8 @@ import { UploadStatusBannerComponent } from '../upload-status-banner/upload-stat
|
||||
(removeUpload)="removeUpload.emit($event)"
|
||||
(retryUpload)="retryUpload.emit($event)"
|
||||
(deleteUpload)="deleteUpload.emit($event)"
|
||||
(channelChange)="channelChange.emit({ categoryId: c.categoryId, channel: $event })" />
|
||||
(channelChange)="channelChange.emit({ categoryId: c.categoryId, channel: $event })"
|
||||
/>
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -21,21 +21,47 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
`aria-describedby` (pattern requirement). Pure UI — no validation, no upload. */
|
||||
@Component({
|
||||
selector: 'app-file-input',
|
||||
styles: [`
|
||||
:host { display: block; }
|
||||
.file-picker-drop-area { display: flex; flex-direction: column; align-items: flex-start; gap: var(--rhc-space-max-sm); }
|
||||
/* Default (not subtle) foreground: subtle grey on the grey drop-area fails WCAG AA contrast. */
|
||||
.upload-instructions { margin: 0; color: var(--rhc-color-foreground-default); font-size: var(--rhc-text-font-size-sm); }
|
||||
/* Solid CIBG-blue button; the .btn-upload ::before folder glyph is the icon, so
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.file-picker-drop-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
/* Default (not subtle) foreground: subtle grey on the grey drop-area fails WCAG AA contrast. */
|
||||
.upload-instructions {
|
||||
margin: 0;
|
||||
color: var(--rhc-color-foreground-default);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
}
|
||||
/* Solid CIBG-blue button; the .btn-upload ::before folder glyph is the icon, so
|
||||
drop its redundant centred background-image folder. */
|
||||
.btn-upload { cursor: pointer; background-image: none; }
|
||||
.btn-upload.disabled { cursor: not-allowed; }
|
||||
/* The input is visually hidden but still focusable; surface its focus on the button. */
|
||||
input:focus-visible + .btn-upload { outline: var(--rhc-border-width-md) solid var(--rhc-color-foreground-link); outline-offset: 2px; }
|
||||
`],
|
||||
.btn-upload {
|
||||
cursor: pointer;
|
||||
background-image: none;
|
||||
}
|
||||
.btn-upload.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* The input is visually hidden but still focusable; surface its focus on the button. */
|
||||
input:focus-visible + .btn-upload {
|
||||
outline: var(--rhc-border-width-md) solid var(--rhc-color-foreground-link);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="file-picker-drop-area" [class.drag-over]="dragging()"
|
||||
(dragover)="onDragOver($event)" (dragleave)="onDragLeave($event)" (drop)="onDrop($event)">
|
||||
<div
|
||||
class="file-picker-drop-area"
|
||||
[class.drag-over]="dragging()"
|
||||
(dragover)="onDragOver($event)"
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
>
|
||||
@if (instructions()) {
|
||||
<p class="upload-instructions" [id]="instructionsId">{{ instructions() }}</p>
|
||||
}
|
||||
@@ -49,8 +75,13 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
[accept]="accept().join(',')"
|
||||
[multiple]="multiple()"
|
||||
[disabled]="disabled()"
|
||||
(change)="onChange($event)" />
|
||||
<label class="btn btn-primary btn-upload" [class.disabled]="disabled()" [attr.for]="inputId()">
|
||||
(change)="onChange($event)"
|
||||
/>
|
||||
<label
|
||||
class="btn btn-primary btn-upload"
|
||||
[class.disabled]="disabled()"
|
||||
[attr.for]="inputId()"
|
||||
>
|
||||
{{ buttonText() }}
|
||||
</label>
|
||||
</div>
|
||||
@@ -80,9 +111,12 @@ export class FileInputComponent {
|
||||
|
||||
/** Always-visible instruction: allowed file types + max size (CIBG pattern). */
|
||||
protected instructions = computed(() => {
|
||||
const types = this.accept().map((m) => TYPE_LABELS[m] ?? m.split('/').pop()?.toUpperCase() ?? m).join(', ');
|
||||
const types = this.accept()
|
||||
.map((m) => TYPE_LABELS[m] ?? m.split('/').pop()?.toUpperCase() ?? m)
|
||||
.join(', ');
|
||||
const size = this.maxSizeMb();
|
||||
if (types && size > 0) return $localize`:@@upload.fileInput.instrBoth:Toegestaan: ${types}:types: · max ${size}:size: MB`;
|
||||
if (types && size > 0)
|
||||
return $localize`:@@upload.fileInput.instrBoth:Toegestaan: ${types}:types: · max ${size}:size: MB`;
|
||||
if (types) return $localize`:@@upload.fileInput.instrTypes:Toegestaan: ${types}:types:`;
|
||||
if (size > 0) return $localize`:@@upload.fileInput.instrSize:Max ${size}:size: MB`;
|
||||
return '';
|
||||
|
||||
@@ -13,13 +13,31 @@ export default meta;
|
||||
type Story = StoryObj<FileInputComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { inputId: 'diploma', accept: ['application/pdf', 'image/jpeg'], maxSizeMb: 10, multiple: false, disabled: false },
|
||||
args: {
|
||||
inputId: 'diploma',
|
||||
accept: ['application/pdf', 'image/jpeg'],
|
||||
maxSizeMb: 10,
|
||||
multiple: false,
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Multiple: Story = {
|
||||
args: { inputId: 'cv', accept: ['application/pdf'], maxSizeMb: 5, multiple: true, disabled: false },
|
||||
args: {
|
||||
inputId: 'cv',
|
||||
accept: ['application/pdf'],
|
||||
maxSizeMb: 5,
|
||||
multiple: true,
|
||||
disabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: { inputId: 'diploma-disabled', accept: ['application/pdf'], maxSizeMb: 10, multiple: false, disabled: true },
|
||||
args: {
|
||||
inputId: 'diploma-disabled',
|
||||
accept: ['application/pdf'],
|
||||
maxSizeMb: 10,
|
||||
multiple: false,
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,24 +11,55 @@ import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progre
|
||||
selector: 'li[app-single-upload]',
|
||||
host: { class: 'file-container' },
|
||||
imports: [DocumentChipComponent, UploadProgressBarComponent],
|
||||
styles: [`
|
||||
:host { flex-wrap: wrap; align-items: center; }
|
||||
.upload-progress { flex: 1 0 100%; margin-block-start: var(--rhc-space-max-sm); }
|
||||
.actions .btn { background: none; border: none; cursor: pointer; padding: 0; }
|
||||
.actions .btn-retry { color: var(--rhc-color-foreground-link); text-decoration: underline; font: inherit; }
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.upload-progress {
|
||||
flex: 1 0 100%;
|
||||
margin-block-start: var(--rhc-space-max-sm);
|
||||
}
|
||||
.actions .btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.actions .btn-retry {
|
||||
color: var(--rhc-color-foreground-link);
|
||||
text-decoration: underline;
|
||||
font: inherit;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<app-document-chip
|
||||
[fileName]="upload().fileName"
|
||||
[status]="upload().status"
|
||||
[fileSizeMb]="upload().fileSizeMb"
|
||||
[previewUrl]="previewUrl()" />
|
||||
[previewUrl]="previewUrl()"
|
||||
/>
|
||||
<div class="actions">
|
||||
@if (upload().status.type === 'failed') {
|
||||
<button type="button" class="btn btn-retry" [attr.aria-label]="retryLabel" (click)="retry.emit()" i18n="@@upload.chip.retry">Opnieuw</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-retry"
|
||||
[attr.aria-label]="retryLabel"
|
||||
(click)="retry.emit()"
|
||||
i18n="@@upload.chip.retry"
|
||||
>
|
||||
Opnieuw
|
||||
</button>
|
||||
}
|
||||
@if (upload().status.type !== 'deleting') {
|
||||
<button type="button" class="btn icon-remove" [attr.aria-label]="removeLabel" (click)="remove.emit()"></button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn icon-remove"
|
||||
[attr.aria-label]="removeLabel"
|
||||
(click)="remove.emit()"
|
||||
></button>
|
||||
}
|
||||
</div>
|
||||
@if (progressPct() !== null) {
|
||||
|
||||
@@ -4,16 +4,27 @@ import { Component, input } from '@angular/core';
|
||||
the percentage. */
|
||||
@Component({
|
||||
selector: 'app-upload-progress-bar',
|
||||
styles: [`
|
||||
:host { display: flex; align-items: center; gap: var(--rhc-space-max-md); }
|
||||
progress { flex: 1; height: var(--rhc-space-max-md); }
|
||||
.pct { font-size: var(--rhc-text-font-size-sm); color: var(--rhc-color-foreground-subtle); min-width: 3ch; text-align: end; }
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
}
|
||||
progress {
|
||||
flex: 1;
|
||||
height: var(--rhc-space-max-md);
|
||||
}
|
||||
.pct {
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
min-width: 3ch;
|
||||
text-align: end;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<progress
|
||||
max="100"
|
||||
[value]="progressPct()"
|
||||
[attr.aria-label]="progressLabel"></progress>
|
||||
<progress max="100" [value]="progressPct()" [attr.aria-label]="progressLabel"></progress>
|
||||
<span class="pct">{{ progressPct() }}%</span>
|
||||
`,
|
||||
})
|
||||
|
||||
@@ -19,5 +19,7 @@ export class UploadStatusBannerComponent {
|
||||
message = input.required<string>();
|
||||
type = input<BannerType>('info');
|
||||
|
||||
protected readonly alertType = computed<AlertType>(() => (this.type() === 'info' ? 'info' : this.type()));
|
||||
protected readonly alertType = computed<AlertType>(() =>
|
||||
this.type() === 'info' ? 'info' : this.type(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,21 @@ interface Glyph {
|
||||
label derive purely from the status type. */
|
||||
@Component({
|
||||
selector: 'app-upload-status-icon',
|
||||
styles: [`
|
||||
.glyph { font-weight: var(--rhc-text-font-weight-semi-bold); }
|
||||
`],
|
||||
styles: [
|
||||
`
|
||||
.glyph {
|
||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (glyph()) {
|
||||
<span class="glyph" [style.color]="glyph()!.color" [attr.aria-label]="glyph()!.label" role="img">
|
||||
<span
|
||||
class="glyph"
|
||||
[style.color]="glyph()!.color"
|
||||
[attr.aria-label]="glyph()!.label"
|
||||
role="img"
|
||||
>
|
||||
{{ glyph()!.char }}
|
||||
</span>
|
||||
}
|
||||
@@ -28,15 +37,35 @@ export class UploadStatusIconComponent {
|
||||
protected readonly glyph = computed<Glyph | null>(() => {
|
||||
switch (this.status()) {
|
||||
case 'queued':
|
||||
return { char: '…', label: $localize`:@@upload.status.queued:In wachtrij`, color: 'var(--rhc-color-foreground-subtle)' };
|
||||
return {
|
||||
char: '…',
|
||||
label: $localize`:@@upload.status.queued:In wachtrij`,
|
||||
color: 'var(--rhc-color-foreground-subtle)',
|
||||
};
|
||||
case 'uploading':
|
||||
return { char: '↑', label: $localize`:@@upload.status.uploading:Bezig met uploaden`, color: 'var(--rhc-color-lintblauw-600)' };
|
||||
return {
|
||||
char: '↑',
|
||||
label: $localize`:@@upload.status.uploading:Bezig met uploaden`,
|
||||
color: 'var(--rhc-color-lintblauw-600)',
|
||||
};
|
||||
case 'complete':
|
||||
return { char: '✓', label: $localize`:@@upload.status.complete:Geüpload`, color: 'var(--rhc-color-groen-500)' };
|
||||
return {
|
||||
char: '✓',
|
||||
label: $localize`:@@upload.status.complete:Geüpload`,
|
||||
color: 'var(--rhc-color-groen-500)',
|
||||
};
|
||||
case 'failed':
|
||||
return { char: '✕', label: $localize`:@@upload.status.failed:Mislukt`, color: 'var(--rhc-color-rood-500)' };
|
||||
return {
|
||||
char: '✕',
|
||||
label: $localize`:@@upload.status.failed:Mislukt`,
|
||||
color: 'var(--rhc-color-rood-500)',
|
||||
};
|
||||
case 'deleting':
|
||||
return { char: '⟳', label: $localize`:@@upload.status.deleting:Bezig met verwijderen`, color: 'var(--rhc-color-foreground-subtle)' };
|
||||
return {
|
||||
char: '⟳',
|
||||
label: $localize`:@@upload.status.deleting:Bezig met verwijderen`,
|
||||
color: 'var(--rhc-color-foreground-subtle)',
|
||||
};
|
||||
case 'idle':
|
||||
case 'deleted':
|
||||
return null;
|
||||
|
||||
@@ -34,7 +34,10 @@ export function createUploadController(deps: UploadControllerDeps) {
|
||||
if (status === 'resolved' || status === 'local') {
|
||||
deps.dispatch({ type: 'CategoriesLoaded', categories: categoriesRes.value() ?? [] });
|
||||
} else if (status === 'error') {
|
||||
deps.dispatch({ type: 'CategoriesLoadFailed', reason: problemDetail(categoriesRes.error(), SUBMIT_FAILED) });
|
||||
deps.dispatch({
|
||||
type: 'CategoriesLoadFailed',
|
||||
reason: problemDetail(categoriesRes.error(), SUBMIT_FAILED),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -45,7 +48,13 @@ export function createUploadController(deps: UploadControllerDeps) {
|
||||
function start(categoryId: string, file: File) {
|
||||
const localId = crypto.randomUUID();
|
||||
files.set(localId, file);
|
||||
deps.dispatch({ type: 'FileSelected', categoryId, localId, fileName: file.name, fileSizeMb: file.size / 1e6 });
|
||||
deps.dispatch({
|
||||
type: 'FileSelected',
|
||||
categoryId,
|
||||
localId,
|
||||
fileName: file.name,
|
||||
fileSizeMb: file.size / 1e6,
|
||||
});
|
||||
shell.upload({ localId, categoryId, wizardId: deps.wizardId, file }, deps.dispatch);
|
||||
}
|
||||
|
||||
@@ -73,7 +82,10 @@ export function createUploadController(deps: UploadControllerDeps) {
|
||||
const up = deps.getUpload().uploads.find((u) => u.localId === localId);
|
||||
if (!file || !up) return;
|
||||
deps.dispatch({ type: 'UploadRetried', localId });
|
||||
shell.upload({ localId, categoryId: up.categoryId, wizardId: deps.wizardId, file }, deps.dispatch);
|
||||
shell.upload(
|
||||
{ localId, categoryId: up.categoryId, wizardId: deps.wizardId, file },
|
||||
deps.dispatch,
|
||||
);
|
||||
},
|
||||
onDelete(e: { localId: string; documentId: string }) {
|
||||
shell.delete(e.localId, e.documentId, deps.dispatch);
|
||||
|
||||
@@ -42,16 +42,26 @@ export class UploadShellService {
|
||||
|
||||
/** Start (or retry) an upload: queue → progress → complete/failed. */
|
||||
upload(req: XhrUploadRequest, dispatch: Dispatch): void {
|
||||
dispatch({ type: 'UploadQueued', localId: req.localId, backgroundSync: this.backgroundSyncAvailable });
|
||||
dispatch({
|
||||
type: 'UploadQueued',
|
||||
localId: req.localId,
|
||||
backgroundSync: this.backgroundSyncAvailable,
|
||||
});
|
||||
const { done, cancel } = this.transport.send(req, (pct) =>
|
||||
dispatch({ type: 'UploadProgress', localId: req.localId, progressPct: pct }),
|
||||
);
|
||||
this.inflight.set(req.localId, cancel);
|
||||
done
|
||||
.then(({ documentId }) => dispatch({ type: 'UploadComplete', localId: req.localId, documentId }))
|
||||
.then(({ documentId }) =>
|
||||
dispatch({ type: 'UploadComplete', localId: req.localId, documentId }),
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e !== UPLOAD_ABORTED) {
|
||||
dispatch({ type: 'UploadFailed', localId: req.localId, reason: typeof e === 'string' ? e : '' });
|
||||
dispatch({
|
||||
type: 'UploadFailed',
|
||||
localId: req.localId,
|
||||
reason: typeof e === 'string' ? e : '',
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => this.inflight.delete(req.localId));
|
||||
@@ -63,7 +73,9 @@ export class UploadShellService {
|
||||
this.adapter
|
||||
.deleteDocument(documentId)
|
||||
.then(() => dispatch({ type: 'UploadDeleteComplete', localId }))
|
||||
.catch((e) => dispatch({ type: 'UploadDeleteFailed', localId, reason: problemDetail(e, '') }));
|
||||
.catch((e) =>
|
||||
dispatch({ type: 'UploadDeleteFailed', localId, reason: problemDetail(e, '') }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Cancel any in-flight XHRs (e.g. when a category switches to post-delivery). */
|
||||
|
||||
@@ -56,13 +56,24 @@ export class UploadAdapter {
|
||||
// not on every unrelated draft edit (a computed with `equal` returns the cached
|
||||
// reference while equal, so the resource sees no change).
|
||||
const params = computed(
|
||||
() => ({ wizardId, diplomaHerkomst: extra?.().diplomaHerkomst, taalvaardigheid: extra?.().taalvaardigheid }),
|
||||
{ equal: (a, b) => a.wizardId === b.wizardId && a.diplomaHerkomst === b.diplomaHerkomst && a.taalvaardigheid === b.taalvaardigheid },
|
||||
() => ({
|
||||
wizardId,
|
||||
diplomaHerkomst: extra?.().diplomaHerkomst,
|
||||
taalvaardigheid: extra?.().taalvaardigheid,
|
||||
}),
|
||||
{
|
||||
equal: (a, b) =>
|
||||
a.wizardId === b.wizardId &&
|
||||
a.diplomaHerkomst === b.diplomaHerkomst &&
|
||||
a.taalvaardigheid === b.taalvaardigheid,
|
||||
},
|
||||
);
|
||||
return resource({
|
||||
params,
|
||||
loader: ({ params }) =>
|
||||
this.client.categories(params.wizardId, params.diplomaHerkomst, params.taalvaardigheid).then((r) => (r.categories ?? []).map(toCategory)),
|
||||
this.client
|
||||
.categories(params.wizardId, params.diplomaHerkomst, params.taalvaardigheid)
|
||||
.then((r) => (r.categories ?? []).map(toCategory)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -94,7 +105,8 @@ export class UploadAdapter {
|
||||
*/
|
||||
xhrUpload(req: XhrUploadRequest, onProgress: (pct: number) => void): XhrUploadHandle {
|
||||
const scenario = currentScenario();
|
||||
if (scenario === 'upload-slow' || scenario === 'upload-fail') return simulateUpload(scenario, onProgress);
|
||||
if (scenario === 'upload-slow' || scenario === 'upload-fail')
|
||||
return simulateUpload(scenario, onProgress);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
const form = new FormData();
|
||||
@@ -120,7 +132,9 @@ export class UploadAdapter {
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('error', () => reject(genericError()));
|
||||
xhr.addEventListener('abort', () => (aborted ? reject(UPLOAD_ABORTED) : reject(genericError())));
|
||||
xhr.addEventListener('abort', () =>
|
||||
aborted ? reject(UPLOAD_ABORTED) : reject(genericError()),
|
||||
);
|
||||
});
|
||||
|
||||
xhr.open('POST', `${environment.apiBaseUrl}/api/v1/uploads`);
|
||||
@@ -138,7 +152,10 @@ const genericError = (): string => UPLOAD_FAILED;
|
||||
* succeeds (`upload-slow`) or fails (`upload-fail`). ponytail: timer-based, cancel
|
||||
* via the returned handle; no network.
|
||||
*/
|
||||
function simulateUpload(scenario: 'upload-slow' | 'upload-fail', onProgress: (pct: number) => void): XhrUploadHandle {
|
||||
function simulateUpload(
|
||||
scenario: 'upload-slow' | 'upload-fail',
|
||||
onProgress: (pct: number) => void,
|
||||
): XhrUploadHandle {
|
||||
let pct = 0;
|
||||
let cancelled = false;
|
||||
const done = new Promise<{ documentId: string }>((resolve, reject) => {
|
||||
@@ -182,5 +199,9 @@ function toCategory(c: DocumentCategoryDto): DocumentCategory {
|
||||
}
|
||||
|
||||
function toStatusItem(s: UploadStatusItemDto): StatusItem {
|
||||
return { localId: s.localId ?? '', status: s.status ?? 'unknown', documentId: s.documentId ?? undefined };
|
||||
return {
|
||||
localId: s.localId ?? '',
|
||||
status: s.status ?? 'unknown',
|
||||
documentId: s.documentId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,28 +28,50 @@ const stateWith = (categories: DocumentCategory[], over: Partial<UploadState> =
|
||||
reduceUpload({ ...initialUpload, ...over }, { type: 'CategoriesLoaded', categories });
|
||||
|
||||
const select = (s: UploadState, categoryId: string, localId: string): UploadState =>
|
||||
reduceUpload(s, { type: 'FileSelected', categoryId, localId, fileName: `${localId}.pdf`, fileSizeMb: 1 });
|
||||
reduceUpload(s, {
|
||||
type: 'FileSelected',
|
||||
categoryId,
|
||||
localId,
|
||||
fileName: `${localId}.pdf`,
|
||||
fileSizeMb: 1,
|
||||
});
|
||||
|
||||
const get = (s: UploadState, localId: string): Upload | undefined => s.uploads.find((u) => u.localId === localId);
|
||||
const get = (s: UploadState, localId: string): Upload | undefined =>
|
||||
s.uploads.find((u) => u.localId === localId);
|
||||
|
||||
describe('CategoriesLoaded', () => {
|
||||
it('defaults every category to digital without clobbering existing choices', () => {
|
||||
const s0 = stateWith([cat({ categoryId: 'a' })], { deliveryChannel: { a: 'post' } });
|
||||
expect(s0.deliveryChannel['a']).toBe('post');
|
||||
const s1 = reduceUpload(s0, { type: 'CategoriesLoaded', categories: [cat({ categoryId: 'a' }), cat({ categoryId: 'b' })] });
|
||||
const s1 = reduceUpload(s0, {
|
||||
type: 'CategoriesLoaded',
|
||||
categories: [cat({ categoryId: 'a' }), cat({ categoryId: 'b' })],
|
||||
});
|
||||
expect(s1.deliveryChannel).toEqual({ a: 'post', b: 'digital' });
|
||||
});
|
||||
|
||||
it('clears a prior categoriesError', () => {
|
||||
const s = reduceUpload({ ...initialUpload, categoriesError: 'boom' }, { type: 'CategoriesLoaded', categories: [] });
|
||||
const s = reduceUpload(
|
||||
{ ...initialUpload, categoriesError: 'boom' },
|
||||
{ type: 'CategoriesLoaded', categories: [] },
|
||||
);
|
||||
expect(s.categoriesError).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops uploads + channel choices for categories that disappear', () => {
|
||||
const s0 = select(stateWith([cat({ categoryId: 'diploma' }), cat({ categoryId: 'id' })], { deliveryChannel: { id: 'post' } }), 'diploma', 'u1');
|
||||
const s0 = select(
|
||||
stateWith([cat({ categoryId: 'diploma' }), cat({ categoryId: 'id' })], {
|
||||
deliveryChannel: { id: 'post' },
|
||||
}),
|
||||
'diploma',
|
||||
'u1',
|
||||
);
|
||||
expect(get(s0, 'u1')).toBeDefined();
|
||||
// Reload with the diploma category gone (e.g. DUO chosen).
|
||||
const s1 = reduceUpload(s0, { type: 'CategoriesLoaded', categories: [cat({ categoryId: 'id' })] });
|
||||
const s1 = reduceUpload(s0, {
|
||||
type: 'CategoriesLoaded',
|
||||
categories: [cat({ categoryId: 'id' })],
|
||||
});
|
||||
expect(get(s1, 'u1')).toBeUndefined();
|
||||
expect(s1.deliveryChannel).toEqual({ id: 'post' });
|
||||
});
|
||||
@@ -57,10 +79,15 @@ describe('CategoriesLoaded', () => {
|
||||
|
||||
describe('CategoriesLoadFailed / BackgroundSyncAvailability', () => {
|
||||
it('records the error', () => {
|
||||
expect(reduceUpload(initialUpload, { type: 'CategoriesLoadFailed', reason: 'boom' }).categoriesError).toBe('boom');
|
||||
expect(
|
||||
reduceUpload(initialUpload, { type: 'CategoriesLoadFailed', reason: 'boom' }).categoriesError,
|
||||
).toBe('boom');
|
||||
});
|
||||
it('flips background sync availability', () => {
|
||||
expect(reduceUpload(initialUpload, { type: 'BackgroundSyncAvailability', available: true }).backgroundSyncAvailable).toBe(true);
|
||||
expect(
|
||||
reduceUpload(initialUpload, { type: 'BackgroundSyncAvailability', available: true })
|
||||
.backgroundSyncAvailable,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +123,11 @@ describe('FileSelected', () => {
|
||||
|
||||
describe('FileRejected', () => {
|
||||
it('stores a per-category message', () => {
|
||||
const s = reduceUpload(stateWith([cat()]), { type: 'FileRejected', categoryId: 'diploma', reason: 'size' });
|
||||
const s = reduceUpload(stateWith([cat()]), {
|
||||
type: 'FileRejected',
|
||||
categoryId: 'diploma',
|
||||
reason: 'size',
|
||||
});
|
||||
expect(s.rejections['diploma']).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -134,12 +165,20 @@ describe('delete flow (optimistic, revertible)', () => {
|
||||
};
|
||||
|
||||
it('UploadDeleteRequested keeps the documentId for revert', () => {
|
||||
const s = reduceUpload(completed(), { type: 'UploadDeleteRequested', localId: 'u1', documentId: 'doc1' });
|
||||
const s = reduceUpload(completed(), {
|
||||
type: 'UploadDeleteRequested',
|
||||
localId: 'u1',
|
||||
documentId: 'doc1',
|
||||
});
|
||||
expect(get(s, 'u1')?.status).toEqual({ type: 'deleting', documentId: 'doc1' });
|
||||
});
|
||||
|
||||
it('UploadDeleteComplete removes the upload', () => {
|
||||
let s = reduceUpload(completed(), { type: 'UploadDeleteRequested', localId: 'u1', documentId: 'doc1' });
|
||||
let s = reduceUpload(completed(), {
|
||||
type: 'UploadDeleteRequested',
|
||||
localId: 'u1',
|
||||
documentId: 'doc1',
|
||||
});
|
||||
s = reduceUpload(s, { type: 'UploadDeleteComplete', localId: 'u1' });
|
||||
expect(s.uploads).toHaveLength(0);
|
||||
});
|
||||
@@ -161,13 +200,21 @@ describe('DeliveryChannelChanged', () => {
|
||||
|
||||
it('rejects post for a category that does not allow it', () => {
|
||||
const s = stateWith([cat({ allowPostDelivery: false })]);
|
||||
const next = reduceUpload(s, { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'post' });
|
||||
const next = reduceUpload(s, {
|
||||
type: 'DeliveryChannelChanged',
|
||||
categoryId: 'diploma',
|
||||
channel: 'post',
|
||||
});
|
||||
expect(next.deliveryChannel['diploma']).toBe('digital');
|
||||
});
|
||||
|
||||
it('switching back to digital starts clean', () => {
|
||||
let s = stateWith([cat()], { deliveryChannel: { diploma: 'post' } });
|
||||
s = reduceUpload(s, { type: 'DeliveryChannelChanged', categoryId: 'diploma', channel: 'digital' });
|
||||
s = reduceUpload(s, {
|
||||
type: 'DeliveryChannelChanged',
|
||||
categoryId: 'diploma',
|
||||
channel: 'digital',
|
||||
});
|
||||
expect(s.deliveryChannel['diploma']).toBe('digital');
|
||||
expect(s.uploads).toHaveLength(0);
|
||||
});
|
||||
@@ -203,7 +250,10 @@ describe('satisfaction helpers', () => {
|
||||
});
|
||||
|
||||
it('requiredCategoriesSatisfied ignores optional categories', () => {
|
||||
const s = stateWith([cat({ categoryId: 'req', required: true }), cat({ categoryId: 'opt', required: false })]);
|
||||
const s = stateWith([
|
||||
cat({ categoryId: 'req', required: true }),
|
||||
cat({ categoryId: 'opt', required: false }),
|
||||
]);
|
||||
expect(requiredCategoriesSatisfied(s)).toBe(false);
|
||||
const s2 = select(s, 'req', 'u1');
|
||||
expect(requiredCategoriesSatisfied(s2)).toBe(true);
|
||||
@@ -212,7 +262,9 @@ describe('satisfaction helpers', () => {
|
||||
|
||||
describe('deliveryRefs', () => {
|
||||
it('emits documentId for completed digital uploads and channel for post', () => {
|
||||
let s = stateWith([cat({ categoryId: 'a' }), cat({ categoryId: 'b' })], { deliveryChannel: { b: 'post' } });
|
||||
let s = stateWith([cat({ categoryId: 'a' }), cat({ categoryId: 'b' })], {
|
||||
deliveryChannel: { b: 'post' },
|
||||
});
|
||||
s = select(s, 'a', 'u1');
|
||||
s = reduceUpload(s, { type: 'UploadComplete', localId: 'u1', documentId: 'doc1' });
|
||||
expect(deliveryRefs(s)).toEqual([
|
||||
@@ -229,16 +281,27 @@ describe('deliveryRefs', () => {
|
||||
|
||||
describe('rejectReason', () => {
|
||||
it('rejects a disallowed type', () => {
|
||||
expect(rejectReason(cat({ acceptedTypes: ['application/pdf'] }), { type: 'image/png', sizeMb: 1 })).toBe('type');
|
||||
expect(
|
||||
rejectReason(cat({ acceptedTypes: ['application/pdf'] }), { type: 'image/png', sizeMb: 1 }),
|
||||
).toBe('type');
|
||||
});
|
||||
it('rejects an oversized file', () => {
|
||||
expect(rejectReason(cat({ maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 11 })).toBe('size');
|
||||
expect(rejectReason(cat({ maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 11 })).toBe(
|
||||
'size',
|
||||
);
|
||||
});
|
||||
it('accepts a valid file', () => {
|
||||
expect(rejectReason(cat({ acceptedTypes: ['application/pdf'], maxSizeMb: 10 }), { type: 'application/pdf', sizeMb: 1 })).toBeNull();
|
||||
expect(
|
||||
rejectReason(cat({ acceptedTypes: ['application/pdf'], maxSizeMb: 10 }), {
|
||||
type: 'application/pdf',
|
||||
sizeMb: 1,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
it('allows any type when the category lists none', () => {
|
||||
expect(rejectReason(cat({ acceptedTypes: [], maxSizeMb: 10 }), { type: 'image/png', sizeMb: 1 })).toBeNull();
|
||||
expect(
|
||||
rejectReason(cat({ acceptedTypes: [], maxSizeMb: 10 }), { type: 'image/png', sizeMb: 1 }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -249,6 +312,10 @@ describe('inFlight', () => {
|
||||
s = reduceUpload(s, { type: 'UploadProgress', localId: 'u2', progressPct: 10 });
|
||||
s = select(s, 'diploma', 'u3');
|
||||
s = reduceUpload(s, { type: 'UploadComplete', localId: 'u3', documentId: 'doc3' });
|
||||
expect(inFlight(s).map((u) => u.localId).sort()).toEqual(['u1', 'u2']);
|
||||
expect(
|
||||
inFlight(s)
|
||||
.map((u) => u.localId)
|
||||
.sort(),
|
||||
).toEqual(['u1', 'u2']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,13 @@ export type UploadMsg =
|
||||
| { type: 'CategoriesLoaded'; categories: DocumentCategory[] }
|
||||
| { type: 'CategoriesLoadFailed'; reason: string }
|
||||
| { type: 'BackgroundSyncAvailability'; available: boolean }
|
||||
| { type: 'FileSelected'; categoryId: string; localId: string; fileName: string; fileSizeMb: number }
|
||||
| {
|
||||
type: 'FileSelected';
|
||||
categoryId: string;
|
||||
localId: string;
|
||||
fileName: string;
|
||||
fileSizeMb: number;
|
||||
}
|
||||
| { type: 'FileRejected'; categoryId: string; reason: 'type' | 'size' | 'multiple' }
|
||||
| { type: 'UploadQueued'; localId: string; backgroundSync: boolean }
|
||||
| { type: 'UploadProgress'; localId: string; progressPct: number }
|
||||
@@ -75,7 +81,12 @@ export type UploadMsg =
|
||||
| { type: 'DeliveryChannelChanged'; categoryId: string; channel: DeliveryChannel }
|
||||
| {
|
||||
type: 'BackgroundUploadsReturned';
|
||||
results: Array<{ localId: string } & ({ success: true; documentId: string } | { success: false; reason: string })>;
|
||||
results: Array<
|
||||
{ localId: string } & (
|
||||
| { success: true; documentId: string }
|
||||
| { success: false; reason: string }
|
||||
)
|
||||
>;
|
||||
};
|
||||
|
||||
const REJECTION_MESSAGES: Record<'type' | 'size' | 'multiple', string> = {
|
||||
@@ -100,7 +111,10 @@ export function requiredCategoriesSatisfied(s: UploadState): boolean {
|
||||
* FE format-validation (never authority — the server re-validates). Returns the
|
||||
* rejection reason for one file, or null if it passes the category's type/size.
|
||||
*/
|
||||
export function rejectReason(cat: DocumentCategory, file: { type: string; sizeMb: number }): 'type' | 'size' | null {
|
||||
export function rejectReason(
|
||||
cat: DocumentCategory,
|
||||
file: { type: string; sizeMb: number },
|
||||
): 'type' | 'size' | null {
|
||||
if (cat.acceptedTypes.length > 0 && !cat.acceptedTypes.includes(file.type)) return 'type';
|
||||
if (cat.maxSizeMb > 0 && file.sizeMb > cat.maxSizeMb) return 'size';
|
||||
return null;
|
||||
@@ -112,7 +126,8 @@ function mapUpload(s: UploadState, localId: string, f: (u: Upload) => Upload): U
|
||||
}
|
||||
|
||||
const find = (s: UploadState, localId: string) => s.uploads.find((u) => u.localId === localId);
|
||||
const categoryOf = (s: UploadState, categoryId: string) => s.categories.find((c) => c.categoryId === categoryId);
|
||||
const categoryOf = (s: UploadState, categoryId: string) =>
|
||||
s.categories.find((c) => c.categoryId === categoryId);
|
||||
|
||||
export function reduceUpload(s: UploadState, m: UploadMsg): UploadState {
|
||||
switch (m.type) {
|
||||
@@ -121,9 +136,16 @@ export function reduceUpload(s: UploadState, m: UploadMsg): UploadState {
|
||||
// DUO is chosen). Drop uploads + channel choices for categories no longer present.
|
||||
const ids = new Set(m.categories.map((c) => c.categoryId));
|
||||
const deliveryChannel: Record<string, DeliveryChannel> = {};
|
||||
for (const c of m.categories) deliveryChannel[c.categoryId] = s.deliveryChannel[c.categoryId] ?? 'digital';
|
||||
for (const c of m.categories)
|
||||
deliveryChannel[c.categoryId] = s.deliveryChannel[c.categoryId] ?? 'digital';
|
||||
const uploads = s.uploads.filter((u) => ids.has(u.categoryId));
|
||||
return { ...s, categories: m.categories, uploads, deliveryChannel, categoriesError: undefined };
|
||||
return {
|
||||
...s,
|
||||
categories: m.categories,
|
||||
uploads,
|
||||
deliveryChannel,
|
||||
categoriesError: undefined,
|
||||
};
|
||||
}
|
||||
case 'CategoriesLoadFailed':
|
||||
return { ...s, categoriesError: m.reason };
|
||||
@@ -135,7 +157,9 @@ export function reduceUpload(s: UploadState, m: UploadMsg): UploadState {
|
||||
if (!categoryOf(s, m.categoryId) || s.deliveryChannel[m.categoryId] === 'post') return s;
|
||||
const cat = categoryOf(s, m.categoryId)!;
|
||||
// Single-file categories: a new selection replaces any existing upload.
|
||||
const uploads = cat.multiple ? s.uploads : s.uploads.filter((u) => u.categoryId !== m.categoryId);
|
||||
const uploads = cat.multiple
|
||||
? s.uploads
|
||||
: s.uploads.filter((u) => u.categoryId !== m.categoryId);
|
||||
const next: Upload = {
|
||||
localId: m.localId,
|
||||
categoryId: m.categoryId,
|
||||
@@ -148,16 +172,32 @@ export function reduceUpload(s: UploadState, m: UploadMsg): UploadState {
|
||||
return { ...s, uploads: [...uploads, next], rejections };
|
||||
}
|
||||
case 'FileRejected':
|
||||
return { ...s, rejections: { ...s.rejections, [m.categoryId]: REJECTION_MESSAGES[m.reason] } };
|
||||
return {
|
||||
...s,
|
||||
rejections: { ...s.rejections, [m.categoryId]: REJECTION_MESSAGES[m.reason] },
|
||||
};
|
||||
|
||||
case 'UploadQueued':
|
||||
return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'queued' }, backgroundSync: m.backgroundSync }));
|
||||
return mapUpload(s, m.localId, (u) => ({
|
||||
...u,
|
||||
status: { type: 'queued' },
|
||||
backgroundSync: m.backgroundSync,
|
||||
}));
|
||||
case 'UploadProgress':
|
||||
return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'uploading', progressPct: m.progressPct } }));
|
||||
return mapUpload(s, m.localId, (u) => ({
|
||||
...u,
|
||||
status: { type: 'uploading', progressPct: m.progressPct },
|
||||
}));
|
||||
case 'UploadComplete':
|
||||
return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'complete', documentId: m.documentId } }));
|
||||
return mapUpload(s, m.localId, (u) => ({
|
||||
...u,
|
||||
status: { type: 'complete', documentId: m.documentId },
|
||||
}));
|
||||
case 'UploadFailed':
|
||||
return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'failed', reason: m.reason } }));
|
||||
return mapUpload(s, m.localId, (u) => ({
|
||||
...u,
|
||||
status: { type: 'failed', reason: m.reason },
|
||||
}));
|
||||
case 'UploadRetried':
|
||||
return mapUpload(s, m.localId, (u) => ({ ...u, status: { type: 'queued' } }));
|
||||
|
||||
@@ -168,15 +208,22 @@ export function reduceUpload(s: UploadState, m: UploadMsg): UploadState {
|
||||
case 'UploadDeleting':
|
||||
// Both mark the in-flight delete; keep the documentId so a failure can revert.
|
||||
return mapUpload(s, m.localId, (u) => {
|
||||
const documentId = u.status.type === 'complete' ? u.status.documentId
|
||||
: u.status.type === 'deleting' ? u.status.documentId : '';
|
||||
const documentId =
|
||||
u.status.type === 'complete'
|
||||
? u.status.documentId
|
||||
: u.status.type === 'deleting'
|
||||
? u.status.documentId
|
||||
: '';
|
||||
return { ...u, status: { type: 'deleting', documentId } };
|
||||
});
|
||||
case 'UploadDeleteComplete':
|
||||
return { ...s, uploads: s.uploads.filter((u) => u.localId !== m.localId) };
|
||||
case 'UploadDeleteFailed':
|
||||
return mapUpload(s, m.localId, (u) =>
|
||||
u.status.type === 'deleting' ? { ...u, status: { type: 'complete', documentId: u.status.documentId } } : u);
|
||||
u.status.type === 'deleting'
|
||||
? { ...u, status: { type: 'complete', documentId: u.status.documentId } }
|
||||
: u,
|
||||
);
|
||||
|
||||
case 'DeliveryChannelChanged': {
|
||||
const cat = categoryOf(s, m.categoryId);
|
||||
@@ -193,7 +240,9 @@ export function reduceUpload(s: UploadState, m: UploadMsg): UploadState {
|
||||
for (const r of m.results) {
|
||||
next = mapUpload(next, r.localId, (u) => ({
|
||||
...u,
|
||||
status: r.success ? { type: 'complete', documentId: r.documentId } : { type: 'failed', reason: r.reason },
|
||||
status: r.success
|
||||
? { type: 'complete', documentId: r.documentId }
|
||||
: { type: 'failed', reason: r.reason },
|
||||
}));
|
||||
}
|
||||
return next;
|
||||
@@ -205,7 +254,9 @@ export function reduceUpload(s: UploadState, m: UploadMsg): UploadState {
|
||||
}
|
||||
|
||||
/** The submit payload fragment: digital docs carry their documentId, post categories the channel. */
|
||||
export function deliveryRefs(s: UploadState): Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }> {
|
||||
export function deliveryRefs(
|
||||
s: UploadState,
|
||||
): Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }> {
|
||||
const refs: Array<{ categoryId: string; channel: DeliveryChannel; documentId?: string }> = [];
|
||||
for (const c of s.categories) {
|
||||
if (s.deliveryChannel[c.categoryId] === 'post') {
|
||||
@@ -213,7 +264,11 @@ export function deliveryRefs(s: UploadState): Array<{ categoryId: string; channe
|
||||
} else {
|
||||
for (const u of s.uploads) {
|
||||
if (u.categoryId === c.categoryId && u.status.type === 'complete') {
|
||||
refs.push({ categoryId: c.categoryId, channel: 'digital', documentId: u.status.documentId });
|
||||
refs.push({
|
||||
categoryId: c.categoryId,
|
||||
channel: 'digital',
|
||||
documentId: u.status.documentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user