@aiaiai-pt/design-system 0.17.1 → 0.19.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 +41 -3
- package/components/ContrastToggle.svelte +36 -0
- package/components/ContrastToggle.svelte.d.ts +30 -0
- package/components/FeedView.svelte +190 -0
- package/components/FeedView.svelte.d.ts +54 -0
- package/components/FileUpload.svelte +14 -10
- package/components/FilterBar.svelte +147 -58
- package/components/FilterBar.svelte.d.ts +56 -14
- package/components/LinkHighlightToggle.svelte +37 -0
- package/components/LinkHighlightToggle.svelte.d.ts +31 -0
- package/components/MapCluster.svelte +84 -7
- package/components/MapCluster.svelte.d.ts +2 -0
- package/components/TextSizeAdjuster.svelte +150 -0
- package/components/TextSizeAdjuster.svelte.d.ts +42 -0
- package/components/index.d.ts +4 -0
- package/components/index.js +4 -0
- package/components/map-utils.d.ts +107 -0
- package/components/text-size.d.ts +50 -0
- package/components/text-size.js +72 -0
- package/package.json +1 -1
- package/tokens/components.css +19 -0
- package/tokens/semantic.css +75 -0
- package/tokens/themes/valongo.css +28 -1
|
@@ -1,49 +1,90 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component FilterBar
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
Accepts a declarative config of filters or renders children directly.
|
|
6
|
-
Consumes --filter-chip-* and --filter-bar-* tokens from components.css.
|
|
4
|
+
Filter controls over a feed. Two modes:
|
|
7
5
|
|
|
8
|
-
|
|
6
|
+
• **Facet mode** (set `facets` and/or `searchable`) — a row of labelled
|
|
7
|
+
dropdowns + an optional search box, the shape a browse/list/map surface
|
|
8
|
+
uses for its declared facets. Options are supplied by the consumer (it
|
|
9
|
+
owns the data source); FilterBar is presentational. A misconfigured /
|
|
10
|
+
undeclared facet is surfaced LOUDLY via `error` — never a silent dead
|
|
11
|
+
control. Consumes --filter-bar-* tokens.
|
|
12
|
+
|
|
13
|
+
• **Chip mode** (default — `filters` and/or `children`) — horizontal bar of
|
|
14
|
+
filter chips with active-filter state and clear-all. Consumes
|
|
15
|
+
--filter-chip-* and --filter-bar-* tokens.
|
|
16
|
+
|
|
17
|
+
@example Facet mode (browse/list/map)
|
|
18
|
+
<FilterBar
|
|
19
|
+
facets={[
|
|
20
|
+
{ key: 'status', label: 'Status', value: '', options: [
|
|
21
|
+
{ value: '', label: 'All' },
|
|
22
|
+
{ value: 'open', label: 'Open' },
|
|
23
|
+
]},
|
|
24
|
+
]}
|
|
25
|
+
searchable
|
|
26
|
+
searchPlaceholder="Search"
|
|
27
|
+
onfacetchange={(key, value) => refetch(key, value)}
|
|
28
|
+
onsearch={(term) => refetch('search', term)}
|
|
29
|
+
/>
|
|
30
|
+
|
|
31
|
+
@example Chip mode (declarative)
|
|
9
32
|
<FilterBar
|
|
10
33
|
filters={[
|
|
11
34
|
{ key: 'status', label: 'Status', type: 'toggle', options: [
|
|
12
35
|
{ value: 'active', label: 'Active' },
|
|
13
36
|
{ value: 'draft', label: 'Draft' },
|
|
14
|
-
{ value: 'error', label: 'Error' },
|
|
15
37
|
]},
|
|
16
38
|
]}
|
|
17
39
|
bind:value={activeFilters}
|
|
18
40
|
onchange={handleFilterChange}
|
|
19
41
|
/>
|
|
20
|
-
|
|
21
|
-
@example Composable (custom rendering)
|
|
22
|
-
<FilterBar bind:value={activeFilters} onchange={handleFilterChange}>
|
|
23
|
-
{#snippet children(toggle, isActive)}
|
|
24
|
-
<FilterBar.Chip active={isActive('status', 'active')} onclick={() => toggle('status', 'active')}>
|
|
25
|
-
Active
|
|
26
|
-
</FilterBar.Chip>
|
|
27
|
-
{/snippet}
|
|
28
|
-
</FilterBar>
|
|
29
42
|
-->
|
|
30
43
|
<script module>
|
|
31
44
|
/**
|
|
32
45
|
* @typedef {{ value: string, label: string, icon?: import('svelte').Component }} FilterOption
|
|
33
46
|
* @typedef {{ key: string, label: string, type: 'toggle' | 'select' | 'multi-select', options?: FilterOption[], defaultValue?: any }} FilterDef
|
|
47
|
+
* @typedef {{ value: string, label: string, disabled?: boolean }} FacetOption
|
|
48
|
+
* @typedef {{ key: string, label: string, value?: string, options: FacetOption[] }} FacetControl
|
|
34
49
|
*/
|
|
35
50
|
</script>
|
|
36
51
|
|
|
37
52
|
<script>
|
|
53
|
+
import Select from './Select.svelte';
|
|
54
|
+
import SearchInput from './SearchInput.svelte';
|
|
55
|
+
import Alert from './Alert.svelte';
|
|
56
|
+
|
|
38
57
|
let {
|
|
39
|
-
|
|
58
|
+
// ─── Facet mode ───
|
|
59
|
+
/** @type {FacetControl[]} Labelled dropdown controls (facet mode). */
|
|
60
|
+
facets = [],
|
|
61
|
+
/** @type {boolean} Render a search box (facet mode). */
|
|
62
|
+
searchable = false,
|
|
63
|
+
/** @type {string} */
|
|
64
|
+
searchPlaceholder = 'Search',
|
|
65
|
+
/** @type {string} */
|
|
66
|
+
searchValue = '',
|
|
67
|
+
/** @type {boolean} Spinner on the search box / aria-busy on the bar. */
|
|
68
|
+
busy = false,
|
|
69
|
+
/** @type {string | undefined} Loud-fail message for misconfigured facets. */
|
|
70
|
+
error = undefined,
|
|
71
|
+
/** @type {((key: string, value: string) => void) | undefined} */
|
|
72
|
+
onfacetchange = undefined,
|
|
73
|
+
/** @type {((value: string) => void) | undefined} */
|
|
74
|
+
onsearch = undefined,
|
|
75
|
+
/** @type {(() => void) | undefined} Fires when the search box is cleared. */
|
|
76
|
+
onsearchclear = undefined,
|
|
77
|
+
|
|
78
|
+
// ─── Chip mode ───
|
|
79
|
+
/** @type {FilterDef[]} Declarative chip definitions */
|
|
40
80
|
filters = [],
|
|
41
|
-
/** @type {Record<string, any>} Active
|
|
81
|
+
/** @type {Record<string, any>} Active chip values (bindable) */
|
|
42
82
|
value = $bindable({}),
|
|
43
|
-
/** @type {((value: Record<string, any>) => void) | undefined} Fires when any
|
|
83
|
+
/** @type {((value: Record<string, any>) => void) | undefined} Fires when any chip changes */
|
|
44
84
|
onchange = undefined,
|
|
45
85
|
/** @type {(() => void) | undefined} Fires when "Clear all" is clicked */
|
|
46
86
|
onclear = undefined,
|
|
87
|
+
|
|
47
88
|
/** @type {string} */
|
|
48
89
|
class: className = '',
|
|
49
90
|
/** @type {import('svelte').Snippet | undefined} */
|
|
@@ -51,6 +92,10 @@
|
|
|
51
92
|
...rest
|
|
52
93
|
} = $props();
|
|
53
94
|
|
|
95
|
+
// Facet mode wins whenever facet controls or search are configured; chip
|
|
96
|
+
// mode is the default (back-compat with the original FilterBar contract).
|
|
97
|
+
const isFacetMode = $derived(facets.length > 0 || searchable);
|
|
98
|
+
|
|
54
99
|
const activeCount = $derived(
|
|
55
100
|
Object.values(value).filter(v => {
|
|
56
101
|
if (Array.isArray(v)) return v.length > 0;
|
|
@@ -61,7 +106,7 @@
|
|
|
61
106
|
const showClearAll = $derived(activeCount >= 2);
|
|
62
107
|
|
|
63
108
|
/**
|
|
64
|
-
* Toggle a filter value
|
|
109
|
+
* Toggle a filter value (chip mode)
|
|
65
110
|
* @param {string} key
|
|
66
111
|
* @param {string} optionValue
|
|
67
112
|
*/
|
|
@@ -90,7 +135,7 @@
|
|
|
90
135
|
}
|
|
91
136
|
|
|
92
137
|
/**
|
|
93
|
-
* Check if a filter option is active
|
|
138
|
+
* Check if a filter option is active (chip mode)
|
|
94
139
|
* @param {string} key
|
|
95
140
|
* @param {string} optionValue
|
|
96
141
|
* @returns {boolean}
|
|
@@ -106,48 +151,82 @@
|
|
|
106
151
|
onchange?.({});
|
|
107
152
|
onclear?.();
|
|
108
153
|
}
|
|
109
|
-
|
|
110
154
|
</script>
|
|
111
155
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
{@render children(toggle, isActive)}
|
|
120
|
-
{:else}
|
|
121
|
-
<div class="filter-bar-chips">
|
|
122
|
-
{#each filters as filter}
|
|
123
|
-
{#if filter.options}
|
|
124
|
-
{#each filter.options as option}
|
|
125
|
-
{@const active = isActive(filter.key, option.value)}
|
|
126
|
-
<button
|
|
127
|
-
class="filter-chip"
|
|
128
|
-
class:filter-chip-active={active}
|
|
129
|
-
onclick={() => toggle(filter.key, option.value)}
|
|
130
|
-
aria-pressed={active}
|
|
131
|
-
type="button"
|
|
132
|
-
>
|
|
133
|
-
{option.label}
|
|
134
|
-
</button>
|
|
135
|
-
{/each}
|
|
136
|
-
{/if}
|
|
137
|
-
{/each}
|
|
138
|
-
</div>
|
|
139
|
-
|
|
140
|
-
{#if showClearAll}
|
|
141
|
-
<button
|
|
142
|
-
class="filter-bar-clear"
|
|
143
|
-
onclick={clearAll}
|
|
144
|
-
type="button"
|
|
145
|
-
>
|
|
146
|
-
Clear all
|
|
147
|
-
</button>
|
|
148
|
-
{/if}
|
|
156
|
+
{#if isFacetMode}
|
|
157
|
+
<!-- Loud-fail (never a silent dead control): a configured facet the surface
|
|
158
|
+
does not declare can't filter — surface it as an operator fix. -->
|
|
159
|
+
{#if error}
|
|
160
|
+
<Alert variant="error" data-testid="filter-bar-error" class="filter-bar-alert">
|
|
161
|
+
{error}
|
|
162
|
+
</Alert>
|
|
149
163
|
{/if}
|
|
150
|
-
|
|
164
|
+
<div
|
|
165
|
+
class="filter-bar filter-bar-facets {className}"
|
|
166
|
+
role="group"
|
|
167
|
+
aria-label="Filters"
|
|
168
|
+
aria-busy={busy}
|
|
169
|
+
{...rest}
|
|
170
|
+
>
|
|
171
|
+
{#if searchable}
|
|
172
|
+
<SearchInput
|
|
173
|
+
placeholder={searchPlaceholder}
|
|
174
|
+
value={searchValue}
|
|
175
|
+
loading={busy}
|
|
176
|
+
onsearch={(v) => onsearch?.((v ?? '').trim())}
|
|
177
|
+
onclear={() => onsearchclear?.()}
|
|
178
|
+
/>
|
|
179
|
+
{/if}
|
|
180
|
+
{#each facets as facet (facet.key)}
|
|
181
|
+
<Select
|
|
182
|
+
label={facet.label}
|
|
183
|
+
options={facet.options}
|
|
184
|
+
value={facet.value ?? ''}
|
|
185
|
+
onchange={(v) => onfacetchange?.(facet.key, v)}
|
|
186
|
+
/>
|
|
187
|
+
{/each}
|
|
188
|
+
</div>
|
|
189
|
+
{:else}
|
|
190
|
+
<div
|
|
191
|
+
class="filter-bar {className}"
|
|
192
|
+
role="group"
|
|
193
|
+
aria-label="Filters"
|
|
194
|
+
{...rest}
|
|
195
|
+
>
|
|
196
|
+
{#if children}
|
|
197
|
+
{@render children(toggle, isActive)}
|
|
198
|
+
{:else}
|
|
199
|
+
<div class="filter-bar-chips">
|
|
200
|
+
{#each filters as filter}
|
|
201
|
+
{#if filter.options}
|
|
202
|
+
{#each filter.options as option}
|
|
203
|
+
{@const active = isActive(filter.key, option.value)}
|
|
204
|
+
<button
|
|
205
|
+
class="filter-chip"
|
|
206
|
+
class:filter-chip-active={active}
|
|
207
|
+
onclick={() => toggle(filter.key, option.value)}
|
|
208
|
+
aria-pressed={active}
|
|
209
|
+
type="button"
|
|
210
|
+
>
|
|
211
|
+
{option.label}
|
|
212
|
+
</button>
|
|
213
|
+
{/each}
|
|
214
|
+
{/if}
|
|
215
|
+
{/each}
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
{#if showClearAll}
|
|
219
|
+
<button
|
|
220
|
+
class="filter-bar-clear"
|
|
221
|
+
onclick={clearAll}
|
|
222
|
+
type="button"
|
|
223
|
+
>
|
|
224
|
+
Clear all
|
|
225
|
+
</button>
|
|
226
|
+
{/if}
|
|
227
|
+
{/if}
|
|
228
|
+
</div>
|
|
229
|
+
{/if}
|
|
151
230
|
|
|
152
231
|
<style>
|
|
153
232
|
.filter-bar {
|
|
@@ -157,6 +236,16 @@
|
|
|
157
236
|
flex-wrap: wrap;
|
|
158
237
|
}
|
|
159
238
|
|
|
239
|
+
/* Facet mode: labelled dropdowns + search align to their baselines. */
|
|
240
|
+
.filter-bar-facets {
|
|
241
|
+
align-items: flex-end;
|
|
242
|
+
gap: var(--space-sm);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.filter-bar-alert {
|
|
246
|
+
margin-bottom: var(--space-sm);
|
|
247
|
+
}
|
|
248
|
+
|
|
160
249
|
.filter-bar-chips {
|
|
161
250
|
display: flex;
|
|
162
251
|
align-items: center;
|
|
@@ -11,6 +11,17 @@ export type FilterDef = {
|
|
|
11
11
|
options?: FilterOption[];
|
|
12
12
|
defaultValue?: any;
|
|
13
13
|
};
|
|
14
|
+
export type FacetOption = {
|
|
15
|
+
value: string;
|
|
16
|
+
label: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
};
|
|
19
|
+
export type FacetControl = {
|
|
20
|
+
key: string;
|
|
21
|
+
label: string;
|
|
22
|
+
value?: string;
|
|
23
|
+
options: FacetOption[];
|
|
24
|
+
};
|
|
14
25
|
type FilterBar = {
|
|
15
26
|
$on?(type: string, callback: (e: any) => void): () => void;
|
|
16
27
|
$set?(props: Partial<$$ComponentProps>): void;
|
|
@@ -18,33 +29,55 @@ type FilterBar = {
|
|
|
18
29
|
/**
|
|
19
30
|
* FilterBar
|
|
20
31
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
32
|
+
* Filter controls over a feed. Two modes:
|
|
33
|
+
*
|
|
34
|
+
* • **Facet mode** (set `facets` and/or `searchable`) — a row of labelled
|
|
35
|
+
* dropdowns + an optional search box, the shape a browse/list/map surface
|
|
36
|
+
* uses for its declared facets. Options are supplied by the consumer (it
|
|
37
|
+
* owns the data source); FilterBar is presentational. A misconfigured /
|
|
38
|
+
* undeclared facet is surfaced LOUDLY via `error` — never a silent dead
|
|
39
|
+
* control. Consumes --filter-bar-* tokens.
|
|
24
40
|
*
|
|
25
|
-
*
|
|
41
|
+
* • **Chip mode** (default — `filters` and/or `children`) — horizontal bar of
|
|
42
|
+
* filter chips with active-filter state and clear-all. Consumes
|
|
43
|
+
* --filter-chip-* and --filter-bar-* tokens.
|
|
44
|
+
*
|
|
45
|
+
* @example Facet mode (browse/list/map)
|
|
46
|
+
* <FilterBar
|
|
47
|
+
* facets={[
|
|
48
|
+
* { key: 'status', label: 'Status', value: '', options: [
|
|
49
|
+
* { value: '', label: 'All' },
|
|
50
|
+
* { value: 'open', label: 'Open' },
|
|
51
|
+
* ]},
|
|
52
|
+
* ]}
|
|
53
|
+
* searchable
|
|
54
|
+
* searchPlaceholder="Search"
|
|
55
|
+
* onfacetchange={(key, value) => refetch(key, value)}
|
|
56
|
+
* onsearch={(term) => refetch('search', term)}
|
|
57
|
+
* />
|
|
58
|
+
*
|
|
59
|
+
* @example Chip mode (declarative)
|
|
26
60
|
* <FilterBar
|
|
27
61
|
* filters={[
|
|
28
62
|
* { key: 'status', label: 'Status', type: 'toggle', options: [
|
|
29
63
|
* { value: 'active', label: 'Active' },
|
|
30
64
|
* { value: 'draft', label: 'Draft' },
|
|
31
|
-
* { value: 'error', label: 'Error' },
|
|
32
65
|
* ]},
|
|
33
66
|
* ]}
|
|
34
67
|
* bind:value={activeFilters}
|
|
35
68
|
* onchange={handleFilterChange}
|
|
36
69
|
* />
|
|
37
|
-
*
|
|
38
|
-
* @example Composable (custom rendering)
|
|
39
|
-
* <FilterBar bind:value={activeFilters} onchange={handleFilterChange}>
|
|
40
|
-
* {#snippet children(toggle, isActive)}
|
|
41
|
-
* <FilterBar.Chip active={isActive('status', 'active')} onclick={() => toggle('status', 'active')}>
|
|
42
|
-
* Active
|
|
43
|
-
* </FilterBar.Chip>
|
|
44
|
-
* {/snippet}
|
|
45
|
-
* </FilterBar>
|
|
46
70
|
*/
|
|
47
71
|
declare const FilterBar: import("svelte").Component<{
|
|
72
|
+
facets?: any[];
|
|
73
|
+
searchable?: boolean;
|
|
74
|
+
searchPlaceholder?: string;
|
|
75
|
+
searchValue?: string;
|
|
76
|
+
busy?: boolean;
|
|
77
|
+
error?: any;
|
|
78
|
+
onfacetchange?: any;
|
|
79
|
+
onsearch?: any;
|
|
80
|
+
onsearchclear?: any;
|
|
48
81
|
filters?: any[];
|
|
49
82
|
value?: Record<string, any>;
|
|
50
83
|
onchange?: any;
|
|
@@ -53,6 +86,15 @@ declare const FilterBar: import("svelte").Component<{
|
|
|
53
86
|
children?: any;
|
|
54
87
|
} & Record<string, any>, {}, "value">;
|
|
55
88
|
type $$ComponentProps = {
|
|
89
|
+
facets?: any[];
|
|
90
|
+
searchable?: boolean;
|
|
91
|
+
searchPlaceholder?: string;
|
|
92
|
+
searchValue?: string;
|
|
93
|
+
busy?: boolean;
|
|
94
|
+
error?: any;
|
|
95
|
+
onfacetchange?: any;
|
|
96
|
+
onsearch?: any;
|
|
97
|
+
onsearchclear?: any;
|
|
56
98
|
filters?: any[];
|
|
57
99
|
value?: Record<string, any>;
|
|
58
100
|
onchange?: any;
|
|
@@ -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>;
|
|
@@ -39,6 +39,14 @@
|
|
|
39
39
|
layers = [],
|
|
40
40
|
/** @type {((marker: { id: string, lon: number, lat: number, label?: string }) => void) | undefined} */
|
|
41
41
|
onclick = undefined,
|
|
42
|
+
/** @type {import('svelte').Snippet<[any, () => void]> | undefined} —
|
|
43
|
+
* click-popup content. When provided, clicking a single marker (or a
|
|
44
|
+
* stacked pile) opens an anchored OL Overlay rendering this snippet with
|
|
45
|
+
* `(markerData, close)` — the "ficha resumo" intermediate step before the
|
|
46
|
+
* consumer's detail link — INSTEAD of firing `onclick`. Omit for the
|
|
47
|
+
* legacy click-to-navigate behaviour. Compose with the DS `MapPopup` for
|
|
48
|
+
* the card chrome (the original `let:popup` contract, now a snippet). */
|
|
49
|
+
popup = undefined,
|
|
42
50
|
/** @type {string} */
|
|
43
51
|
height = '100%',
|
|
44
52
|
/** @type {string} */
|
|
@@ -48,6 +56,18 @@
|
|
|
48
56
|
|
|
49
57
|
/** @type {HTMLElement | undefined} */
|
|
50
58
|
let container = $state();
|
|
59
|
+
/** @type {HTMLElement | undefined} — the popup overlay's element (Svelte
|
|
60
|
+
* renders the snippet into it; OL positions it). */
|
|
61
|
+
let popupEl = $state();
|
|
62
|
+
/** @type {any} — the marker data of the open popup, or null. */
|
|
63
|
+
let selected = $state(null);
|
|
64
|
+
/** @type {any} — popup OL Overlay ref, set at mount. */
|
|
65
|
+
let _popupOverlay;
|
|
66
|
+
|
|
67
|
+
function closePopup() {
|
|
68
|
+
selected = null;
|
|
69
|
+
_popupOverlay?.setPosition(undefined);
|
|
70
|
+
}
|
|
51
71
|
|
|
52
72
|
// Hoisted references for reactive effects
|
|
53
73
|
/** @type {import('ol/Map.js').default | undefined} */
|
|
@@ -211,6 +231,22 @@
|
|
|
211
231
|
stopEvent: false,
|
|
212
232
|
});
|
|
213
233
|
|
|
234
|
+
// Click-popup overlay (the "ficha resumo"): anchored above the marker.
|
|
235
|
+
// `stopEvent: true` (OL default) keeps it in the interactive overlay
|
|
236
|
+
// pane so the link/close button inside the popup snippet are clickable.
|
|
237
|
+
// Its element is the Svelte-rendered `popupEl`; Svelte owns the content,
|
|
238
|
+
// OL owns the position. Only created when a `popup` snippet is supplied.
|
|
239
|
+
const overlays = [tooltipOverlay];
|
|
240
|
+
if (popup && popupEl) {
|
|
241
|
+
_popupOverlay = new Overlay({
|
|
242
|
+
element: popupEl,
|
|
243
|
+
positioning: 'bottom-center',
|
|
244
|
+
offset: [0, -18],
|
|
245
|
+
stopEvent: true,
|
|
246
|
+
});
|
|
247
|
+
overlays.push(_popupOverlay);
|
|
248
|
+
}
|
|
249
|
+
|
|
214
250
|
const viewCenter = center
|
|
215
251
|
? fromLonLat(center)
|
|
216
252
|
: markers.length > 0
|
|
@@ -225,7 +261,7 @@
|
|
|
225
261
|
// The `layers` overlays are NOT built here — the reactive owner
|
|
226
262
|
// effect above inserts them between the tiles and the cluster layer.
|
|
227
263
|
layers: [tileLayer, clusterLayer],
|
|
228
|
-
overlays
|
|
264
|
+
overlays,
|
|
229
265
|
view: new View({
|
|
230
266
|
center: viewCenter,
|
|
231
267
|
zoom,
|
|
@@ -267,16 +303,32 @@
|
|
|
267
303
|
}
|
|
268
304
|
});
|
|
269
305
|
|
|
306
|
+
// `popup` opens the anchored ficha-resumo overlay; otherwise `onclick`
|
|
307
|
+
// navigates (legacy). A single marker (or a stacked pile that can't split
|
|
308
|
+
// by zoom) "selects"; a spread cluster zooms in.
|
|
309
|
+
const selectMarker = (data, coords) => {
|
|
310
|
+
if (!data) return;
|
|
311
|
+
if (popup) {
|
|
312
|
+
selected = data;
|
|
313
|
+
if (coords) _popupOverlay?.setPosition(coords);
|
|
314
|
+
} else {
|
|
315
|
+
onclick?.(data);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
|
|
270
319
|
// Click handler
|
|
271
|
-
if (onclick) {
|
|
320
|
+
if (onclick || popup) {
|
|
272
321
|
map.on('click', (evt) => {
|
|
273
322
|
const feature = map?.forEachFeatureAtPixel(evt.pixel, f => f);
|
|
274
|
-
|
|
323
|
+
// Clicking empty map dismisses an open popup (no-op without one).
|
|
324
|
+
if (!feature) { closePopup(); return; }
|
|
275
325
|
|
|
276
326
|
const clustered = feature.get('features');
|
|
277
327
|
if (clustered?.length === 1) {
|
|
278
|
-
|
|
279
|
-
|
|
328
|
+
selectMarker(
|
|
329
|
+
clustered[0].get('markerData'),
|
|
330
|
+
(/** @type {any} */ (feature.getGeometry()))?.getCoordinates(),
|
|
331
|
+
);
|
|
280
332
|
} else if (clustered?.length > 1) {
|
|
281
333
|
// A cluster of (near-)IDENTICAL coordinates can never split by
|
|
282
334
|
// zooming (stacked reports at one point are common in civic
|
|
@@ -286,8 +338,10 @@
|
|
|
286
338
|
const lats = coords.map((c) => c[1]);
|
|
287
339
|
const spread = Math.max(...lons) - Math.min(...lons) + (Math.max(...lats) - Math.min(...lats));
|
|
288
340
|
if (spread < 1) { // metres in web-mercator units — a stacked pile
|
|
289
|
-
|
|
290
|
-
|
|
341
|
+
selectMarker(
|
|
342
|
+
clustered[0].get('markerData'),
|
|
343
|
+
(/** @type {any} */ (feature.getGeometry()))?.getCoordinates(),
|
|
344
|
+
);
|
|
291
345
|
return;
|
|
292
346
|
}
|
|
293
347
|
const view = map?.getView();
|
|
@@ -307,9 +361,16 @@
|
|
|
307
361
|
});
|
|
308
362
|
} catch (err) { renderMapError(container, 'MapCluster', /** @type {Error} */ (err)); } })();
|
|
309
363
|
|
|
364
|
+
// Escape closes an open popup (keyboard dismissal).
|
|
365
|
+
const onKeydown = (/** @type {KeyboardEvent} */ e) => {
|
|
366
|
+
if (e.key === 'Escape') closePopup();
|
|
367
|
+
};
|
|
368
|
+
if (typeof window !== 'undefined') window.addEventListener('keydown', onKeydown);
|
|
369
|
+
|
|
310
370
|
return () => {
|
|
311
371
|
disposed = true;
|
|
312
372
|
disposeTheme?.();
|
|
373
|
+
if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
|
|
313
374
|
map?.setTarget(undefined);
|
|
314
375
|
};
|
|
315
376
|
});
|
|
@@ -324,6 +385,15 @@
|
|
|
324
385
|
{...rest}
|
|
325
386
|
></div>
|
|
326
387
|
|
|
388
|
+
<!-- Popup overlay element. Always in the DOM (OL moves it into the overlay
|
|
389
|
+
pane + positions it); Svelte owns its content. The snippet receives the
|
|
390
|
+
selected marker + a `close` callback (the original `let:popup` contract). -->
|
|
391
|
+
{#if popup}
|
|
392
|
+
<div bind:this={popupEl} class="map-cluster-popup">
|
|
393
|
+
{#if selected}{@render popup(selected, closePopup)}{/if}
|
|
394
|
+
</div>
|
|
395
|
+
{/if}
|
|
396
|
+
|
|
327
397
|
<style>
|
|
328
398
|
.map-cluster {
|
|
329
399
|
width: 100%;
|
|
@@ -336,4 +406,11 @@
|
|
|
336
406
|
.map-cluster :global(.ol-viewport) {
|
|
337
407
|
border-radius: inherit;
|
|
338
408
|
}
|
|
409
|
+
|
|
410
|
+
/* Overlay element wrapper — OL owns its position (inline transform); this
|
|
411
|
+
just lifts it above the map controls. The card chrome is the consumer's
|
|
412
|
+
MapPopup. */
|
|
413
|
+
.map-cluster-popup {
|
|
414
|
+
z-index: 2;
|
|
415
|
+
}
|
|
339
416
|
</style>
|
|
@@ -25,6 +25,7 @@ declare const MapCluster: import("svelte").Component<{
|
|
|
25
25
|
tileSource?: Record<string, any>;
|
|
26
26
|
layers?: any[];
|
|
27
27
|
onclick?: any;
|
|
28
|
+
popup?: any;
|
|
28
29
|
height?: string;
|
|
29
30
|
class?: string;
|
|
30
31
|
} & Record<string, any>, {}, "">;
|
|
@@ -36,6 +37,7 @@ type $$ComponentProps = {
|
|
|
36
37
|
tileSource?: Record<string, any>;
|
|
37
38
|
layers?: any[];
|
|
38
39
|
onclick?: any;
|
|
40
|
+
popup?: any;
|
|
39
41
|
height?: string;
|
|
40
42
|
class?: string;
|
|
41
43
|
} & Record<string, any>;
|