@aiaiai-pt/design-system 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/ActionFormRenderer.svelte +61 -3
- package/components/ActionFormRenderer.svelte.d.ts +12 -0
- package/components/ContrastToggle.svelte +36 -0
- package/components/ContrastToggle.svelte.d.ts +30 -0
- package/components/FileUpload.svelte +14 -10
- package/components/LinkHighlightToggle.svelte +37 -0
- package/components/LinkHighlightToggle.svelte.d.ts +31 -0
- package/components/MapCluster.svelte +39 -1
- package/components/MapCluster.svelte.d.ts +4 -0
- package/components/MapDisplay.svelte +38 -4
- package/components/MapHeatmap.svelte +48 -1
- package/components/MapHeatmap.svelte.d.ts +2 -0
- package/components/MapPicker.svelte +59 -12
- package/components/TextSizeAdjuster.svelte +150 -0
- package/components/TextSizeAdjuster.svelte.d.ts +42 -0
- package/components/index.js +3 -0
- package/components/text-size.d.ts +50 -0
- package/components/text-size.js +72 -0
- package/package.json +1 -1
- package/tokens/semantic.css +75 -0
- package/tokens/themes/valongo.css +28 -1
|
@@ -73,6 +73,18 @@
|
|
|
73
73
|
* in submit modes. */
|
|
74
74
|
captcha?: Snippet;
|
|
75
75
|
submitLabel?: string;
|
|
76
|
+
/** Forwarded VERBATIM to every `geo` parameter's MapPicker (the renderer
|
|
77
|
+
* stays generic — boundary/overlay semantics live in the consumer):
|
|
78
|
+
* `boundary` draws the dashed tenant-boundary overlay and arms the
|
|
79
|
+
* out-of-bounds check; `layers` are ordered GeoJSON overlays (unbounded);
|
|
80
|
+
* `onoutofbounds(outside, coords)` fires on every point placement when a
|
|
81
|
+
* boundary is set — NON-blocking, the consumer owns surfacing/gating. */
|
|
82
|
+
boundary?: unknown;
|
|
83
|
+
layers?: unknown[];
|
|
84
|
+
onoutofbounds?: (outside: boolean, coords: [number, number]) => void;
|
|
85
|
+
/** Inline error rendered by the geo MapPicker(s) (e.g. the consumer's
|
|
86
|
+
* out-of-bounds copy) — forwarded as MapPicker's `error`. */
|
|
87
|
+
geoError?: string;
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
let {
|
|
@@ -86,6 +98,10 @@
|
|
|
86
98
|
uploadFile = undefined,
|
|
87
99
|
captcha = undefined,
|
|
88
100
|
submitLabel = "Submit",
|
|
101
|
+
boundary = undefined,
|
|
102
|
+
layers = [],
|
|
103
|
+
onoutofbounds = undefined,
|
|
104
|
+
geoError = undefined,
|
|
89
105
|
}: Props = $props();
|
|
90
106
|
|
|
91
107
|
let values = $state<Record<string, unknown>>({});
|
|
@@ -381,6 +397,11 @@
|
|
|
381
397
|
{@const parameter = rawParameter as Entity}
|
|
382
398
|
{@const key = parameterKey(parameter)}
|
|
383
399
|
{@const type = parameterType(parameter)}
|
|
400
|
+
<!-- A11y (#244 C7 / Selo item 4): required fields are marked
|
|
401
|
+
`aria-required` on the control itself, so a screen reader announces
|
|
402
|
+
"required" on the field — not just the disconnected visual hint below.
|
|
403
|
+
Passed through the DS field components' `{...rest}` onto the input. -->
|
|
404
|
+
{@const ariaRequired = parameter.required ? "true" : undefined}
|
|
384
405
|
{#if type === "enum" || type === "select" || enumOptions(parameter).length}
|
|
385
406
|
<Select
|
|
386
407
|
label={String(parameter.label ?? key)}
|
|
@@ -389,6 +410,7 @@
|
|
|
389
410
|
options={enumOptions(parameter)}
|
|
390
411
|
placeholder="Select value"
|
|
391
412
|
onchange={(value: string) => setValue(key, value)}
|
|
413
|
+
aria-required={ariaRequired}
|
|
392
414
|
/>
|
|
393
415
|
{:else if type === "number" || type === "integer"}
|
|
394
416
|
<Input
|
|
@@ -400,6 +422,7 @@
|
|
|
400
422
|
const value = (event.target as HTMLInputElement).value;
|
|
401
423
|
setValue(key, value === "" ? "" : Number(value));
|
|
402
424
|
}}
|
|
425
|
+
aria-required={ariaRequired}
|
|
403
426
|
/>
|
|
404
427
|
{:else if type === "bool" || type === "boolean"}
|
|
405
428
|
<Select
|
|
@@ -411,12 +434,20 @@
|
|
|
411
434
|
{ value: "false", label: "No" },
|
|
412
435
|
]}
|
|
413
436
|
onchange={(value: string) => setValue(key, value === "true")}
|
|
437
|
+
aria-required={ariaRequired}
|
|
414
438
|
/>
|
|
415
439
|
{:else if type === "file"}
|
|
416
440
|
<!-- `file` parameter (#75 M5 slice 4b): upload-as-you-attach. The keys
|
|
417
441
|
ride payload.attachment_keys; raw_values never sees this param. -->
|
|
418
|
-
|
|
419
|
-
|
|
442
|
+
<!-- A11y: the file param is a labelled group (the FileUpload's own input
|
|
443
|
+
can't take a `for`/label from here), so SR users get the field name +
|
|
444
|
+
required state when they enter it. -->
|
|
445
|
+
<div class="afr-file-param" role="group" aria-labelledby={`${key}-file-label`}>
|
|
446
|
+
<span id={`${key}-file-label`} class="afr-file-label"
|
|
447
|
+
>{String(parameter.label ?? key)}{parameter.required
|
|
448
|
+
? " (required)"
|
|
449
|
+
: ""}</span
|
|
450
|
+
>
|
|
420
451
|
{#each fileUploads[key] ?? [] as f (f.key)}
|
|
421
452
|
<FileUploadItem
|
|
422
453
|
name={f.name}
|
|
@@ -451,6 +482,10 @@
|
|
|
451
482
|
<MapPicker
|
|
452
483
|
mode="point"
|
|
453
484
|
height="20rem"
|
|
485
|
+
{boundary}
|
|
486
|
+
{layers}
|
|
487
|
+
{onoutofbounds}
|
|
488
|
+
error={geoError}
|
|
454
489
|
label={String(parameter.label ?? key)}
|
|
455
490
|
center={Array.isArray(parameter.default_value)
|
|
456
491
|
? (parameter.default_value as [number, number])
|
|
@@ -471,6 +506,7 @@
|
|
|
471
506
|
name={key}
|
|
472
507
|
value={String(values[key] ?? "")}
|
|
473
508
|
oninput={(event: Event) => setValue(key, (event.target as HTMLInputElement).value)}
|
|
509
|
+
aria-required={ariaRequired}
|
|
474
510
|
/>
|
|
475
511
|
{/if}
|
|
476
512
|
{#if mode === "public-submit"}
|
|
@@ -506,7 +542,14 @@
|
|
|
506
542
|
<p class="muted">This action has no fields yet.</p>
|
|
507
543
|
{:else}
|
|
508
544
|
{@const Layout = resolvedLayout.component}
|
|
509
|
-
|
|
545
|
+
<!-- A11y: name the form region so SR users land on a labelled form, not an
|
|
546
|
+
anonymous group of inputs (Selo item 4 / WCAG 1.3.1). -->
|
|
547
|
+
<form
|
|
548
|
+
class="rendered-form"
|
|
549
|
+
aria-label={String(
|
|
550
|
+
renderedAction?.label ?? renderedAction?.key ?? "Form",
|
|
551
|
+
)}
|
|
552
|
+
>
|
|
510
553
|
<Layout {sections} field={fieldRow} />
|
|
511
554
|
</form>
|
|
512
555
|
{/if}
|
|
@@ -524,6 +567,15 @@
|
|
|
524
567
|
{#if captcha}
|
|
525
568
|
<div class="submit-captcha">{@render captcha()}</div>
|
|
526
569
|
{/if}
|
|
570
|
+
<!-- A11y: a disabled submit gives no reason on its own. This polite
|
|
571
|
+
status spells out the gate (and disappears when satisfied), so SR
|
|
572
|
+
users know WHY they can't submit yet — paired with the per-field
|
|
573
|
+
`aria-required` that marks which fields are needed. -->
|
|
574
|
+
{#if !canSubmit && !submitting}
|
|
575
|
+
<p class="submit-hint" role="status">
|
|
576
|
+
Fill in all required fields to submit.
|
|
577
|
+
</p>
|
|
578
|
+
{/if}
|
|
527
579
|
<Button
|
|
528
580
|
variant="primary"
|
|
529
581
|
loading={submitting}
|
|
@@ -622,6 +674,12 @@
|
|
|
622
674
|
color: var(--color-text-muted);
|
|
623
675
|
}
|
|
624
676
|
|
|
677
|
+
.submit-hint {
|
|
678
|
+
color: var(--color-text-secondary);
|
|
679
|
+
font-size: var(--type-body-sm-size);
|
|
680
|
+
margin: 0;
|
|
681
|
+
}
|
|
682
|
+
|
|
625
683
|
.rendered-form,
|
|
626
684
|
.admin-preview,
|
|
627
685
|
.preview-block,
|
|
@@ -58,6 +58,18 @@ interface Props {
|
|
|
58
58
|
* in submit modes. */
|
|
59
59
|
captcha?: Snippet;
|
|
60
60
|
submitLabel?: string;
|
|
61
|
+
/** Forwarded VERBATIM to every `geo` parameter's MapPicker (the renderer
|
|
62
|
+
* stays generic — boundary/overlay semantics live in the consumer):
|
|
63
|
+
* `boundary` draws the dashed tenant-boundary overlay and arms the
|
|
64
|
+
* out-of-bounds check; `layers` are ordered GeoJSON overlays (unbounded);
|
|
65
|
+
* `onoutofbounds(outside, coords)` fires on every point placement when a
|
|
66
|
+
* boundary is set — NON-blocking, the consumer owns surfacing/gating. */
|
|
67
|
+
boundary?: unknown;
|
|
68
|
+
layers?: unknown[];
|
|
69
|
+
onoutofbounds?: (outside: boolean, coords: [number, number]) => void;
|
|
70
|
+
/** Inline error rendered by the geo MapPicker(s) (e.g. the consumer's
|
|
71
|
+
* out-of-bounds copy) — forwarded as MapPicker's `error`. */
|
|
72
|
+
geoError?: string;
|
|
61
73
|
}
|
|
62
74
|
declare const ActionFormRenderer: import("svelte").Component<Props, {}, "">;
|
|
63
75
|
type ActionFormRenderer = ReturnType<typeof ActionFormRenderer>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component ContrastToggle
|
|
3
|
+
|
|
4
|
+
Citizen high-contrast preference (#244 C7 accessibility pack). A single on/off
|
|
5
|
+
switch: "on" asks for a high-contrast rendering (max text/surface separation,
|
|
6
|
+
stronger borders, underlined links) on top of the active light/dark scheme.
|
|
7
|
+
|
|
8
|
+
Presentational, mirrors the scheme/motion controls: it reports the new value
|
|
9
|
+
via `onchange`; the consumer persists it (cookie) and applies the
|
|
10
|
+
`:root[data-contrast="high"]` layer (tokens/semantic.css) server-side, so SSR
|
|
11
|
+
paints it with no flash. The label is a prop for i18n (default English).
|
|
12
|
+
|
|
13
|
+
@example
|
|
14
|
+
<ContrastToggle high={prefs.contrast === "high"} onchange={(v) => setContrast(v)} />
|
|
15
|
+
-->
|
|
16
|
+
<script>
|
|
17
|
+
import Toggle from "./Toggle.svelte";
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
/** @type {boolean} Whether high-contrast is currently on. */
|
|
21
|
+
high = false,
|
|
22
|
+
/** @type {((high: boolean) => void) | undefined} */
|
|
23
|
+
onchange = undefined,
|
|
24
|
+
/** @type {string} Accessible label (i18n). */
|
|
25
|
+
label = "High contrast",
|
|
26
|
+
...rest
|
|
27
|
+
} = $props();
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<Toggle
|
|
31
|
+
checked={high}
|
|
32
|
+
{label}
|
|
33
|
+
{onchange}
|
|
34
|
+
data-testid="contrast-toggle"
|
|
35
|
+
{...rest}
|
|
36
|
+
/>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export default ContrastToggle;
|
|
2
|
+
type ContrastToggle = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* ContrastToggle
|
|
8
|
+
*
|
|
9
|
+
* Citizen high-contrast preference (#244 C7 accessibility pack). A single on/off
|
|
10
|
+
* switch: "on" asks for a high-contrast rendering (max text/surface separation,
|
|
11
|
+
* stronger borders, underlined links) on top of the active light/dark scheme.
|
|
12
|
+
*
|
|
13
|
+
* Presentational, mirrors the scheme/motion controls: it reports the new value
|
|
14
|
+
* via `onchange`; the consumer persists it (cookie) and applies the
|
|
15
|
+
* `:root[data-contrast="high"]` layer (tokens/semantic.css) server-side, so SSR
|
|
16
|
+
* paints it with no flash. The label is a prop for i18n (default English).
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* <ContrastToggle high={prefs.contrast === "high"} onchange={(v) => setContrast(v)} />
|
|
20
|
+
*/
|
|
21
|
+
declare const ContrastToggle: import("svelte").Component<{
|
|
22
|
+
high?: boolean;
|
|
23
|
+
onchange?: any;
|
|
24
|
+
label?: string;
|
|
25
|
+
} & Record<string, any>, {}, "">;
|
|
26
|
+
type $$ComponentProps = {
|
|
27
|
+
high?: boolean;
|
|
28
|
+
onchange?: any;
|
|
29
|
+
label?: string;
|
|
30
|
+
} & Record<string, any>;
|
|
@@ -158,6 +158,20 @@
|
|
|
158
158
|
}
|
|
159
159
|
</script>
|
|
160
160
|
|
|
161
|
+
<!-- The file input is a SIBLING of the trigger button, not a child: a focusable
|
|
162
|
+
<input> nested inside a <button> is a nested-interactive a11y violation
|
|
163
|
+
(axe, #244 S5). It is visually hidden + pointer-events:none and is opened
|
|
164
|
+
via the `inputEl` ref from the button's onclick, so behaviour is unchanged. -->
|
|
165
|
+
<input
|
|
166
|
+
bind:this={inputEl}
|
|
167
|
+
type="file"
|
|
168
|
+
{accept}
|
|
169
|
+
{multiple}
|
|
170
|
+
class="fileupload-input"
|
|
171
|
+
onchange={handleInputChange}
|
|
172
|
+
tabindex={-1}
|
|
173
|
+
aria-hidden="true"
|
|
174
|
+
/>
|
|
161
175
|
<button
|
|
162
176
|
type="button"
|
|
163
177
|
class="fileupload {className}"
|
|
@@ -171,16 +185,6 @@
|
|
|
171
185
|
{...rest}
|
|
172
186
|
onclick={handleClick}
|
|
173
187
|
>
|
|
174
|
-
<input
|
|
175
|
-
bind:this={inputEl}
|
|
176
|
-
type="file"
|
|
177
|
-
{accept}
|
|
178
|
-
{multiple}
|
|
179
|
-
class="fileupload-input"
|
|
180
|
-
onchange={handleInputChange}
|
|
181
|
-
tabindex={-1}
|
|
182
|
-
aria-hidden="true"
|
|
183
|
-
/>
|
|
184
188
|
{#if children}
|
|
185
189
|
{@render children()}
|
|
186
190
|
{:else}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component LinkHighlightToggle
|
|
3
|
+
|
|
4
|
+
Citizen "underline links" preference (#244 C7 accessibility pack). A single
|
|
5
|
+
on/off switch: "on" forces a visible underline on every in-content link, so
|
|
6
|
+
links are distinguishable by more than colour (WCAG 1.4.1 Use of Colour /
|
|
7
|
+
Selo item 6 — hyperlinks should not rely on colour alone).
|
|
8
|
+
|
|
9
|
+
Presentational, mirrors the scheme/motion controls: it reports the new value
|
|
10
|
+
via `onchange`; the consumer persists it (cookie) and applies the
|
|
11
|
+
`:root[data-link-highlight="on"]` layer (tokens/semantic.css) server-side, so
|
|
12
|
+
SSR paints it with no flash. The label is a prop for i18n (default English).
|
|
13
|
+
|
|
14
|
+
@example
|
|
15
|
+
<LinkHighlightToggle on={prefs.linkHighlight} onchange={(v) => setLinkHighlight(v)} />
|
|
16
|
+
-->
|
|
17
|
+
<script>
|
|
18
|
+
import Toggle from "./Toggle.svelte";
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
/** @type {boolean} Whether link-underlining is currently on. */
|
|
22
|
+
on = false,
|
|
23
|
+
/** @type {((on: boolean) => void) | undefined} */
|
|
24
|
+
onchange = undefined,
|
|
25
|
+
/** @type {string} Accessible label (i18n). */
|
|
26
|
+
label = "Underline links",
|
|
27
|
+
...rest
|
|
28
|
+
} = $props();
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<Toggle
|
|
32
|
+
checked={on}
|
|
33
|
+
{label}
|
|
34
|
+
{onchange}
|
|
35
|
+
data-testid="link-highlight-toggle"
|
|
36
|
+
{...rest}
|
|
37
|
+
/>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export default LinkHighlightToggle;
|
|
2
|
+
type LinkHighlightToggle = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* LinkHighlightToggle
|
|
8
|
+
*
|
|
9
|
+
* Citizen "underline links" preference (#244 C7 accessibility pack). A single
|
|
10
|
+
* on/off switch: "on" forces a visible underline on every in-content link, so
|
|
11
|
+
* links are distinguishable by more than colour (WCAG 1.4.1 Use of Colour /
|
|
12
|
+
* Selo item 6 — hyperlinks should not rely on colour alone).
|
|
13
|
+
*
|
|
14
|
+
* Presentational, mirrors the scheme/motion controls: it reports the new value
|
|
15
|
+
* via `onchange`; the consumer persists it (cookie) and applies the
|
|
16
|
+
* `:root[data-link-highlight="on"]` layer (tokens/semantic.css) server-side, so
|
|
17
|
+
* SSR paints it with no flash. The label is a prop for i18n (default English).
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* <LinkHighlightToggle on={prefs.linkHighlight} onchange={(v) => setLinkHighlight(v)} />
|
|
21
|
+
*/
|
|
22
|
+
declare const LinkHighlightToggle: import("svelte").Component<{
|
|
23
|
+
on?: boolean;
|
|
24
|
+
onchange?: any;
|
|
25
|
+
label?: string;
|
|
26
|
+
} & Record<string, any>, {}, "">;
|
|
27
|
+
type $$ComponentProps = {
|
|
28
|
+
on?: boolean;
|
|
29
|
+
onchange?: any;
|
|
30
|
+
label?: string;
|
|
31
|
+
} & Record<string, any>;
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
<script>
|
|
16
16
|
import { fromLonLat } from 'ol/proj.js';
|
|
17
17
|
import { boundingExtent } from 'ol/extent.js';
|
|
18
|
-
import { createTileLayer, createMapStyles, watchTheme, renderMapError } from './map-utils.js';
|
|
18
|
+
import { createTileLayer, createMapStyles, createOverlayLayers, watchTheme, renderMapError } from './map-utils.js';
|
|
19
19
|
|
|
20
20
|
let {
|
|
21
21
|
/** @type {{ id: string, lon: number, lat: number, label?: string, [key: string]: any }[]} */
|
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
distance = 40,
|
|
32
32
|
/** @type {import('./map-utils.js').TileSourceConfig} */
|
|
33
33
|
tileSource = { type: 'osm' },
|
|
34
|
+
/** @type {import('./map-utils.js').OverlayLayerDef[]} — ordered GeoJSON
|
|
35
|
+
* overlays rendered between the tiles and the cluster layer. Each
|
|
36
|
+
* entry: inline `data` or a `url` (e.g. the platform's
|
|
37
|
+
* `/{app}/public/layers/{id}/features`), optional flat or GeoStyler
|
|
38
|
+
* `style`. Unbounded — render as many as the consumer configures. */
|
|
39
|
+
layers = [],
|
|
34
40
|
/** @type {((marker: { id: string, lon: number, lat: number, label?: string }) => void) | undefined} */
|
|
35
41
|
onclick = undefined,
|
|
36
42
|
/** @type {string} */
|
|
@@ -50,11 +56,40 @@
|
|
|
50
56
|
let _vectorSource = $state();
|
|
51
57
|
/** @type {any} — Cluster source */
|
|
52
58
|
let _clusterSource = $state();
|
|
59
|
+
/** @type {any} — shared style factory (set at mount, consumed by the
|
|
60
|
+
* overlay owner effect) */
|
|
61
|
+
let _styles = $state();
|
|
53
62
|
/** @type {any} — Feature constructor */
|
|
54
63
|
let _Feature;
|
|
55
64
|
/** @type {any} — Point constructor */
|
|
56
65
|
let _Point;
|
|
57
66
|
|
|
67
|
+
// The `layers` overlays have ONE owner: this effect (same contract as
|
|
68
|
+
// MapPicker's boundary). Consumers typically RESOLVE overlay defs
|
|
69
|
+
// asynchronously (layer codes → url defs, after a fetch), so a
|
|
70
|
+
// mount-time-only build silently drops them — the overlays must track
|
|
71
|
+
// `layers` for as long as the map lives. The sequence counter drops
|
|
72
|
+
// stale async builds when the defs change mid-flight.
|
|
73
|
+
/** @type {any[]} */
|
|
74
|
+
let _overlayLayers = [];
|
|
75
|
+
let _overlaySeq = 0;
|
|
76
|
+
$effect(() => {
|
|
77
|
+
const defs = layers;
|
|
78
|
+
const map = _map;
|
|
79
|
+
const styles = _styles;
|
|
80
|
+
if (!map || !styles) return;
|
|
81
|
+
const seq = ++_overlaySeq;
|
|
82
|
+
void (async () => {
|
|
83
|
+
const built = defs?.length ? await createOverlayLayers(defs, styles) : [];
|
|
84
|
+
if (seq !== _overlaySeq || _map !== map) return;
|
|
85
|
+
for (const l of _overlayLayers) map.removeLayer(l);
|
|
86
|
+
_overlayLayers = built;
|
|
87
|
+
// Between the tiles (index 0) and the cluster layer — overlays never
|
|
88
|
+
// cover the markers.
|
|
89
|
+
built.forEach((l, i) => map.getLayers().insertAt(1 + i, l));
|
|
90
|
+
})();
|
|
91
|
+
});
|
|
92
|
+
|
|
58
93
|
// Reactive: animate when the CALLER's center/zoom props change. Guarded
|
|
59
94
|
// against the mount-time run — with no explicit `center` the view was
|
|
60
95
|
// initialised to the markers' extent/mean, and animating to the prop
|
|
@@ -135,6 +170,7 @@
|
|
|
135
170
|
// Store refs for reactive effects
|
|
136
171
|
_vectorSource = vectorSource;
|
|
137
172
|
_clusterSource = clusterSource;
|
|
173
|
+
_styles = styles;
|
|
138
174
|
_Feature = Feature;
|
|
139
175
|
_Point = Point;
|
|
140
176
|
|
|
@@ -186,6 +222,8 @@
|
|
|
186
222
|
|
|
187
223
|
map = new OlMap({
|
|
188
224
|
target: container,
|
|
225
|
+
// The `layers` overlays are NOT built here — the reactive owner
|
|
226
|
+
// effect above inserts them between the tiles and the cluster layer.
|
|
189
227
|
layers: [tileLayer, clusterLayer],
|
|
190
228
|
overlays: [tooltipOverlay],
|
|
191
229
|
view: new View({
|
|
@@ -23,7 +23,9 @@ declare const MapCluster: import("svelte").Component<{
|
|
|
23
23
|
zoom?: number;
|
|
24
24
|
distance?: number;
|
|
25
25
|
tileSource?: Record<string, any>;
|
|
26
|
+
layers?: any[];
|
|
26
27
|
onclick?: any;
|
|
28
|
+
popup?: any;
|
|
27
29
|
height?: string;
|
|
28
30
|
class?: string;
|
|
29
31
|
} & Record<string, any>, {}, "">;
|
|
@@ -33,7 +35,9 @@ type $$ComponentProps = {
|
|
|
33
35
|
zoom?: number;
|
|
34
36
|
distance?: number;
|
|
35
37
|
tileSource?: Record<string, any>;
|
|
38
|
+
layers?: any[];
|
|
36
39
|
onclick?: any;
|
|
40
|
+
popup?: any;
|
|
37
41
|
height?: string;
|
|
38
42
|
class?: string;
|
|
39
43
|
} & Record<string, any>;
|
|
@@ -41,6 +41,38 @@
|
|
|
41
41
|
/** @type {HTMLElement | undefined} */
|
|
42
42
|
let container = $state();
|
|
43
43
|
|
|
44
|
+
// Hoisted references for the overlay owner effect
|
|
45
|
+
/** @type {import('ol/Map.js').default | undefined} */
|
|
46
|
+
let _map = $state();
|
|
47
|
+
/** @type {any} — shared style factory (set at mount) */
|
|
48
|
+
let _styles = $state();
|
|
49
|
+
|
|
50
|
+
// The `layers` overlays have ONE owner: this effect (same contract as
|
|
51
|
+
// MapPicker's boundary). Consumers typically RESOLVE overlay defs
|
|
52
|
+
// asynchronously (layer codes → url defs, after a fetch), so a
|
|
53
|
+
// mount-time-only build silently drops them — the overlays must track
|
|
54
|
+
// `layers` for as long as the map lives. The sequence counter drops
|
|
55
|
+
// stale async builds when the defs change mid-flight.
|
|
56
|
+
/** @type {any[]} */
|
|
57
|
+
let _overlayLayers = [];
|
|
58
|
+
let _overlaySeq = 0;
|
|
59
|
+
$effect(() => {
|
|
60
|
+
const defs = layers;
|
|
61
|
+
const map = _map;
|
|
62
|
+
const styles = _styles;
|
|
63
|
+
if (!map || !styles) return;
|
|
64
|
+
const seq = ++_overlaySeq;
|
|
65
|
+
void (async () => {
|
|
66
|
+
const built = defs?.length ? await createOverlayLayers(defs, styles) : [];
|
|
67
|
+
if (seq !== _overlaySeq || _map !== map) return;
|
|
68
|
+
for (const l of _overlayLayers) map.removeLayer(l);
|
|
69
|
+
_overlayLayers = built;
|
|
70
|
+
// Between the tiles (index 0) and the marker/polygon vector layer —
|
|
71
|
+
// overlays never cover the marker.
|
|
72
|
+
built.forEach((l, i) => map.getLayers().insertAt(1 + i, l));
|
|
73
|
+
})();
|
|
74
|
+
});
|
|
75
|
+
|
|
44
76
|
$effect(() => {
|
|
45
77
|
if (!container) return;
|
|
46
78
|
|
|
@@ -77,8 +109,7 @@
|
|
|
77
109
|
]);
|
|
78
110
|
if (disposed) return;
|
|
79
111
|
|
|
80
|
-
|
|
81
|
-
if (disposed) return;
|
|
112
|
+
_styles = styles;
|
|
82
113
|
|
|
83
114
|
/** @type {Feature[]} */
|
|
84
115
|
const features = [];
|
|
@@ -103,20 +134,23 @@
|
|
|
103
134
|
|
|
104
135
|
map = new OlMap({
|
|
105
136
|
target: container,
|
|
106
|
-
layers
|
|
137
|
+
// The `layers` overlays are NOT built here — the reactive owner
|
|
138
|
+
// effect above inserts them between the tiles and the vector layer.
|
|
139
|
+
layers: [tileLayer, vectorLayer],
|
|
107
140
|
view: new View({
|
|
108
141
|
center: fromLonLat(center),
|
|
109
142
|
zoom,
|
|
110
143
|
}),
|
|
111
144
|
controls: [],
|
|
112
145
|
});
|
|
146
|
+
_map = map;
|
|
113
147
|
|
|
114
148
|
disposeTheme = watchTheme(() => {
|
|
115
149
|
styles.refresh();
|
|
116
150
|
vectorLayer.getSource()?.changed();
|
|
117
151
|
// Token-styled overlays (no custom style) re-read via the shared
|
|
118
152
|
// styles object; poke their sources so OL repaints.
|
|
119
|
-
for (const l of
|
|
153
|
+
for (const l of _overlayLayers) l.getSource()?.changed();
|
|
120
154
|
});
|
|
121
155
|
} catch (err) { renderMapError(container, 'MapDisplay', /** @type {Error} */ (err)); } })();
|
|
122
156
|
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
-->
|
|
22
22
|
<script>
|
|
23
23
|
import { fromLonLat } from 'ol/proj.js';
|
|
24
|
-
import { createTileLayer, getHeatmapGradient, watchTheme, renderMapError } from './map-utils.js';
|
|
24
|
+
import { createTileLayer, createMapStyles, createOverlayLayers, getHeatmapGradient, watchTheme, renderMapError } from './map-utils.js';
|
|
25
25
|
|
|
26
26
|
let {
|
|
27
27
|
/** @type {{ lon: number, lat: number, weight?: number }[]} */
|
|
@@ -38,6 +38,12 @@
|
|
|
38
38
|
gradient = undefined,
|
|
39
39
|
/** @type {import('./map-utils.js').TileSourceConfig} */
|
|
40
40
|
tileSource = { type: 'osm' },
|
|
41
|
+
/** @type {import('./map-utils.js').OverlayLayerDef[]} — ordered GeoJSON
|
|
42
|
+
* overlays rendered between the tiles and the heatmap layer. Each
|
|
43
|
+
* entry: inline `data` or a `url` (e.g. the platform's
|
|
44
|
+
* `/{app}/public/layers/{id}/features`), optional flat or GeoStyler
|
|
45
|
+
* `style`. Unbounded — render as many as the consumer configures. */
|
|
46
|
+
layers = [],
|
|
41
47
|
/** @type {number} — max zoom when auto-fitting to points extent */
|
|
42
48
|
maxZoom = 17,
|
|
43
49
|
/** @type {string} */
|
|
@@ -50,6 +56,38 @@
|
|
|
50
56
|
/** @type {HTMLElement | undefined} */
|
|
51
57
|
let container = $state();
|
|
52
58
|
|
|
59
|
+
// Hoisted references for the overlay owner effect
|
|
60
|
+
/** @type {import('ol/Map.js').default | undefined} */
|
|
61
|
+
let _map = $state();
|
|
62
|
+
/** @type {any} — shared style factory (set at mount) */
|
|
63
|
+
let _styles = $state();
|
|
64
|
+
|
|
65
|
+
// The `layers` overlays have ONE owner: this effect (same contract as
|
|
66
|
+
// MapPicker's boundary). Consumers typically RESOLVE overlay defs
|
|
67
|
+
// asynchronously (layer codes → url defs, after a fetch), so a
|
|
68
|
+
// mount-time-only build silently drops them — the overlays must track
|
|
69
|
+
// `layers` for as long as the map lives. The sequence counter drops
|
|
70
|
+
// stale async builds when the defs change mid-flight.
|
|
71
|
+
/** @type {any[]} */
|
|
72
|
+
let _overlayLayers = [];
|
|
73
|
+
let _overlaySeq = 0;
|
|
74
|
+
$effect(() => {
|
|
75
|
+
const defs = layers;
|
|
76
|
+
const map = _map;
|
|
77
|
+
const styles = _styles;
|
|
78
|
+
if (!map || !styles) return;
|
|
79
|
+
const seq = ++_overlaySeq;
|
|
80
|
+
void (async () => {
|
|
81
|
+
const built = defs?.length ? await createOverlayLayers(defs, styles) : [];
|
|
82
|
+
if (seq !== _overlaySeq || _map !== map) return;
|
|
83
|
+
for (const l of _overlayLayers) map.removeLayer(l);
|
|
84
|
+
_overlayLayers = built;
|
|
85
|
+
// Between the tiles (index 0) and the heatmap layer — overlays never
|
|
86
|
+
// cover the heat surface.
|
|
87
|
+
built.forEach((l, i) => map.getLayers().insertAt(1 + i, l));
|
|
88
|
+
})();
|
|
89
|
+
});
|
|
90
|
+
|
|
53
91
|
$effect(() => {
|
|
54
92
|
if (!container) return;
|
|
55
93
|
|
|
@@ -81,6 +119,12 @@
|
|
|
81
119
|
const tileLayer = await createTileLayer(tileSource);
|
|
82
120
|
if (disposed) return;
|
|
83
121
|
|
|
122
|
+
// Style factory only feeds overlay fallback styles here — the heatmap
|
|
123
|
+
// layer itself styles via gradient tokens.
|
|
124
|
+
const styles = await createMapStyles(container);
|
|
125
|
+
if (disposed) return;
|
|
126
|
+
_styles = styles;
|
|
127
|
+
|
|
84
128
|
// Non-linear weight normalization: sqrt lifts low values so they're
|
|
85
129
|
// visible while preserving relative ordering. OL expects 0-1.
|
|
86
130
|
const maxWeight = Math.max(...points.map(p => p.weight ?? 1), 1);
|
|
@@ -106,12 +150,15 @@
|
|
|
106
150
|
|
|
107
151
|
map = new OlMap({
|
|
108
152
|
target: container,
|
|
153
|
+
// The `layers` overlays are NOT built here — the reactive owner
|
|
154
|
+
// effect above inserts them between the tiles and the heatmap layer.
|
|
109
155
|
layers: [tileLayer, heatmapLayer],
|
|
110
156
|
view: new View({
|
|
111
157
|
center: fromLonLat(center),
|
|
112
158
|
zoom,
|
|
113
159
|
}),
|
|
114
160
|
});
|
|
161
|
+
_map = map;
|
|
115
162
|
|
|
116
163
|
// Auto-fit view to points extent
|
|
117
164
|
if (points.length > 0) {
|
|
@@ -32,6 +32,7 @@ declare const MapHeatmap: import("svelte").Component<{
|
|
|
32
32
|
blur?: number;
|
|
33
33
|
gradient?: any;
|
|
34
34
|
tileSource?: Record<string, any>;
|
|
35
|
+
layers?: any[];
|
|
35
36
|
maxZoom?: number;
|
|
36
37
|
height?: string;
|
|
37
38
|
class?: string;
|
|
@@ -44,6 +45,7 @@ type $$ComponentProps = {
|
|
|
44
45
|
blur?: number;
|
|
45
46
|
gradient?: any;
|
|
46
47
|
tileSource?: Record<string, any>;
|
|
48
|
+
layers?: any[];
|
|
47
49
|
maxZoom?: number;
|
|
48
50
|
height?: string;
|
|
49
51
|
class?: string;
|
|
@@ -95,6 +95,9 @@
|
|
|
95
95
|
let container = $state();
|
|
96
96
|
/** @type {import('ol/Map.js').default | undefined} */
|
|
97
97
|
let _map = $state();
|
|
98
|
+
/** @type {any} — shared style factory (set at mount, consumed by the
|
|
99
|
+
* overlay owner effect) */
|
|
100
|
+
let _styles = $state();
|
|
98
101
|
/** @type {any} — VectorSource for placing markers via search */
|
|
99
102
|
let _vectorSource;
|
|
100
103
|
/** @type {any} */
|
|
@@ -142,6 +145,56 @@
|
|
|
142
145
|
onoutofbounds(!pointInRings(coords, boundaryRings), coords);
|
|
143
146
|
}
|
|
144
147
|
|
|
148
|
+
// The dashed boundary overlay has ONE owner: this effect. Consumers
|
|
149
|
+
// typically FETCH the boundary (it arrives after map init), so a
|
|
150
|
+
// mount-time-only build silently drops it — the overlay must track
|
|
151
|
+
// `boundaryRings` for as long as the map lives. The sequence counter
|
|
152
|
+
// drops stale async builds when the rings change mid-flight.
|
|
153
|
+
/** @type {any} */
|
|
154
|
+
let _boundaryLayer = null;
|
|
155
|
+
let _boundarySeq = 0;
|
|
156
|
+
$effect(() => {
|
|
157
|
+
const rings = boundaryRings;
|
|
158
|
+
const map = _map;
|
|
159
|
+
if (!map || !container) return;
|
|
160
|
+
const seq = ++_boundarySeq;
|
|
161
|
+
void (async () => {
|
|
162
|
+
const layer = rings.length ? await createBoundaryLayer(rings, container) : null;
|
|
163
|
+
if (seq !== _boundarySeq || _map !== map) return;
|
|
164
|
+
if (_boundaryLayer) map.removeLayer(_boundaryLayer);
|
|
165
|
+
_boundaryLayer = layer;
|
|
166
|
+
if (layer) {
|
|
167
|
+
// Just below the pin/draw vector layer — boundary never covers the pin.
|
|
168
|
+
const coll = map.getLayers();
|
|
169
|
+
coll.insertAt(coll.getLength() - 1, layer);
|
|
170
|
+
}
|
|
171
|
+
})();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// The `layers` overlays get the same single-owner treatment as the
|
|
175
|
+
// boundary above: consumers typically RESOLVE overlay defs asynchronously
|
|
176
|
+
// (layer codes → url defs, after a fetch), so a mount-time-only build
|
|
177
|
+
// silently drops them.
|
|
178
|
+
/** @type {any[]} */
|
|
179
|
+
let _overlayLayers = [];
|
|
180
|
+
let _overlaySeq = 0;
|
|
181
|
+
$effect(() => {
|
|
182
|
+
const defs = layers;
|
|
183
|
+
const map = _map;
|
|
184
|
+
const styles = _styles;
|
|
185
|
+
if (!map || !styles) return;
|
|
186
|
+
const seq = ++_overlaySeq;
|
|
187
|
+
void (async () => {
|
|
188
|
+
const built = defs?.length ? await createOverlayLayers(defs, styles) : [];
|
|
189
|
+
if (seq !== _overlaySeq || _map !== map) return;
|
|
190
|
+
for (const l of _overlayLayers) map.removeLayer(l);
|
|
191
|
+
_overlayLayers = built;
|
|
192
|
+
// Between the tiles (index 0) and the boundary/pin layers — overlays
|
|
193
|
+
// never cover the pin.
|
|
194
|
+
built.forEach((l, i) => map.getLayers().insertAt(1 + i, l));
|
|
195
|
+
})();
|
|
196
|
+
});
|
|
197
|
+
|
|
145
198
|
$effect(() => {
|
|
146
199
|
if (!container || disabled) return;
|
|
147
200
|
|
|
@@ -178,11 +231,10 @@
|
|
|
178
231
|
]);
|
|
179
232
|
if (disposed) return;
|
|
180
233
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (disposed) return;
|
|
234
|
+
// Neither the boundary overlay NOR the `layers` overlays are built
|
|
235
|
+
// here — the reactive owner effects above track their late-arriving
|
|
236
|
+
// props for as long as the map lives.
|
|
237
|
+
_styles = styles;
|
|
186
238
|
|
|
187
239
|
const vectorSource = new VectorSource();
|
|
188
240
|
_vectorSource = vectorSource;
|
|
@@ -240,12 +292,7 @@
|
|
|
240
292
|
|
|
241
293
|
map = new OlMap({
|
|
242
294
|
target: container,
|
|
243
|
-
layers: [
|
|
244
|
-
tileLayer,
|
|
245
|
-
...overlayLayers,
|
|
246
|
-
...(boundaryLayer ? [boundaryLayer] : []),
|
|
247
|
-
vectorLayer,
|
|
248
|
-
],
|
|
295
|
+
layers: [tileLayer, vectorLayer],
|
|
249
296
|
view: new View({
|
|
250
297
|
center: initialCenter,
|
|
251
298
|
zoom,
|
|
@@ -258,7 +305,7 @@
|
|
|
258
305
|
disposeTheme = watchTheme(() => {
|
|
259
306
|
styles.refresh();
|
|
260
307
|
vectorSource.changed();
|
|
261
|
-
for (const l of
|
|
308
|
+
for (const l of _overlayLayers) l.getSource()?.changed();
|
|
262
309
|
});
|
|
263
310
|
} catch (err) { renderMapError(container, 'MapPicker', /** @type {Error} */ (err)); } })();
|
|
264
311
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component TextSizeAdjuster
|
|
3
|
+
|
|
4
|
+
Citizen text-size control (#244 C7 accessibility pack). Three buttons —
|
|
5
|
+
decrease (A−), reset (A), increase (A+) — step the root font scale across four
|
|
6
|
+
rungs (100–160%), so a citizen can enlarge the rem-based type system without
|
|
7
|
+
breaking the layout (WCAG 1.4.4 Resize Text). The classic pt-gov
|
|
8
|
+
"diminuir / normal / aumentar" pattern.
|
|
9
|
+
|
|
10
|
+
Presentational: it reports the chosen size via `onchange`; the consumer
|
|
11
|
+
persists it (cookie) and applies the `:root[data-text-size="N"]` layer
|
|
12
|
+
(tokens/semantic.css) server-side, so SSR paints it with no flash. Controls
|
|
13
|
+
disable at the bounds; a polite live region announces the current size for
|
|
14
|
+
screen-reader users. Labels are props for i18n (default English).
|
|
15
|
+
|
|
16
|
+
@example
|
|
17
|
+
<TextSizeAdjuster size={prefs.textSize} onchange={(n) => setTextSize(n)} />
|
|
18
|
+
-->
|
|
19
|
+
<script>
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_TEXT_SIZE,
|
|
22
|
+
normalizeTextSize,
|
|
23
|
+
increaseTextSize,
|
|
24
|
+
decreaseTextSize,
|
|
25
|
+
isMinTextSize,
|
|
26
|
+
isMaxTextSize,
|
|
27
|
+
} from "./text-size.js";
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
/** @type {number} Current size step (percent). Off-ladder values normalize. */
|
|
31
|
+
size = DEFAULT_TEXT_SIZE,
|
|
32
|
+
/** @type {((size: number) => void) | undefined} */
|
|
33
|
+
onchange = undefined,
|
|
34
|
+
/** @type {string} Group label (i18n). */
|
|
35
|
+
label = "Text size",
|
|
36
|
+
/** @type {string} Decrease-button accessible label (i18n). */
|
|
37
|
+
decreaseLabel = "Decrease text size",
|
|
38
|
+
/** @type {string} Reset-button accessible label (i18n). */
|
|
39
|
+
resetLabel = "Reset text size",
|
|
40
|
+
/** @type {string} Increase-button accessible label (i18n). */
|
|
41
|
+
increaseLabel = "Increase text size",
|
|
42
|
+
/** @type {string} */
|
|
43
|
+
class: className = "",
|
|
44
|
+
...rest
|
|
45
|
+
} = $props();
|
|
46
|
+
|
|
47
|
+
const current = $derived(normalizeTextSize(size));
|
|
48
|
+
const atMin = $derived(isMinTextSize(current));
|
|
49
|
+
const atMax = $derived(isMaxTextSize(current));
|
|
50
|
+
const atDefault = $derived(current === DEFAULT_TEXT_SIZE);
|
|
51
|
+
|
|
52
|
+
function emit(next) {
|
|
53
|
+
if (next !== current) onchange?.(next);
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<div
|
|
58
|
+
class="text-size-adjuster {className}"
|
|
59
|
+
role="group"
|
|
60
|
+
aria-label={label}
|
|
61
|
+
data-testid="text-size-adjuster"
|
|
62
|
+
{...rest}
|
|
63
|
+
>
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
class="ts-btn ts-decrease"
|
|
67
|
+
aria-label={decreaseLabel}
|
|
68
|
+
disabled={atMin}
|
|
69
|
+
onclick={() => emit(decreaseTextSize(current))}
|
|
70
|
+
>A<span aria-hidden="true">−</span></button>
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
class="ts-btn ts-reset"
|
|
74
|
+
aria-label={resetLabel}
|
|
75
|
+
disabled={atDefault}
|
|
76
|
+
onclick={() => emit(DEFAULT_TEXT_SIZE)}
|
|
77
|
+
>A</button>
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
class="ts-btn ts-increase"
|
|
81
|
+
aria-label={increaseLabel}
|
|
82
|
+
disabled={atMax}
|
|
83
|
+
onclick={() => emit(increaseTextSize(current))}
|
|
84
|
+
>A<span aria-hidden="true">+</span></button>
|
|
85
|
+
<span class="sr-only" aria-live="polite" data-testid="text-size-readout"
|
|
86
|
+
>{current}%</span
|
|
87
|
+
>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<style>
|
|
91
|
+
.text-size-adjuster {
|
|
92
|
+
display: inline-flex;
|
|
93
|
+
align-items: center;
|
|
94
|
+
gap: var(--space-2xs);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.ts-btn {
|
|
98
|
+
display: inline-flex;
|
|
99
|
+
align-items: baseline;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
min-width: var(--space-lg);
|
|
102
|
+
font: inherit;
|
|
103
|
+
color: var(--color-text-secondary);
|
|
104
|
+
background: none;
|
|
105
|
+
border: var(--border-width-thin, 1px) solid var(--color-border);
|
|
106
|
+
border-radius: var(--radius-md);
|
|
107
|
+
padding: var(--space-2xs) var(--space-xs);
|
|
108
|
+
cursor: pointer;
|
|
109
|
+
line-height: 1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* The glyph "A" sizes telegraph the control: smaller on decrease, larger on
|
|
113
|
+
increase, so the affordance reads without relying on the +/− alone. */
|
|
114
|
+
.ts-decrease {
|
|
115
|
+
font-size: var(--type-body-sm-size);
|
|
116
|
+
}
|
|
117
|
+
.ts-reset {
|
|
118
|
+
font-size: var(--type-body-size);
|
|
119
|
+
}
|
|
120
|
+
.ts-increase {
|
|
121
|
+
font-size: var(--type-heading-sm-size);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.ts-btn:hover:not(:disabled) {
|
|
125
|
+
color: var(--color-text);
|
|
126
|
+
background: var(--color-surface-secondary);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.ts-btn:focus-visible {
|
|
130
|
+
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
|
131
|
+
outline-offset: var(--focus-ring-offset);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.ts-btn:disabled {
|
|
135
|
+
opacity: 0.4;
|
|
136
|
+
cursor: not-allowed;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.sr-only {
|
|
140
|
+
position: absolute;
|
|
141
|
+
width: 1px;
|
|
142
|
+
height: 1px;
|
|
143
|
+
padding: 0;
|
|
144
|
+
margin: -1px;
|
|
145
|
+
overflow: hidden;
|
|
146
|
+
clip: rect(0, 0, 0, 0);
|
|
147
|
+
white-space: nowrap;
|
|
148
|
+
border: 0;
|
|
149
|
+
}
|
|
150
|
+
</style>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export default TextSizeAdjuster;
|
|
2
|
+
type TextSizeAdjuster = {
|
|
3
|
+
$on?(type: string, callback: (e: any) => void): () => void;
|
|
4
|
+
$set?(props: Partial<$$ComponentProps>): void;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* TextSizeAdjuster
|
|
8
|
+
*
|
|
9
|
+
* Citizen text-size control (#244 C7 accessibility pack). Three buttons —
|
|
10
|
+
* decrease (A−), reset (A), increase (A+) — step the root font scale across four
|
|
11
|
+
* rungs (100–160%), so a citizen can enlarge the rem-based type system without
|
|
12
|
+
* breaking the layout (WCAG 1.4.4 Resize Text). The classic pt-gov
|
|
13
|
+
* "diminuir / normal / aumentar" pattern.
|
|
14
|
+
*
|
|
15
|
+
* Presentational: it reports the chosen size via `onchange`; the consumer
|
|
16
|
+
* persists it (cookie) and applies the `:root[data-text-size="N"]` layer
|
|
17
|
+
* (tokens/semantic.css) server-side, so SSR paints it with no flash. Controls
|
|
18
|
+
* disable at the bounds; a polite live region announces the current size for
|
|
19
|
+
* screen-reader users. Labels are props for i18n (default English).
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* <TextSizeAdjuster size={prefs.textSize} onchange={(n) => setTextSize(n)} />
|
|
23
|
+
*/
|
|
24
|
+
declare const TextSizeAdjuster: import("svelte").Component<{
|
|
25
|
+
size?: typeof DEFAULT_TEXT_SIZE;
|
|
26
|
+
onchange?: any;
|
|
27
|
+
label?: string;
|
|
28
|
+
decreaseLabel?: string;
|
|
29
|
+
resetLabel?: string;
|
|
30
|
+
increaseLabel?: string;
|
|
31
|
+
class?: string;
|
|
32
|
+
} & Record<string, any>, {}, "">;
|
|
33
|
+
type $$ComponentProps = {
|
|
34
|
+
size?: typeof DEFAULT_TEXT_SIZE;
|
|
35
|
+
onchange?: any;
|
|
36
|
+
label?: string;
|
|
37
|
+
decreaseLabel?: string;
|
|
38
|
+
resetLabel?: string;
|
|
39
|
+
increaseLabel?: string;
|
|
40
|
+
class?: string;
|
|
41
|
+
} & Record<string, any>;
|
|
42
|
+
import { DEFAULT_TEXT_SIZE } from "./text-size.js";
|
package/components/index.js
CHANGED
|
@@ -38,6 +38,9 @@ export { default as AppFrame } from "./AppFrame.svelte";
|
|
|
38
38
|
export { default as SiteHeader } from "./SiteHeader.svelte";
|
|
39
39
|
export { default as SiteFooter } from "./SiteFooter.svelte";
|
|
40
40
|
export { default as SkipLink } from "./SkipLink.svelte";
|
|
41
|
+
export { default as TextSizeAdjuster } from "./TextSizeAdjuster.svelte";
|
|
42
|
+
export { default as ContrastToggle } from "./ContrastToggle.svelte";
|
|
43
|
+
export { default as LinkHighlightToggle } from "./LinkHighlightToggle.svelte";
|
|
41
44
|
export { default as ServiceNavigation } from "./ServiceNavigation.svelte";
|
|
42
45
|
export { default as Link } from "./Link.svelte";
|
|
43
46
|
export { default as Hero } from "./Hero.svelte";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clamp an arbitrary value to a valid ladder step. The cookie can carry junk or
|
|
3
|
+
* a numeric string (`"120"`), so coerce + validate; anything off-ladder falls
|
|
4
|
+
* back to the default. Idempotent.
|
|
5
|
+
* @param {unknown} value
|
|
6
|
+
* @returns {number}
|
|
7
|
+
*/
|
|
8
|
+
export function normalizeTextSize(value: unknown): number;
|
|
9
|
+
/**
|
|
10
|
+
* The next step up, clamped at the top rung.
|
|
11
|
+
* @param {unknown} value
|
|
12
|
+
* @returns {number}
|
|
13
|
+
*/
|
|
14
|
+
export function increaseTextSize(value: unknown): number;
|
|
15
|
+
/**
|
|
16
|
+
* The next step down, clamped at the bottom rung.
|
|
17
|
+
* @param {unknown} value
|
|
18
|
+
* @returns {number}
|
|
19
|
+
*/
|
|
20
|
+
export function decreaseTextSize(value: unknown): number;
|
|
21
|
+
/**
|
|
22
|
+
* Whether the value is at the bottom rung (decrease control should be disabled).
|
|
23
|
+
* @param {unknown} value
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*/
|
|
26
|
+
export function isMinTextSize(value: unknown): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether the value is at the top rung (increase control should be disabled).
|
|
29
|
+
* @param {unknown} value
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
export function isMaxTextSize(value: unknown): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Text-size preference ladder (#244 C7 — DS accessibility pack).
|
|
35
|
+
*
|
|
36
|
+
* Four steps spanning 100–160% let a citizen scale the rem-based type system
|
|
37
|
+
* (WCAG 1.4.4 Resize Text) without breaking the layout. Pure logic: the
|
|
38
|
+
* `TextSizeAdjuster` component renders the controls, and the consumer persists
|
|
39
|
+
* the choice (cookie) + applies it as a root font-scale via the
|
|
40
|
+
* `:root[data-text-size="N"]` layer in tokens/semantic.css. Kept dependency-free
|
|
41
|
+
* + framework-agnostic so it is unit-testable with `node:test`.
|
|
42
|
+
*/
|
|
43
|
+
/** The valid text-size steps, ascending (percent of the base root font-size). */
|
|
44
|
+
export const TEXT_SIZE_STEPS: number[];
|
|
45
|
+
/**
|
|
46
|
+
* The default (no preference) — the bottom rung. Derived from the ladder (not a
|
|
47
|
+
* literal `100`) so its inferred type stays `number`: consumers bind it to a
|
|
48
|
+
* `number`-typed prop, and a literal type would reject any other rung.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_TEXT_SIZE: number;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-size preference ladder (#244 C7 — DS accessibility pack).
|
|
3
|
+
*
|
|
4
|
+
* Four steps spanning 100–160% let a citizen scale the rem-based type system
|
|
5
|
+
* (WCAG 1.4.4 Resize Text) without breaking the layout. Pure logic: the
|
|
6
|
+
* `TextSizeAdjuster` component renders the controls, and the consumer persists
|
|
7
|
+
* the choice (cookie) + applies it as a root font-scale via the
|
|
8
|
+
* `:root[data-text-size="N"]` layer in tokens/semantic.css. Kept dependency-free
|
|
9
|
+
* + framework-agnostic so it is unit-testable with `node:test`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The valid text-size steps, ascending (percent of the base root font-size). */
|
|
13
|
+
export const TEXT_SIZE_STEPS = [100, 120, 140, 160];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The default (no preference) — the bottom rung. Derived from the ladder (not a
|
|
17
|
+
* literal `100`) so its inferred type stays `number`: consumers bind it to a
|
|
18
|
+
* `number`-typed prop, and a literal type would reject any other rung.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_TEXT_SIZE = TEXT_SIZE_STEPS[0];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Clamp an arbitrary value to a valid ladder step. The cookie can carry junk or
|
|
24
|
+
* a numeric string (`"120"`), so coerce + validate; anything off-ladder falls
|
|
25
|
+
* back to the default. Idempotent.
|
|
26
|
+
* @param {unknown} value
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
export function normalizeTextSize(value) {
|
|
30
|
+
const n = Number(value);
|
|
31
|
+
return TEXT_SIZE_STEPS.includes(n) ? n : DEFAULT_TEXT_SIZE;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The next step up, clamped at the top rung.
|
|
36
|
+
* @param {unknown} value
|
|
37
|
+
* @returns {number}
|
|
38
|
+
*/
|
|
39
|
+
export function increaseTextSize(value) {
|
|
40
|
+
const i = TEXT_SIZE_STEPS.indexOf(normalizeTextSize(value));
|
|
41
|
+
return TEXT_SIZE_STEPS[Math.min(i + 1, TEXT_SIZE_STEPS.length - 1)];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The next step down, clamped at the bottom rung.
|
|
46
|
+
* @param {unknown} value
|
|
47
|
+
* @returns {number}
|
|
48
|
+
*/
|
|
49
|
+
export function decreaseTextSize(value) {
|
|
50
|
+
const i = TEXT_SIZE_STEPS.indexOf(normalizeTextSize(value));
|
|
51
|
+
return TEXT_SIZE_STEPS[Math.max(i - 1, 0)];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether the value is at the bottom rung (decrease control should be disabled).
|
|
56
|
+
* @param {unknown} value
|
|
57
|
+
* @returns {boolean}
|
|
58
|
+
*/
|
|
59
|
+
export function isMinTextSize(value) {
|
|
60
|
+
return normalizeTextSize(value) === TEXT_SIZE_STEPS[0];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Whether the value is at the top rung (increase control should be disabled).
|
|
65
|
+
* @param {unknown} value
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
export function isMaxTextSize(value) {
|
|
69
|
+
return (
|
|
70
|
+
normalizeTextSize(value) === TEXT_SIZE_STEPS[TEXT_SIZE_STEPS.length - 1]
|
|
71
|
+
);
|
|
72
|
+
}
|
package/package.json
CHANGED
package/tokens/semantic.css
CHANGED
|
@@ -255,6 +255,15 @@
|
|
|
255
255
|
--color-text-secondary: var(--raw-color-neutral-400);
|
|
256
256
|
--color-text-muted: var(--raw-color-neutral-500);
|
|
257
257
|
|
|
258
|
+
/* Status colours lifted one ramp step (600 → 400) for dark surfaces. The
|
|
259
|
+
light -600 values are too dark on the dark (or dark-tinted -subtle) surface
|
|
260
|
+
a badge/alert sits on — e.g. info blue-600 #2e63a3 was 2.57:1, an AA fail
|
|
261
|
+
(axe #244 S5). The -400 ramp reads as the status colour AND clears 4.5:1. */
|
|
262
|
+
--color-destructive: var(--raw-color-red-400);
|
|
263
|
+
--color-success: var(--raw-color-green-400);
|
|
264
|
+
--color-warning: var(--raw-color-amber-400);
|
|
265
|
+
--color-info: var(--raw-color-blue-400);
|
|
266
|
+
|
|
258
267
|
/* Subtle washes — derived from the LIVE hue (theme-set or default),
|
|
259
268
|
mixed into the dark surface instead of the light-only raw tints. */
|
|
260
269
|
--color-accent-subtle: color-mix(
|
|
@@ -284,6 +293,72 @@
|
|
|
284
293
|
);
|
|
285
294
|
}
|
|
286
295
|
|
|
296
|
+
/* ═══════════════════════════════════════════════
|
|
297
|
+
ACCESSIBILITY PREFERENCE LAYERS — #244 C7
|
|
298
|
+
═══════════════════════════════════════════════
|
|
299
|
+
|
|
300
|
+
Citizen-selectable a11y affordances, each driven by a `data-*` attribute the
|
|
301
|
+
consumer stamps on <html> server-side (from a cookie, no flash) and toggles
|
|
302
|
+
live. Independent of scheme/theme — they LAYER on whatever brand + light/dark
|
|
303
|
+
is active. Placed after the dark block so a tie on specificity resolves here.
|
|
304
|
+
|
|
305
|
+
- text-size → `:root[data-text-size="N"]` scales the root font-size; the
|
|
306
|
+
whole rem-based type system grows with it (WCAG 1.4.4).
|
|
307
|
+
- contrast → `:root[data-contrast="high"]` re-maps the neutral roles to a
|
|
308
|
+
black-on-white (or white-on-black in dark) maximum, kills the
|
|
309
|
+
muted greys that fail AA, and strengthens borders (the
|
|
310
|
+
"alto contraste" pattern). A second selector handles the dark
|
|
311
|
+
pairing; (0,2,0) beats the generic dark layer.
|
|
312
|
+
- link-mark → `:root[data-link-highlight="on"] a` underlines every in-content
|
|
313
|
+
link so it is distinguishable by more than colour (1.4.1). */
|
|
314
|
+
|
|
315
|
+
:root[data-text-size="100"] {
|
|
316
|
+
font-size: 100%;
|
|
317
|
+
}
|
|
318
|
+
:root[data-text-size="120"] {
|
|
319
|
+
font-size: 120%;
|
|
320
|
+
}
|
|
321
|
+
:root[data-text-size="140"] {
|
|
322
|
+
font-size: 140%;
|
|
323
|
+
}
|
|
324
|
+
:root[data-text-size="160"] {
|
|
325
|
+
font-size: 160%;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
:root[data-contrast="high"] {
|
|
329
|
+
--color-text: #000000;
|
|
330
|
+
--color-text-secondary: #000000;
|
|
331
|
+
--color-text-muted: #1a1a1a;
|
|
332
|
+
--color-surface: #ffffff;
|
|
333
|
+
--color-surface-secondary: #ffffff;
|
|
334
|
+
--color-surface-tertiary: #ffffff;
|
|
335
|
+
--color-surface-raised: #ffffff;
|
|
336
|
+
--color-border: #000000;
|
|
337
|
+
--color-border-strong: #000000;
|
|
338
|
+
--focus-ring-color: #000000;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
:root[data-contrast="high"][data-scheme="dark"] {
|
|
342
|
+
--color-text: #ffffff;
|
|
343
|
+
--color-text-secondary: #ffffff;
|
|
344
|
+
--color-text-muted: #e6e6e6;
|
|
345
|
+
--color-surface: #000000;
|
|
346
|
+
--color-surface-secondary: #000000;
|
|
347
|
+
--color-surface-tertiary: #000000;
|
|
348
|
+
--color-surface-raised: #000000;
|
|
349
|
+
--color-border: #ffffff;
|
|
350
|
+
--color-border-strong: #ffffff;
|
|
351
|
+
--focus-ring-color: #ffffff;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/* High contrast implies link distinction — underline links even if the citizen
|
|
355
|
+
hasn't separately enabled the link-highlight pref. */
|
|
356
|
+
:root[data-contrast="high"] a,
|
|
357
|
+
:root[data-link-highlight="on"] a {
|
|
358
|
+
text-decoration: underline;
|
|
359
|
+
text-underline-offset: 0.15em;
|
|
360
|
+
}
|
|
361
|
+
|
|
287
362
|
/* ─── Tablet (768px+) ─── */
|
|
288
363
|
@media (min-width: 768px) {
|
|
289
364
|
:root {
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
/* ─── Text ─── */
|
|
35
35
|
--color-text: #1c241b;
|
|
36
36
|
--color-text-secondary: #44513f;
|
|
37
|
-
|
|
37
|
+
/* Muted hits 4.5:1 even on the secondary/tertiary surfaces it sits on
|
|
38
|
+
(#f4f6f3 → 5.2:1) — the old #71806b was 3.86:1, an AA fail (axe #244 S5). */
|
|
39
|
+
--color-text-muted: #5e6b57;
|
|
38
40
|
|
|
39
41
|
/* ─── Overlay + focus follow the brand ─── */
|
|
40
42
|
--color-overlay: rgba(28, 36, 27, 0.5);
|
|
@@ -42,3 +44,28 @@
|
|
|
42
44
|
|
|
43
45
|
/* Total overrides: 14 tokens. Still an aiaiai product, in Valongo's colours. */
|
|
44
46
|
}
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Dark-scheme brand override (#244 C7 + S5 contrast remediation).
|
|
50
|
+
*
|
|
51
|
+
* The generic dark layer (`:root[data-scheme="dark"]` in semantic.css) re-derives
|
|
52
|
+
* surfaces/text/borders from the neutral ramp but leaves the BRAND accent
|
|
53
|
+
* untouched, and the light civic green (#2e7d32) is too dark on dark surfaces.
|
|
54
|
+
*
|
|
55
|
+
* The accent is DUAL-USE — link text (on a dark surface) AND fill (a button,
|
|
56
|
+
* with `--color-text-on-accent` text). Those pull opposite ways: link text wants
|
|
57
|
+
* a LIGHT accent (≥4.5:1 vs the surface), white-on-fill wants a DARK one. So lift
|
|
58
|
+
* the accent for link contrast (#57bb5d clears 4.5:1 even on the lightest dark
|
|
59
|
+
* surface, neutral-800) AND flip `--color-text-on-accent` to dark, so fills read
|
|
60
|
+
* dark-on-light-green (the standard dark-mode resolution). Validated by axe.
|
|
61
|
+
*
|
|
62
|
+
* Selector (0,2,0) ties the theme block + beats the generic dark layer (both
|
|
63
|
+
* (0,1,1)). `auto` is resolved before paint, so this only matches a real
|
|
64
|
+
* `data-scheme="dark"`. The portal injects the equivalent at runtime; this seeds.
|
|
65
|
+
*/
|
|
66
|
+
[data-theme="valongo"][data-scheme="dark"] {
|
|
67
|
+
--color-accent: #57bb5d; /* link text ≥4.5:1 on every dark surface (4.78 on n-800) */
|
|
68
|
+
--color-accent-hover: #81c784;
|
|
69
|
+
--color-accent-subtle: #15311a;
|
|
70
|
+
--color-text-on-accent: #14210f; /* dark text on the lighter accent fill */
|
|
71
|
+
}
|