@dta-au/civictheme-twig 1.16.0 → 1.16.1
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/02-molecules/link-card/link-card.mdx +9 -9
- package/components/02-molecules/navigation-card/__snapshots__/navigation-card.test.js.snap +256 -8
- package/components/02-molecules/navigation-card/navigation-card.mdx +39 -0
- package/components/02-molecules/navigation-card/navigation-card.scss +17 -0
- package/components/02-molecules/navigation-card/navigation-card.stories.data.js +1 -0
- package/components/02-molecules/navigation-card/navigation-card.stories.js +18 -0
- package/components/02-molecules/navigation-card/navigation-card.test.js +59 -0
- package/components/02-molecules/navigation-card/navigation-card.twig +22 -3
- package/components/02-molecules/progress-nav/progress-nav.mdx +11 -11
- package/components/02-molecules/progress-tracker/progress-tracker.mdx +5 -5
- package/components/variables.components.scss +10 -0
- package/dist/civictheme.css +12 -0
- package/dist/civictheme.storybook.css +12 -0
- package/dist/civictheme.storybook.js +0 -336
- package/dist/civictheme.variables.css +2 -0
- package/dist/constants.json +2 -0
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ import * as Stories from './link-card.stories';
|
|
|
5
5
|
|
|
6
6
|
# Link card
|
|
7
7
|
|
|
8
|
-
A single link rendered as a compact, flat list row. One card is one link
|
|
8
|
+
A single link rendered as a compact, flat list row. One card is one link – the whole row is the target – with a trailing icon that signals the link type. A digital.gov.au extension of the CivicTheme card family; the group heading and spacing are owned by the **Link list** organism, so the card stays layout-free.
|
|
9
9
|
|
|
10
10
|
<Canvas of={Stories.LinkCard} />
|
|
11
11
|
|
|
@@ -18,24 +18,24 @@ A single link rendered as a compact, flat list row. One card is one link - the w
|
|
|
18
18
|
|
|
19
19
|
## When not to use
|
|
20
20
|
|
|
21
|
-
- a single standalone call to action
|
|
22
|
-
- rich cards with an image, summary, or metadata block
|
|
21
|
+
- a single standalone call to action – use a button or plain link
|
|
22
|
+
- rich cards with an image, summary, or metadata block – use a card from the card family instead
|
|
23
23
|
|
|
24
24
|
## Types
|
|
25
25
|
|
|
26
26
|
The trailing icon is set by the link type:
|
|
27
27
|
|
|
28
|
-
- **internal** (default)
|
|
29
|
-
- **external** (`is_external: true`)
|
|
30
|
-
- **download** (`variant: 'download'`)
|
|
28
|
+
- **internal** (default) – a right arrow
|
|
29
|
+
- **external** (`is_external: true`) – an upper-right arrow; pair with `is_new_window` for a new tab
|
|
30
|
+
- **download** (`variant: 'download'`) – a download icon, plus the optional `file_extension` and `file_size` beside the title
|
|
31
31
|
|
|
32
32
|
## Secured and locked
|
|
33
33
|
|
|
34
34
|
`is_secured` is orthogonal to the type: it adds a padlock beside the type icon for a gated resource (e.g. an internal page that needs sign-in), so a download can also be secured.
|
|
35
35
|
|
|
36
|
-
- **secured + navigable**
|
|
37
|
-
- **deactivated** (`is_deactivated: true`)
|
|
38
|
-
- discourage `is_secured` on external links
|
|
36
|
+
- **secured + navigable** – padlock alongside the type icon
|
|
37
|
+
- **deactivated** (`is_deactivated: true`) – a locked resource with no target; shows the padlock alone, dropping the directional arrow so it does not imply the card is navigable
|
|
38
|
+
- discourage `is_secured` on external links – the destination owns its own authentication
|
|
39
39
|
|
|
40
40
|
<Canvas of={Stories.SecuredDeactivated} />
|
|
41
41
|
|
|
@@ -1,10 +1,245 @@
|
|
|
1
1
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
|
2
2
|
|
|
3
|
+
exports[`Navigation Card Component default variant keeps the bottom arrow and emits no variant modifier 1`] = `
|
|
4
|
+
<div>
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
<div
|
|
11
|
+
class="ct-navigation-card ct-theme-light ct-navigation-card--with-link ct-navigation-card--card-clickable "
|
|
12
|
+
>
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
<div
|
|
17
|
+
class="ct-navigation-card__content"
|
|
18
|
+
>
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
<div
|
|
24
|
+
class="ct-navigation-card__title"
|
|
25
|
+
>
|
|
26
|
+
|
|
27
|
+
<a
|
|
28
|
+
class="ct-navigation-card__title-link"
|
|
29
|
+
href="https://example.com"
|
|
30
|
+
>
|
|
31
|
+
|
|
32
|
+
Card Title
|
|
33
|
+
</a>
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
<div
|
|
44
|
+
class="ct-paragraph ct-theme-light ct-paragraph--regular ct-paragraph--no-margin ct-navigation-card__summary"
|
|
45
|
+
>
|
|
46
|
+
This is a summary of the card.
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
<div
|
|
52
|
+
class="ct-navigation-card__tags"
|
|
53
|
+
>
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
<div
|
|
57
|
+
class="ct-navigation-card__link-graphic"
|
|
58
|
+
>
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
<svg
|
|
62
|
+
aria-hidden="true"
|
|
63
|
+
class="ct-icon ct-icon--size-regular"
|
|
64
|
+
height="24"
|
|
65
|
+
role="img"
|
|
66
|
+
viewBox="0 0 24 24"
|
|
67
|
+
width="24"
|
|
68
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
69
|
+
>
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
<path
|
|
73
|
+
d="M17.92 11.62C17.8724 11.4973 17.801 11.3851 17.71 11.29L12.71 6.29C12.6168 6.19676 12.5061 6.1228 12.3842 6.07234C12.2624 6.02188 12.1319 5.99591 12 5.99591C11.7337 5.99591 11.4783 6.1017 11.29 6.29C11.1968 6.38324 11.1228 6.49393 11.0723 6.61575C11.0219 6.73758 10.9959 6.86814 10.9959 7C10.9959 7.2663 11.1017 7.5217 11.29 7.71L14.59 11H7C6.73478 11 6.48043 11.1054 6.29289 11.2929C6.10536 11.4804 6 11.7348 6 12C6 12.2652 6.10536 12.5196 6.29289 12.7071C6.48043 12.8946 6.73478 13 7 13H14.59L11.29 16.29C11.1963 16.383 11.1219 16.4936 11.0711 16.6154C11.0203 16.7373 10.9942 16.868 10.9942 17C10.9942 17.132 11.0203 17.2627 11.0711 17.3846C11.1219 17.5064 11.1963 17.617 11.29 17.71C11.383 17.8037 11.4936 17.8781 11.6154 17.9289C11.7373 17.9797 11.868 18.0058 12 18.0058C12.132 18.0058 12.2627 17.9797 12.3846 17.9289C12.5064 17.8781 12.617 17.8037 12.71 17.71L17.71 12.71C17.801 12.6149 17.8724 12.5028 17.92 12.38C18.02 12.1365 18.02 11.8635 17.92 11.62Z"
|
|
74
|
+
/>
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
</svg>
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
</div>
|
|
93
|
+
`;
|
|
94
|
+
|
|
3
95
|
exports[`Navigation Card Component does not render when title is empty 1`] = `
|
|
4
96
|
<div>
|
|
5
97
|
|
|
6
98
|
|
|
7
99
|
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
</div>
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
exports[`Navigation Card Component index variant renders its modifier class and moves the arrow onto the title 1`] = `
|
|
106
|
+
<div>
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
<div
|
|
113
|
+
class="ct-navigation-card ct-theme-light ct-navigation-card--index ct-navigation-card--with-link ct-navigation-card--card-clickable "
|
|
114
|
+
>
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
<div
|
|
119
|
+
class="ct-navigation-card__content"
|
|
120
|
+
>
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
<div
|
|
126
|
+
class="ct-navigation-card__title"
|
|
127
|
+
>
|
|
128
|
+
|
|
129
|
+
<a
|
|
130
|
+
class="ct-navigation-card__title-link"
|
|
131
|
+
href="https://example.com"
|
|
132
|
+
>
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
<span
|
|
137
|
+
class="ct-text-icon__text"
|
|
138
|
+
>
|
|
139
|
+
Card
|
|
140
|
+
</span>
|
|
141
|
+
<span
|
|
142
|
+
class="ct-text-icon__group"
|
|
143
|
+
>
|
|
144
|
+
<span
|
|
145
|
+
class="ct-text-icon__text"
|
|
146
|
+
>
|
|
147
|
+
Title
|
|
148
|
+
</span>
|
|
149
|
+
|
|
150
|
+
<svg
|
|
151
|
+
aria-hidden="true"
|
|
152
|
+
class="ct-icon"
|
|
153
|
+
height="24"
|
|
154
|
+
role="img"
|
|
155
|
+
viewBox="0 0 24 24"
|
|
156
|
+
width="24"
|
|
157
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
158
|
+
>
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
<path
|
|
162
|
+
d="M15.5397 11.29L9.87974 5.64C9.78677 5.54627 9.67617 5.47188 9.55431 5.42111C9.43246 5.37034 9.30175 5.3442 9.16974 5.3442C9.03773 5.3442 8.90702 5.37034 8.78516 5.42111C8.6633 5.47188 8.5527 5.54627 8.45974 5.64C8.27349 5.82736 8.16895 6.08081 8.16895 6.345C8.16895 6.60918 8.27349 6.86264 8.45974 7.05L13.4097 12.05L8.45974 17C8.27349 17.1874 8.16895 17.4408 8.16895 17.705C8.16895 17.9692 8.27349 18.2226 8.45974 18.41C8.55235 18.5045 8.6628 18.5797 8.78467 18.6312C8.90655 18.6826 9.03743 18.7094 9.16974 18.71C9.30204 18.7094 9.43293 18.6826 9.5548 18.6312C9.67668 18.5797 9.78712 18.5045 9.87974 18.41L15.5397 12.76C15.6412 12.6664 15.7223 12.5527 15.7777 12.4262C15.8331 12.2997 15.8617 12.1631 15.8617 12.025C15.8617 11.8869 15.8331 11.7503 15.7777 11.6238C15.7223 11.4973 15.6412 11.3836 15.5397 11.29Z"
|
|
163
|
+
/>
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
</svg>
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
</span>
|
|
170
|
+
</a>
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
<div
|
|
181
|
+
class="ct-paragraph ct-theme-light ct-paragraph--regular ct-paragraph--no-margin ct-navigation-card__summary"
|
|
182
|
+
>
|
|
183
|
+
This is a summary of the card.
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
</div>
|
|
195
|
+
`;
|
|
196
|
+
|
|
197
|
+
exports[`Navigation Card Component index variant renders no arrow when only the title is clickable 1`] = `
|
|
198
|
+
<div>
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
<div
|
|
205
|
+
class="ct-navigation-card ct-theme-light ct-navigation-card--index ct-navigation-card--with-link "
|
|
206
|
+
>
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
<div
|
|
211
|
+
class="ct-navigation-card__content"
|
|
212
|
+
>
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
<div
|
|
218
|
+
class="ct-navigation-card__title"
|
|
219
|
+
>
|
|
220
|
+
|
|
221
|
+
<a
|
|
222
|
+
class="ct-navigation-card__title-link"
|
|
223
|
+
href="https://example.com"
|
|
224
|
+
>
|
|
225
|
+
|
|
226
|
+
Card Title
|
|
227
|
+
</a>
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
|
|
8
243
|
</div>
|
|
9
244
|
`;
|
|
10
245
|
|
|
@@ -12,9 +247,11 @@ exports[`Navigation Card Component renders with image as icon 1`] = `
|
|
|
12
247
|
<div>
|
|
13
248
|
|
|
14
249
|
|
|
250
|
+
|
|
251
|
+
|
|
15
252
|
|
|
16
253
|
<div
|
|
17
|
-
class="ct-navigation-card ct-theme-light
|
|
254
|
+
class="ct-navigation-card ct-theme-light ct-navigation-card--with-image ct-navigation-card--image-as-icon "
|
|
18
255
|
>
|
|
19
256
|
|
|
20
257
|
|
|
@@ -77,9 +314,11 @@ exports[`Navigation Card Component renders with link when provided 1`] = `
|
|
|
77
314
|
<div>
|
|
78
315
|
|
|
79
316
|
|
|
317
|
+
|
|
318
|
+
|
|
80
319
|
|
|
81
320
|
<div
|
|
82
|
-
class="ct-navigation-card ct-theme-light
|
|
321
|
+
class="ct-navigation-card ct-theme-light ct-navigation-card--with-link ct-navigation-card--card-clickable "
|
|
83
322
|
>
|
|
84
323
|
|
|
85
324
|
|
|
@@ -99,7 +338,8 @@ exports[`Navigation Card Component renders with link when provided 1`] = `
|
|
|
99
338
|
class="ct-navigation-card__title-link"
|
|
100
339
|
href="https://example.com"
|
|
101
340
|
>
|
|
102
|
-
|
|
341
|
+
|
|
342
|
+
Card Title
|
|
103
343
|
</a>
|
|
104
344
|
|
|
105
345
|
|
|
@@ -166,9 +406,11 @@ exports[`Navigation Card Component renders with optional attributes, no icon, im
|
|
|
166
406
|
<div>
|
|
167
407
|
|
|
168
408
|
|
|
409
|
+
|
|
410
|
+
|
|
169
411
|
|
|
170
412
|
<div
|
|
171
|
-
class="ct-navigation-card ct-theme-dark
|
|
413
|
+
class="ct-navigation-card ct-theme-dark ct-navigation-card--with-image ct-navigation-card--with-link ct-navigation-card--card-clickable custom-class"
|
|
172
414
|
data-test="true"
|
|
173
415
|
>
|
|
174
416
|
|
|
@@ -225,7 +467,8 @@ exports[`Navigation Card Component renders with optional attributes, no icon, im
|
|
|
225
467
|
href="https://example.com"
|
|
226
468
|
target="_blank"
|
|
227
469
|
>
|
|
228
|
-
|
|
470
|
+
|
|
471
|
+
Card Title
|
|
229
472
|
</a>
|
|
230
473
|
|
|
231
474
|
|
|
@@ -306,9 +549,11 @@ exports[`Navigation Card Component renders with optional attributes, with icon 1
|
|
|
306
549
|
<div>
|
|
307
550
|
|
|
308
551
|
|
|
552
|
+
|
|
553
|
+
|
|
309
554
|
|
|
310
555
|
<div
|
|
311
|
-
class="ct-navigation-card ct-theme-dark
|
|
556
|
+
class="ct-navigation-card ct-theme-dark ct-navigation-card--with-image ct-navigation-card--with-link ct-navigation-card--image-as-icon ct-navigation-card--card-clickable custom-class"
|
|
312
557
|
data-test="true"
|
|
313
558
|
>
|
|
314
559
|
|
|
@@ -382,7 +627,8 @@ exports[`Navigation Card Component renders with optional attributes, with icon 1
|
|
|
382
627
|
href="https://example.com"
|
|
383
628
|
target="_blank"
|
|
384
629
|
>
|
|
385
|
-
|
|
630
|
+
|
|
631
|
+
Card Title
|
|
386
632
|
</a>
|
|
387
633
|
|
|
388
634
|
|
|
@@ -463,9 +709,11 @@ exports[`Navigation Card Component renders with required attributes 1`] = `
|
|
|
463
709
|
<div>
|
|
464
710
|
|
|
465
711
|
|
|
712
|
+
|
|
713
|
+
|
|
466
714
|
|
|
467
715
|
<div
|
|
468
|
-
class="ct-navigation-card ct-theme-light
|
|
716
|
+
class="ct-navigation-card ct-theme-light "
|
|
469
717
|
>
|
|
470
718
|
|
|
471
719
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
|
|
2
|
+
import * as Stories from './navigation-card.stories';
|
|
3
|
+
|
|
4
|
+
<Meta of={Stories} />
|
|
5
|
+
|
|
6
|
+
# Navigation card
|
|
7
|
+
|
|
8
|
+
A card that points to another page, pairing a title link with a short summary and an optional image or icon. The whole card is the click target.
|
|
9
|
+
|
|
10
|
+
<Canvas of={Stories.NavigationCard} />
|
|
11
|
+
|
|
12
|
+
<Controls of={Stories.NavigationCard} />
|
|
13
|
+
|
|
14
|
+
## When to use
|
|
15
|
+
|
|
16
|
+
- a grid or list of destinations, each needing a title and a line or two of explanation
|
|
17
|
+
- landing pages and section indexes, where the summary helps people choose where to go
|
|
18
|
+
|
|
19
|
+
## When not to use
|
|
20
|
+
|
|
21
|
+
- a bare list of links with no summary – use the **Link card** and **Link list** instead
|
|
22
|
+
- a single standalone call to action – use a button or plain link
|
|
23
|
+
|
|
24
|
+
## Variants
|
|
25
|
+
|
|
26
|
+
- **default** – the directional arrow sits in the bottom corner of the card, below the summary
|
|
27
|
+
- **index** (`variant: 'index'`) – the arrow moves inline to the end of the title, and a divider rule separates the title from the summary
|
|
28
|
+
|
|
29
|
+
Both keep the whole card as the click target; the variant changes where the arrow sits, not how the card behaves. Use **index** for dense, text-only listings where the arrow reads better attached to the title than floating below the summary.
|
|
30
|
+
|
|
31
|
+
<Canvas of={Stories.NavigationCardIndex} />
|
|
32
|
+
|
|
33
|
+
The index divider takes the standard border token, so it follows the active theme. The variant sets no colour or surface of its own – a consuming theme controls those through `$ct-navigation-card-*`.
|
|
34
|
+
|
|
35
|
+
## Accessibility
|
|
36
|
+
|
|
37
|
+
- the whole card is a single link; the directional arrow is decorative (`aria-hidden`) in both variants, so assistive tech announces the title, not the icon
|
|
38
|
+
- on the index variant the arrow is grouped with the last word of the title in a nowrap span, so it cannot wrap onto a line of its own
|
|
39
|
+
- `is_title_click` narrows the target to the title alone; neither variant renders the arrow in that state
|
|
@@ -32,6 +32,17 @@
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Arrow moves inline onto the title, with a divider rule below it. The
|
|
36
|
+
// whole-card click target is unchanged - ct-link-cover-parent still applies.
|
|
37
|
+
// The arrow itself needs no rules: civictheme:text-icon supplies the nowrap
|
|
38
|
+
// grouping and the preceding space, and a size-less .ct-icon is 1em.
|
|
39
|
+
&--index {
|
|
40
|
+
#{$root}__title {
|
|
41
|
+
padding-bottom: ct-spacing(2);
|
|
42
|
+
border-bottom: ct-particle(0.125) solid;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
35
46
|
@container (min-width: #{$ct-navigation-card-orientation-threshold}) {
|
|
36
47
|
flex-direction: row;
|
|
37
48
|
}
|
|
@@ -150,6 +161,12 @@
|
|
|
150
161
|
}
|
|
151
162
|
}
|
|
152
163
|
|
|
164
|
+
&#{$root}--index {
|
|
165
|
+
#{$root}__title {
|
|
166
|
+
@include ct-component-property($root, $theme, divider, border-color);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
153
170
|
#{$root}__title,
|
|
154
171
|
#{$root}__title-link {
|
|
155
172
|
@include ct-component-property($root, $theme, title, color);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export default {
|
|
2
2
|
args: (theme = 'light') => ({
|
|
3
3
|
theme,
|
|
4
|
+
variant: 'default',
|
|
4
5
|
title: 'Navigation card heading which runs across two or three lines',
|
|
5
6
|
summary: 'Bring to the table win-win survival strategies to ensure proactive domination. At the end of the day, going forward, a new normal that has evolved from generation X is on the runway heading towards a streamlined cloud solution. User generated content in real-time will have multiple touchpoints for offshoring.',
|
|
6
7
|
link: {
|
|
@@ -14,6 +14,10 @@ const meta = {
|
|
|
14
14
|
control: { type: 'radio' },
|
|
15
15
|
options: ['light', 'dark'],
|
|
16
16
|
},
|
|
17
|
+
variant: {
|
|
18
|
+
control: { type: 'radio' },
|
|
19
|
+
options: ['default', 'index'],
|
|
20
|
+
},
|
|
17
21
|
title: {
|
|
18
22
|
control: { type: 'text' },
|
|
19
23
|
},
|
|
@@ -62,3 +66,17 @@ export const NavigationCard = {
|
|
|
62
66
|
},
|
|
63
67
|
args: NavigationCardData.args('light'),
|
|
64
68
|
};
|
|
69
|
+
|
|
70
|
+
export const NavigationCardIndex = {
|
|
71
|
+
parameters: {
|
|
72
|
+
layout: 'centered',
|
|
73
|
+
},
|
|
74
|
+
args: {
|
|
75
|
+
...NavigationCardData.args('light'),
|
|
76
|
+
variant: 'index',
|
|
77
|
+
title: 'Prepare your digital proposal',
|
|
78
|
+
summary: 'Understand the requirements of the investment process, meet your policy obligations and find information to support your proposal and business case.',
|
|
79
|
+
// The index variant is a text-only card - no image, no icon.
|
|
80
|
+
image: { url: '', alt: '' },
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -143,4 +143,63 @@ describe('Navigation Card Component', () => {
|
|
|
143
143
|
|
|
144
144
|
assertUniqueCssClasses(c);
|
|
145
145
|
});
|
|
146
|
+
|
|
147
|
+
test('index variant renders its modifier class and moves the arrow onto the title', async () => {
|
|
148
|
+
const c = await dom(template, {
|
|
149
|
+
variant: 'index',
|
|
150
|
+
title: 'Card Title',
|
|
151
|
+
summary: 'This is a summary of the card.',
|
|
152
|
+
link: { text: 'Learn more', url: 'https://example.com' },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const element = c.querySelector('.ct-navigation-card');
|
|
156
|
+
expect(element.classList.contains('ct-navigation-card--index')).toBe(true);
|
|
157
|
+
// The whole card stays the click target, exactly as on the default variant.
|
|
158
|
+
expect(element.classList.contains('ct-navigation-card--card-clickable')).toBe(true);
|
|
159
|
+
|
|
160
|
+
// text-icon groups the last word with the arrow, so it cannot orphan.
|
|
161
|
+
const group = c.querySelector('.ct-navigation-card__title-link .ct-text-icon__group');
|
|
162
|
+
expect(group).toBeTruthy();
|
|
163
|
+
expect(group.textContent.trim()).toEqual('Title');
|
|
164
|
+
|
|
165
|
+
const icon = group.querySelector('.ct-icon');
|
|
166
|
+
expect(icon).toBeTruthy();
|
|
167
|
+
expect(icon.getAttribute('aria-hidden')).toEqual('true');
|
|
168
|
+
expect(c.querySelector('.ct-navigation-card__link-graphic')).toBeNull();
|
|
169
|
+
|
|
170
|
+
// The decorative arrow must not leak into the link's accessible name.
|
|
171
|
+
expect(c.querySelector('.ct-navigation-card__title-link').textContent.trim()).toEqual('Card Title');
|
|
172
|
+
|
|
173
|
+
assertUniqueCssClasses(c);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('default variant keeps the bottom arrow and emits no variant modifier', async () => {
|
|
177
|
+
const c = await dom(template, {
|
|
178
|
+
title: 'Card Title',
|
|
179
|
+
summary: 'This is a summary of the card.',
|
|
180
|
+
link: { text: 'Learn more', url: 'https://example.com' },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const element = c.querySelector('.ct-navigation-card');
|
|
184
|
+
expect(element.className).not.toContain('ct-navigation-card--index');
|
|
185
|
+
expect(element.className).not.toContain('ct-navigation-card--default');
|
|
186
|
+
expect(c.querySelector('.ct-navigation-card__link-graphic')).toBeTruthy();
|
|
187
|
+
expect(c.querySelector('.ct-navigation-card__title-link .ct-text-icon__group')).toBeNull();
|
|
188
|
+
|
|
189
|
+
assertUniqueCssClasses(c);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('index variant renders no arrow when only the title is clickable', async () => {
|
|
193
|
+
const c = await dom(template, {
|
|
194
|
+
variant: 'index',
|
|
195
|
+
title: 'Card Title',
|
|
196
|
+
link: { text: 'Learn more', url: 'https://example.com' },
|
|
197
|
+
is_title_click: true,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(c.querySelector('.ct-navigation-card__title-link .ct-text-icon__group')).toBeNull();
|
|
201
|
+
expect(c.querySelector('.ct-navigation-card__link-graphic')).toBeNull();
|
|
202
|
+
|
|
203
|
+
assertUniqueCssClasses(c);
|
|
204
|
+
});
|
|
146
205
|
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Props:
|
|
7
7
|
* - theme: [string] Theme variation (light or dark).
|
|
8
|
+
* - variant: [string] Card variant. "index" renders the directional arrow inline after the title and separates the title from the summary with a divider rule. The default places the arrow in the bottom corner. Both keep the whole card as the click target.
|
|
8
9
|
* - image_as_icon: [boolean] Whether to show the image as an icon.
|
|
9
10
|
* - modifier_class: [string] Additional CSS classes.
|
|
10
11
|
* - image: [object] Card image.
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
*/
|
|
42
43
|
#}
|
|
43
44
|
|
|
45
|
+
{% set variant = variant in ['default', 'index'] ? variant : 'default' %}
|
|
44
46
|
{% set with_image = image.url is not empty %}
|
|
45
47
|
{% set with_image_class = with_image ? 'ct-navigation-card--with-image' : '' %}
|
|
46
48
|
{% set with_link = link is not empty and link.url is defined and link.url is not empty %}
|
|
@@ -49,7 +51,11 @@
|
|
|
49
51
|
{% set card_clickable_class = is_card_clickable ? 'ct-navigation-card--card-clickable' : '' %}
|
|
50
52
|
{% set image_as_icon_class = image_as_icon ? 'ct-navigation-card--image-as-icon' : '' %}
|
|
51
53
|
{% set theme_class = 'ct-theme-%s'|format(theme|default('light')) %}
|
|
52
|
-
{
|
|
54
|
+
{# Default emits no modifier, keeping existing cards unchanged (cf. step-by-step-nav). #}
|
|
55
|
+
{% set variant_class = variant != 'default' ? 'ct-navigation-card--%s'|format(variant) : '' %}
|
|
56
|
+
{# Index moves the arrow onto the title, so both placements share stock's condition. #}
|
|
57
|
+
{% set with_title_icon = variant == 'index' and is_card_clickable %}
|
|
58
|
+
{% set modifier_class = '%s %s %s %s %s %s %s'|format(theme_class, variant_class, with_image_class, with_link_class, image_as_icon_class, card_clickable_class, modifier_class|default('')) %}
|
|
53
59
|
|
|
54
60
|
{% if title is not empty %}
|
|
55
61
|
<div class="ct-navigation-card {{ modifier_class -}}" {% if attributes is defined and attributes is not null %}{{- attributes -}}{% endif %}>
|
|
@@ -114,7 +120,20 @@
|
|
|
114
120
|
class="ct-navigation-card__title-link"
|
|
115
121
|
href="{{ link.url }}"
|
|
116
122
|
{% if link.is_new_window %}target="_blank"{% endif %}
|
|
117
|
-
>
|
|
123
|
+
>
|
|
124
|
+
{#- text-icon groups the last word with the icon in a nowrap span, so the
|
|
125
|
+
arrow cannot orphan onto its own line. Size-less icons are 1em, so it
|
|
126
|
+
scales with the title; the icon atom sets aria-hidden itself (a11y #C). -#}
|
|
127
|
+
{%- if with_title_icon -%}
|
|
128
|
+
{%- include '@base/text-icon/text-icon.twig' with {
|
|
129
|
+
text: title,
|
|
130
|
+
icon: 'right-arrow-1',
|
|
131
|
+
icon_placement: 'after',
|
|
132
|
+
} only -%}
|
|
133
|
+
{%- else -%}
|
|
134
|
+
{{- title -}}
|
|
135
|
+
{%- endif -%}
|
|
136
|
+
</a>
|
|
118
137
|
{% else %}
|
|
119
138
|
{{ title }}
|
|
120
139
|
{% endif %}
|
|
@@ -150,7 +169,7 @@
|
|
|
150
169
|
{% endif %}
|
|
151
170
|
{% endblock %}
|
|
152
171
|
|
|
153
|
-
{% if is_card_clickable %}
|
|
172
|
+
{% if is_card_clickable and not with_title_icon %}
|
|
154
173
|
<div class="ct-navigation-card__tags">
|
|
155
174
|
<div class="ct-navigation-card__link-graphic">
|
|
156
175
|
{% include '@base/icon/icon.twig' with {
|
|
@@ -21,25 +21,25 @@ It follows the GOV.UK Design System task-list pattern, with a ghost-to-solid vis
|
|
|
21
21
|
|
|
22
22
|
## When not to use
|
|
23
23
|
|
|
24
|
-
- a single-page form
|
|
25
|
-
- a flow whose path is unknown ahead of time (heavily branching)
|
|
24
|
+
- a single-page form – it adds overhead with no benefit
|
|
25
|
+
- a flow whose path is unknown ahead of time (heavily branching) – the list implies a known set of steps. For a branching client-side check, see the **Decision tool**, which shows only the path taken.
|
|
26
26
|
|
|
27
27
|
## Statuses
|
|
28
28
|
|
|
29
29
|
Each item takes a `status` that selects its marker:
|
|
30
30
|
|
|
31
|
-
- **completed**
|
|
32
|
-
- **saved**
|
|
33
|
-
- **in-progress**
|
|
34
|
-
- **error**
|
|
35
|
-
- **todo**
|
|
36
|
-
- **cannot-start-yet**
|
|
31
|
+
- **completed** – filled circle with a check
|
|
32
|
+
- **saved** – filled circle with a dashed check (saved but revisitable)
|
|
33
|
+
- **in-progress** – outline circle with a filled centre dot; pair with `is_active: true`
|
|
34
|
+
- **error** – filled circle with an exclamation. Prefer handling errors inline on the step page; use this only for a saved step that still has a problem.
|
|
35
|
+
- **todo** – dashed ghost circle ("Not started")
|
|
36
|
+
- **cannot-start-yet** – a padlock; rendered as a non-interactive span, not a link
|
|
37
37
|
|
|
38
38
|
## Accessibility
|
|
39
39
|
|
|
40
|
-
- each step's link is linked to a visually-hidden status element via `aria-describedby`, so a screen reader announces "[step], link
|
|
40
|
+
- each step's link is linked to a visually-hidden status element via `aria-describedby`, so a screen reader announces "[step], link – [status]" as one unit
|
|
41
41
|
- the active step carries `aria-current="step"`
|
|
42
|
-
- `cannot-start-yet` steps use `aria-disabled="true"` on a span
|
|
42
|
+
- `cannot-start-yet` steps use `aria-disabled="true"` on a span – never a disabled link
|
|
43
43
|
- markers never rely on colour alone; the status text and the marker shape carry the meaning
|
|
44
44
|
- contrast is tuned so muted and disabled markers still meet WCAG 1.4.11 (3:1 for UI components)
|
|
45
45
|
|
|
@@ -47,7 +47,7 @@ Each item takes a `status` that selects its marker:
|
|
|
47
47
|
|
|
48
48
|
- keep step titles short and matching the destination page heading
|
|
49
49
|
- show progress with the `current_step` / `total_steps` counter, or a `progress_summary` override
|
|
50
|
-
- keep one source of truth for status
|
|
50
|
+
- keep one source of truth for status – drive it from the form's own step state
|
|
51
51
|
|
|
52
52
|
## Don't
|
|
53
53
|
|
|
@@ -11,7 +11,7 @@ A horizontal step tracker: a row of markers joined by a connector line that fill
|
|
|
11
11
|
|
|
12
12
|
<Controls of={Stories.Default} />
|
|
13
13
|
|
|
14
|
-
It uses the `progress-tracker` / `progress-step` / `progress-marker` class language
|
|
14
|
+
It uses the `progress-tracker` / `progress-step` / `progress-marker` class language – the same one the NigelOToole progress-tracker and the Drupal Webform progress-tracker integration emit – so these styles also apply to that server-rendered markup.
|
|
15
15
|
|
|
16
16
|
## When to use
|
|
17
17
|
|
|
@@ -20,16 +20,16 @@ It uses the `progress-tracker` / `progress-step` / `progress-marker` class langu
|
|
|
20
20
|
|
|
21
21
|
## When not to use
|
|
22
22
|
|
|
23
|
-
- a long list of steps with descriptions
|
|
23
|
+
- a long list of steps with descriptions – use the [Progress nav](?path=/docs/forms-progress-nav--docs) task list, which has room for status text and hints
|
|
24
24
|
- a single step
|
|
25
25
|
|
|
26
26
|
## Statuses
|
|
27
27
|
|
|
28
28
|
Each step takes a `status`:
|
|
29
29
|
|
|
30
|
-
- **complete**
|
|
31
|
-
- **active**
|
|
32
|
-
- **todo**
|
|
30
|
+
- **complete** – filled marker with a check; its outgoing connector is brand-coloured
|
|
31
|
+
- **active** – outlined marker with its number and a soft ring; pair with the current step
|
|
32
|
+
- **todo** – outlined marker with its number, muted
|
|
33
33
|
|
|
34
34
|
## Accessibility
|
|
35
35
|
|
|
@@ -23,6 +23,16 @@ $ct-link-card-dark-link-color: ct-color-dark('body') !default;
|
|
|
23
23
|
$ct-link-card-dark-link-hover-color: ct-color-dark('interaction-background') !default;
|
|
24
24
|
$ct-link-card-dark-deactivated-link-color: ct-color-dark('body') !default;
|
|
25
25
|
|
|
26
|
+
//
|
|
27
|
+
// Navigation Card (digital.gov.au).
|
|
28
|
+
//
|
|
29
|
+
// Divider rule between title and summary on the "index" variant. Upstream
|
|
30
|
+
// navigation-card has no divider, so this is declared here rather than in
|
|
31
|
+
// 00-base. Uses the same border token as the card's hover outline.
|
|
32
|
+
//
|
|
33
|
+
$ct-navigation-card-light-divider-border-color: ct-color-light('border') !default;
|
|
34
|
+
$ct-navigation-card-dark-divider-border-color: ct-color-dark('border') !default;
|
|
35
|
+
|
|
26
36
|
//
|
|
27
37
|
// Link List (digital.gov.au).
|
|
28
38
|
//
|
package/dist/civictheme.css
CHANGED
|
@@ -8123,6 +8123,10 @@ ol ol ol {
|
|
|
8123
8123
|
left: 0;
|
|
8124
8124
|
right: 0;
|
|
8125
8125
|
}
|
|
8126
|
+
.ct-navigation-card--index .ct-navigation-card__title {
|
|
8127
|
+
padding-bottom: 1rem;
|
|
8128
|
+
border-bottom: 0.0625rem solid;
|
|
8129
|
+
}
|
|
8126
8130
|
@container (min-width: 36rem) {
|
|
8127
8131
|
.ct-navigation-card {
|
|
8128
8132
|
flex-direction: row;
|
|
@@ -8238,6 +8242,9 @@ ol ol ol {
|
|
|
8238
8242
|
.ct-navigation-card.ct-theme-light:not(.ct-navigation-card--card-clickable) .ct-navigation-card__title-link:hover {
|
|
8239
8243
|
color: var(--ct-navigation-card-light-title-hover-color);
|
|
8240
8244
|
}
|
|
8245
|
+
.ct-navigation-card.ct-theme-light.ct-navigation-card--index .ct-navigation-card__title {
|
|
8246
|
+
border-color: var(--ct-navigation-card-light-divider-border-color);
|
|
8247
|
+
}
|
|
8241
8248
|
.ct-navigation-card.ct-theme-light .ct-navigation-card__title,
|
|
8242
8249
|
.ct-navigation-card.ct-theme-light .ct-navigation-card__title-link {
|
|
8243
8250
|
color: var(--ct-navigation-card-light-title-color);
|
|
@@ -8261,6 +8268,9 @@ ol ol ol {
|
|
|
8261
8268
|
.ct-navigation-card.ct-theme-dark:not(.ct-navigation-card--card-clickable) .ct-navigation-card__title-link:hover {
|
|
8262
8269
|
color: var(--ct-navigation-card-dark-title-hover-color);
|
|
8263
8270
|
}
|
|
8271
|
+
.ct-navigation-card.ct-theme-dark.ct-navigation-card--index .ct-navigation-card__title {
|
|
8272
|
+
border-color: var(--ct-navigation-card-dark-divider-border-color);
|
|
8273
|
+
}
|
|
8264
8274
|
.ct-navigation-card.ct-theme-dark .ct-navigation-card__title,
|
|
8265
8275
|
.ct-navigation-card.ct-theme-dark .ct-navigation-card__title-link {
|
|
8266
8276
|
color: var(--ct-navigation-card-dark-title-color);
|
|
@@ -14191,6 +14201,8 @@ html {
|
|
|
14191
14201
|
--ct-link-card-dark-link-color: var(--ct-color-dark-body);
|
|
14192
14202
|
--ct-link-card-dark-link-hover-color: var(--ct-color-dark-interaction-background);
|
|
14193
14203
|
--ct-link-card-dark-deactivated-link-color: var(--ct-color-dark-body);
|
|
14204
|
+
--ct-navigation-card-light-divider-border-color: var(--ct-color-light-border);
|
|
14205
|
+
--ct-navigation-card-dark-divider-border-color: var(--ct-color-dark-border);
|
|
14194
14206
|
--ct-link-list-connected-border-radius: 0.25rem;
|
|
14195
14207
|
--ct-link-list-light-connected-border-color: var(--ct-color-light-border);
|
|
14196
14208
|
--ct-link-list-dark-connected-border-color: var(--ct-color-dark-border);
|
|
@@ -8123,6 +8123,10 @@ ol ol ol {
|
|
|
8123
8123
|
left: 0;
|
|
8124
8124
|
right: 0;
|
|
8125
8125
|
}
|
|
8126
|
+
.ct-navigation-card--index .ct-navigation-card__title {
|
|
8127
|
+
padding-bottom: 1rem;
|
|
8128
|
+
border-bottom: 0.0625rem solid;
|
|
8129
|
+
}
|
|
8126
8130
|
@container (min-width: 36rem) {
|
|
8127
8131
|
.ct-navigation-card {
|
|
8128
8132
|
flex-direction: row;
|
|
@@ -8238,6 +8242,9 @@ ol ol ol {
|
|
|
8238
8242
|
.ct-navigation-card.ct-theme-light:not(.ct-navigation-card--card-clickable) .ct-navigation-card__title-link:hover {
|
|
8239
8243
|
color: var(--ct-navigation-card-light-title-hover-color);
|
|
8240
8244
|
}
|
|
8245
|
+
.ct-navigation-card.ct-theme-light.ct-navigation-card--index .ct-navigation-card__title {
|
|
8246
|
+
border-color: var(--ct-navigation-card-light-divider-border-color);
|
|
8247
|
+
}
|
|
8241
8248
|
.ct-navigation-card.ct-theme-light .ct-navigation-card__title,
|
|
8242
8249
|
.ct-navigation-card.ct-theme-light .ct-navigation-card__title-link {
|
|
8243
8250
|
color: var(--ct-navigation-card-light-title-color);
|
|
@@ -8261,6 +8268,9 @@ ol ol ol {
|
|
|
8261
8268
|
.ct-navigation-card.ct-theme-dark:not(.ct-navigation-card--card-clickable) .ct-navigation-card__title-link:hover {
|
|
8262
8269
|
color: var(--ct-navigation-card-dark-title-hover-color);
|
|
8263
8270
|
}
|
|
8271
|
+
.ct-navigation-card.ct-theme-dark.ct-navigation-card--index .ct-navigation-card__title {
|
|
8272
|
+
border-color: var(--ct-navigation-card-dark-divider-border-color);
|
|
8273
|
+
}
|
|
8264
8274
|
.ct-navigation-card.ct-theme-dark .ct-navigation-card__title,
|
|
8265
8275
|
.ct-navigation-card.ct-theme-dark .ct-navigation-card__title-link {
|
|
8266
8276
|
color: var(--ct-navigation-card-dark-title-color);
|
|
@@ -14191,6 +14201,8 @@ html {
|
|
|
14191
14201
|
--ct-link-card-dark-link-color: var(--ct-color-dark-body);
|
|
14192
14202
|
--ct-link-card-dark-link-hover-color: var(--ct-color-dark-interaction-background);
|
|
14193
14203
|
--ct-link-card-dark-deactivated-link-color: var(--ct-color-dark-body);
|
|
14204
|
+
--ct-navigation-card-light-divider-border-color: var(--ct-color-light-border);
|
|
14205
|
+
--ct-navigation-card-dark-divider-border-color: var(--ct-color-dark-border);
|
|
14194
14206
|
--ct-link-list-connected-border-radius: 0.25rem;
|
|
14195
14207
|
--ct-link-list-light-connected-border-color: var(--ct-color-light-border);
|
|
14196
14208
|
--ct-link-list-dark-connected-border-color: var(--ct-color-dark-border);
|
|
@@ -6147,342 +6147,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
6147
6147
|
);
|
|
6148
6148
|
})();
|
|
6149
6149
|
|
|
6150
|
-
});
|
|
6151
|
-
document.addEventListener('DOMContentLoaded', () => {
|
|
6152
|
-
/**
|
|
6153
|
-
* Chart data core - framework-agnostic.
|
|
6154
|
-
*
|
|
6155
|
-
* Pure data helpers shared by the chart renderer (chart.js) and the bdga
|
|
6156
|
-
* paragraph editor. No DOM, no D3, no Drupal. Authored as ESM; built to
|
|
6157
|
-
* ESM + UMD so the editor's plain-JS admin library can load it too.
|
|
6158
|
-
*
|
|
6159
|
-
* Parity contract: isNumeric / autodetectKeys / validate mirror the PHP
|
|
6160
|
-
* save-time parsers in bdga (chart_postprocess.inc). Pinned by
|
|
6161
|
-
* tests/fixtures/chart_data_parity.json - PHP-generated, the authority.
|
|
6162
|
-
* Keep both sides green when editing these three.
|
|
6163
|
-
*/
|
|
6164
|
-
|
|
6165
|
-
export const ALLOWED_HOSTS = ['data.gov.au', 'www.data.gov.au'];
|
|
6166
|
-
export const MAX_ROWS = 5000;
|
|
6167
|
-
export const MAX_CELL_CHARS = 500;
|
|
6168
|
-
export const MAX_PAYLOAD_BYTES = 256 * 1024;
|
|
6169
|
-
export const FETCH_TIMEOUT_MS = 10000;
|
|
6170
|
-
|
|
6171
|
-
// Port of PHP 8 is_numeric(). PHP 8 accepts leading AND trailing whitespace;
|
|
6172
|
-
// rejects thousands separators, hex/binary/underscore literals, and the bare
|
|
6173
|
-
// strings "INF"/"NAN". JSON numbers arrive as real numbers, hence the Number
|
|
6174
|
-
// branch.
|
|
6175
|
-
const PHP8_NUMERIC = /^[ \t\n\r\v\f]*[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?[ \t\n\r\v\f]*$/;
|
|
6176
|
-
export function isNumeric(value) {
|
|
6177
|
-
if (typeof value === 'number') return Number.isFinite(value);
|
|
6178
|
-
if (typeof value !== 'string') return false;
|
|
6179
|
-
return PHP8_NUMERIC.test(value);
|
|
6180
|
-
}
|
|
6181
|
-
|
|
6182
|
-
// Columns treated as the category axis even when numeric: a year is a label,
|
|
6183
|
-
// not a measurement. Exact match after trim + lowercase, not substring.
|
|
6184
|
-
const TEMPORAL_KEYS = new Set([
|
|
6185
|
-
'year', 'years', 'fy', 'fiscal_year', 'financial_year', 'reporting_year',
|
|
6186
|
-
'date', 'datetime', 'timestamp', 'month', 'quarter', 'period',
|
|
6187
|
-
]);
|
|
6188
|
-
const isTemporal = (header) => TEMPORAL_KEYS.has(String(header).trim().toLowerCase());
|
|
6189
|
-
|
|
6190
|
-
/**
|
|
6191
|
-
* Pick the X (category) and Y (series) keys from a record set, mirroring
|
|
6192
|
-
* _bdga_chart_autodetect. records[0] is the type sample. X = first
|
|
6193
|
-
* non-numeric-or-temporal header (else the first header); Y = the remaining
|
|
6194
|
-
* numeric non-temporal headers, falling back to numeric temporal headers,
|
|
6195
|
-
* then to every non-X header.
|
|
6196
|
-
*/
|
|
6197
|
-
export function autodetectKeys(records) {
|
|
6198
|
-
const sample = (records && records[0]) || {};
|
|
6199
|
-
const headers = Object.keys(sample);
|
|
6200
|
-
let xKey = null;
|
|
6201
|
-
for (const h of headers) {
|
|
6202
|
-
if (!isNumeric(sample[h]) || isTemporal(h)) { xKey = h; break; }
|
|
6203
|
-
}
|
|
6204
|
-
if (xKey === null) xKey = headers[0] ?? '';
|
|
6205
|
-
|
|
6206
|
-
const numeric = [];
|
|
6207
|
-
const numericTemporal = [];
|
|
6208
|
-
for (const h of headers) {
|
|
6209
|
-
if (h === xKey) continue;
|
|
6210
|
-
if (isNumeric(sample[h])) {
|
|
6211
|
-
(isTemporal(h) ? numericTemporal : numeric).push(h);
|
|
6212
|
-
}
|
|
6213
|
-
}
|
|
6214
|
-
let yKeys = numeric;
|
|
6215
|
-
if (!yKeys.length && numericTemporal.length) yKeys = numericTemporal;
|
|
6216
|
-
if (!yKeys.length) yKeys = headers.filter((h) => h !== xKey);
|
|
6217
|
-
return { x_key: xKey, y_keys: yKeys };
|
|
6218
|
-
}
|
|
6219
|
-
|
|
6220
|
-
/**
|
|
6221
|
-
* Data-level validation, mirroring what the PHP parsers reject at save: a
|
|
6222
|
-
* non-empty chosen key absent from the data. Non-numeric Y values are NOT an
|
|
6223
|
-
* error - PHP coerces them to 0, so callers surface that as a warning, not a
|
|
6224
|
-
* save-blocker. `keys` is the autodetectKeys shape ({ x_key, y_keys }).
|
|
6225
|
-
* Returns [] when clean.
|
|
6226
|
-
*/
|
|
6227
|
-
export function validate(records, keys = {}) {
|
|
6228
|
-
const columns = new Set(Object.keys((records && records[0]) || {}));
|
|
6229
|
-
const list = [...columns].join(', ');
|
|
6230
|
-
const errors = [];
|
|
6231
|
-
const xKey = keys.x_key ?? '';
|
|
6232
|
-
const yKeys = keys.y_keys ?? [];
|
|
6233
|
-
if (xKey && !columns.has(xKey)) errors.push(`X key "${xKey}" not in columns: ${list}`);
|
|
6234
|
-
for (const y of yKeys) {
|
|
6235
|
-
if (y && !columns.has(y)) errors.push(`Y key "${y}" not in columns: ${list}`);
|
|
6236
|
-
}
|
|
6237
|
-
return errors;
|
|
6238
|
-
}
|
|
6239
|
-
|
|
6240
|
-
/**
|
|
6241
|
-
* Validate an author-supplied data.gov.au URL the way bdga does server-side
|
|
6242
|
-
* (_bdga_chart_validate_url): https only, host on the allowlist, length cap,
|
|
6243
|
-
* no embedded credentials. Returns { ok, host, error }; never throws.
|
|
6244
|
-
*/
|
|
6245
|
-
export function validateSourceUrl(raw) {
|
|
6246
|
-
const value = String(raw ?? '').trim();
|
|
6247
|
-
if (value === '') return { ok: false, host: '', error: 'Empty URL' };
|
|
6248
|
-
if (value.length > 2048) return { ok: false, host: '', error: 'URL exceeds 2048 chars' };
|
|
6249
|
-
let url;
|
|
6250
|
-
try {
|
|
6251
|
-
url = new URL(value);
|
|
6252
|
-
} catch {
|
|
6253
|
-
return { ok: false, host: '', error: 'Malformed URL' };
|
|
6254
|
-
}
|
|
6255
|
-
if (url.protocol !== 'https:') return { ok: false, host: '', error: 'Scheme must be https' };
|
|
6256
|
-
const host = url.hostname.toLowerCase();
|
|
6257
|
-
if (!ALLOWED_HOSTS.includes(host)) return { ok: false, host, error: `Host not on allowlist: ${host}` };
|
|
6258
|
-
if (url.username || url.password) return { ok: false, host, error: 'Credentials in URL not permitted' };
|
|
6259
|
-
return { ok: true, host, error: '' };
|
|
6260
|
-
}
|
|
6261
|
-
|
|
6262
|
-
// Sanitise raw records into flat { string: string } rows: drop nested
|
|
6263
|
-
// objects/arrays, coerce scalars to strings, cap each cell and the row count.
|
|
6264
|
-
// Numeric coercion of Y columns happens later in the renderer once keys are
|
|
6265
|
-
// resolved - the editor needs raw values to suggest keys.
|
|
6266
|
-
function sanitiseRecords(rawRecords) {
|
|
6267
|
-
const out = [];
|
|
6268
|
-
for (const r of rawRecords) {
|
|
6269
|
-
if (out.length >= MAX_ROWS) break;
|
|
6270
|
-
if (!r || typeof r !== 'object' || Array.isArray(r)) continue;
|
|
6271
|
-
const row = {};
|
|
6272
|
-
for (const k of Object.keys(r)) {
|
|
6273
|
-
const v = r[k];
|
|
6274
|
-
if (v === null || v === undefined) { row[k] = ''; continue; }
|
|
6275
|
-
if (typeof v === 'object') continue;
|
|
6276
|
-
const s = String(v);
|
|
6277
|
-
row[k] = s.length > MAX_CELL_CHARS ? `${s.slice(0, MAX_CELL_CHARS)}…` : s;
|
|
6278
|
-
}
|
|
6279
|
-
out.push(row);
|
|
6280
|
-
}
|
|
6281
|
-
return out;
|
|
6282
|
-
}
|
|
6283
|
-
|
|
6284
|
-
// CKAN gives a typed field list (result.fields = [{ id, type }]); prefer it so
|
|
6285
|
-
// the editor can suggest by type. Fall back to the first record's keys.
|
|
6286
|
-
function deriveFields(ckanFields, records) {
|
|
6287
|
-
if (Array.isArray(ckanFields) && ckanFields.length) {
|
|
6288
|
-
return ckanFields
|
|
6289
|
-
.filter((f) => f && typeof f.id === 'string')
|
|
6290
|
-
.map((f) => ({ id: f.id, type: typeof f.type === 'string' ? f.type : null }));
|
|
6291
|
-
}
|
|
6292
|
-
return Object.keys(records[0] || {}).map((id) => ({ id, type: null }));
|
|
6293
|
-
}
|
|
6294
|
-
|
|
6295
|
-
/**
|
|
6296
|
-
* Fetch + sanitise records from a data.gov.au CKAN endpoint. Returns
|
|
6297
|
-
* { records, fields }; fields carries CKAN's typed column list when present
|
|
6298
|
-
* (the renderer's old extractCkanRows dropped it). Throws Error(message) on
|
|
6299
|
-
* any failure so callers can surface the message. Network, so not covered by
|
|
6300
|
-
* the parity fixtures. `fetchImpl` is injectable for tests.
|
|
6301
|
-
*/
|
|
6302
|
-
export async function fetchRecords(url, { fetchImpl, timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
6303
|
-
const check = validateSourceUrl(url);
|
|
6304
|
-
if (!check.ok) throw new Error(check.error);
|
|
6305
|
-
const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
|
|
6306
|
-
if (!doFetch) throw new Error('No fetch implementation available');
|
|
6307
|
-
|
|
6308
|
-
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
6309
|
-
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
6310
|
-
let payload;
|
|
6311
|
-
try {
|
|
6312
|
-
const res = await doFetch(new URL(String(url).trim()).toString(), {
|
|
6313
|
-
method: 'GET',
|
|
6314
|
-
credentials: 'omit',
|
|
6315
|
-
referrerPolicy: 'no-referrer',
|
|
6316
|
-
mode: 'cors',
|
|
6317
|
-
signal: controller ? controller.signal : undefined,
|
|
6318
|
-
headers: { Accept: 'application/json' },
|
|
6319
|
-
});
|
|
6320
|
-
if (timer) clearTimeout(timer);
|
|
6321
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
6322
|
-
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
|
6323
|
-
if (ct.indexOf('application/json') === -1) throw new Error(`Unexpected content-type: ${ct}`);
|
|
6324
|
-
payload = await res.json();
|
|
6325
|
-
} catch (err) {
|
|
6326
|
-
if (timer) clearTimeout(timer);
|
|
6327
|
-
if (err instanceof Error && /^(HTTP |Unexpected content-type)/.test(err.message)) throw err;
|
|
6328
|
-
throw new Error(`Fetch failed: ${err && err.message ? err.message : err}`, { cause: err });
|
|
6329
|
-
}
|
|
6330
|
-
|
|
6331
|
-
const rawRecords = payload && payload.result && Array.isArray(payload.result.records)
|
|
6332
|
-
? payload.result.records
|
|
6333
|
-
: (Array.isArray(payload) ? payload : []);
|
|
6334
|
-
const records = sanitiseRecords(rawRecords);
|
|
6335
|
-
const fields = deriveFields(payload && payload.result ? payload.result.fields : null, records);
|
|
6336
|
-
return { records, fields };
|
|
6337
|
-
}
|
|
6338
|
-
|
|
6339
|
-
// Quote-aware single-line CSV split: handles embedded commas and "" escapes.
|
|
6340
|
-
// Embedded newlines inside quoted fields are not supported (rare in CKAN
|
|
6341
|
-
// tabular exports); the renderer falls back to the server-rendered table.
|
|
6342
|
-
function splitCsvLine(line) {
|
|
6343
|
-
const out = [];
|
|
6344
|
-
let cur = '';
|
|
6345
|
-
let inQuotes = false;
|
|
6346
|
-
for (let i = 0; i < line.length; i += 1) {
|
|
6347
|
-
const ch = line[i];
|
|
6348
|
-
if (inQuotes) {
|
|
6349
|
-
if (ch === '"') {
|
|
6350
|
-
if (line[i + 1] === '"') { cur += '"'; i += 1; } else { inQuotes = false; }
|
|
6351
|
-
} else { cur += ch; }
|
|
6352
|
-
} else if (ch === '"') {
|
|
6353
|
-
inQuotes = true;
|
|
6354
|
-
} else if (ch === ',') {
|
|
6355
|
-
out.push(cur); cur = '';
|
|
6356
|
-
} else {
|
|
6357
|
-
cur += ch;
|
|
6358
|
-
}
|
|
6359
|
-
}
|
|
6360
|
-
out.push(cur);
|
|
6361
|
-
return out;
|
|
6362
|
-
}
|
|
6363
|
-
|
|
6364
|
-
function parseCsvToRecords(text) {
|
|
6365
|
-
const lines = text.split(/\r\n|\r|\n/).filter((l) => l.trim() !== '');
|
|
6366
|
-
if (lines.length < 2) throw new Error('CSV needs a header row and at least one data row');
|
|
6367
|
-
const headers = splitCsvLine(lines[0]).map((h) => h.trim());
|
|
6368
|
-
const records = [];
|
|
6369
|
-
for (let i = 1; i < lines.length && records.length < MAX_ROWS; i += 1) {
|
|
6370
|
-
const cells = splitCsvLine(lines[i]);
|
|
6371
|
-
const row = {};
|
|
6372
|
-
headers.forEach((h, idx) => {
|
|
6373
|
-
const cell = (cells[idx] ?? '').trim();
|
|
6374
|
-
row[h] = cell.length > MAX_CELL_CHARS ? `${cell.slice(0, MAX_CELL_CHARS)}…` : cell;
|
|
6375
|
-
});
|
|
6376
|
-
records.push(row);
|
|
6377
|
-
}
|
|
6378
|
-
return { records, headers };
|
|
6379
|
-
}
|
|
6380
|
-
|
|
6381
|
-
function parseJsonToRecords(text) {
|
|
6382
|
-
let decoded;
|
|
6383
|
-
try {
|
|
6384
|
-
decoded = JSON.parse(text);
|
|
6385
|
-
} catch (cause) {
|
|
6386
|
-
throw new Error('Invalid JSON', { cause });
|
|
6387
|
-
}
|
|
6388
|
-
let raw = null;
|
|
6389
|
-
if (Array.isArray(decoded)) raw = decoded;
|
|
6390
|
-
else if (decoded && Array.isArray(decoded.rows)) raw = decoded.rows;
|
|
6391
|
-
else if (decoded && decoded.result && Array.isArray(decoded.result.records)) raw = decoded.result.records;
|
|
6392
|
-
if (!Array.isArray(raw) || !raw.length) throw new Error('No row array found in JSON');
|
|
6393
|
-
const records = sanitiseRecords(raw);
|
|
6394
|
-
return { records, headers: Object.keys(records[0] || {}) };
|
|
6395
|
-
}
|
|
6396
|
-
|
|
6397
|
-
/**
|
|
6398
|
-
* Parse author-pasted CSV or JSON into { records, fields }. Mirrors the shapes
|
|
6399
|
-
* the PHP parsers accept (CSV header + rows; JSON array, { rows }, or CKAN
|
|
6400
|
-
* { result: { records } }). Key resolution is the caller's job via
|
|
6401
|
-
* autodetectKeys; this only produces records + the column list. Throws
|
|
6402
|
-
* Error(message) on malformed input.
|
|
6403
|
-
*/
|
|
6404
|
-
export function parse(text, mode) {
|
|
6405
|
-
const input = String(text ?? '');
|
|
6406
|
-
if (input.length > MAX_PAYLOAD_BYTES) throw new Error('Payload exceeds size cap');
|
|
6407
|
-
let result;
|
|
6408
|
-
if (mode === 'csv') result = parseCsvToRecords(input);
|
|
6409
|
-
else if (mode === 'json') result = parseJsonToRecords(input);
|
|
6410
|
-
else throw new Error(`Unknown parse mode: ${mode}`);
|
|
6411
|
-
return { records: result.records, fields: result.headers.map((id) => ({ id, type: null })) };
|
|
6412
|
-
}
|
|
6413
|
-
|
|
6414
|
-
// Canonical 8-4-4-4-12 hex UUID, matching bdga's PHP _bdga_chart_is_uuid.
|
|
6415
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6416
|
-
const isUuid = (s) => typeof s === 'string' && UUID_RE.test(s);
|
|
6417
|
-
|
|
6418
|
-
/**
|
|
6419
|
-
* Extract the CKAN resource_id (a UUID) from a data.gov.au data URL. Handles
|
|
6420
|
-
* the three shapes authors paste: ?resource_id=<uuid>, a datastore_search_sql
|
|
6421
|
-
* ?sql=... FROM "<uuid>", and a resource page .../resource/<uuid>. Returns the
|
|
6422
|
-
* lower-cased UUID, or null when none is found. Mirrors the removed PHP
|
|
6423
|
-
* _bdga_chart_extract_resource_id.
|
|
6424
|
-
*/
|
|
6425
|
-
export function extractResourceId(url) {
|
|
6426
|
-
let parsed;
|
|
6427
|
-
try {
|
|
6428
|
-
parsed = new URL(String(url ?? '').trim());
|
|
6429
|
-
} catch {
|
|
6430
|
-
return null;
|
|
6431
|
-
}
|
|
6432
|
-
const rid = parsed.searchParams.get('resource_id');
|
|
6433
|
-
if (rid && isUuid(rid)) return rid.toLowerCase();
|
|
6434
|
-
const sql = parsed.searchParams.get('sql');
|
|
6435
|
-
const fromMatch = sql && sql.match(/FROM\s+"?([0-9a-fA-F-]{36})"?/);
|
|
6436
|
-
if (fromMatch && isUuid(fromMatch[1])) return fromMatch[1].toLowerCase();
|
|
6437
|
-
const pathMatch = parsed.pathname.match(/\/resource\/([0-9a-fA-F-]{36})/);
|
|
6438
|
-
if (pathMatch && isUuid(pathMatch[1])) return pathMatch[1].toLowerCase();
|
|
6439
|
-
return null;
|
|
6440
|
-
}
|
|
6441
|
-
|
|
6442
|
-
/**
|
|
6443
|
-
* Resolve a data.gov.au datastore URL to its human resource landing page,
|
|
6444
|
-
* client-side (GovCMS bans server-side outbound HTTP). Editor-only: the
|
|
6445
|
-
* renderer just displays the stored source_page_url prop. Returns
|
|
6446
|
-
* { landingUrl, resourceId, packageId }, or null on any failure (not a
|
|
6447
|
-
* data.gov.au URL, no resource_id, resource_show fails, or no valid
|
|
6448
|
-
* package_id). Best-effort, so the editor shows no suggestion when null.
|
|
6449
|
-
* Mirrors the removed PHP _bdga_chart_resolve_source_page. `fetchImpl` is
|
|
6450
|
-
* injectable for tests.
|
|
6451
|
-
*/
|
|
6452
|
-
export async function resolveSourcePage(url, { fetchImpl, timeoutMs = FETCH_TIMEOUT_MS } = {}) {
|
|
6453
|
-
if (!validateSourceUrl(url).ok) return null;
|
|
6454
|
-
const resourceId = extractResourceId(url);
|
|
6455
|
-
if (!resourceId) return null;
|
|
6456
|
-
const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
|
|
6457
|
-
if (!doFetch) return null;
|
|
6458
|
-
|
|
6459
|
-
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
6460
|
-
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
6461
|
-
try {
|
|
6462
|
-
const encId = encodeURIComponent(resourceId);
|
|
6463
|
-
const api = `https://data.gov.au/data/api/3/action/resource_show?id=${encId}`;
|
|
6464
|
-
const res = await doFetch(api, {
|
|
6465
|
-
method: 'GET',
|
|
6466
|
-
credentials: 'omit',
|
|
6467
|
-
referrerPolicy: 'no-referrer',
|
|
6468
|
-
mode: 'cors',
|
|
6469
|
-
signal: controller ? controller.signal : undefined,
|
|
6470
|
-
headers: { Accept: 'application/json' },
|
|
6471
|
-
});
|
|
6472
|
-
if (timer) clearTimeout(timer);
|
|
6473
|
-
if (!res.ok) return null;
|
|
6474
|
-
const data = await res.json();
|
|
6475
|
-
const packageId = data && data.result && data.result.package_id;
|
|
6476
|
-
if (!isUuid(packageId)) return null;
|
|
6477
|
-
const encPkg = encodeURIComponent(packageId);
|
|
6478
|
-
const landingUrl = `https://data.gov.au/data/dataset/${encPkg}/resource/${encId}`;
|
|
6479
|
-
return { landingUrl, resourceId, packageId: packageId.toLowerCase() };
|
|
6480
|
-
} catch {
|
|
6481
|
-
if (timer) clearTimeout(timer);
|
|
6482
|
-
return null;
|
|
6483
|
-
}
|
|
6484
|
-
}
|
|
6485
|
-
|
|
6486
6150
|
});
|
|
6487
6151
|
document.addEventListener('DOMContentLoaded', () => {
|
|
6488
6152
|
/**
|
|
@@ -145,6 +145,8 @@ html {
|
|
|
145
145
|
--ct-link-card-dark-link-color: var(--ct-color-dark-body);
|
|
146
146
|
--ct-link-card-dark-link-hover-color: var(--ct-color-dark-interaction-background);
|
|
147
147
|
--ct-link-card-dark-deactivated-link-color: var(--ct-color-dark-body);
|
|
148
|
+
--ct-navigation-card-light-divider-border-color: var(--ct-color-light-border);
|
|
149
|
+
--ct-navigation-card-dark-divider-border-color: var(--ct-color-dark-border);
|
|
148
150
|
--ct-link-list-connected-border-radius: 0.25rem;
|
|
149
151
|
--ct-link-list-light-connected-border-color: var(--ct-color-light-border);
|
|
150
152
|
--ct-link-list-dark-connected-border-color: var(--ct-color-dark-border);
|
package/dist/constants.json
CHANGED
|
@@ -1589,6 +1589,8 @@
|
|
|
1589
1589
|
"ct-link-card-dark-link-color": "ct-color-dark('body')",
|
|
1590
1590
|
"ct-link-card-dark-link-hover-color": "ct-color-dark('interaction-background')",
|
|
1591
1591
|
"ct-link-card-dark-deactivated-link-color": "ct-color-dark('body')",
|
|
1592
|
+
"ct-navigation-card-light-divider-border-color": "ct-color-light('border')",
|
|
1593
|
+
"ct-navigation-card-dark-divider-border-color": "ct-color-dark('border')",
|
|
1592
1594
|
"ct-link-list-connected-border-radius": "$ct-border-radius",
|
|
1593
1595
|
"ct-link-list-light-connected-border-color": "ct-color-light('border')",
|
|
1594
1596
|
"ct-link-list-dark-connected-border-color": "ct-color-dark('border')",
|