@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.
@@ -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 - 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.
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 - use a button or plain link
22
- - rich cards with an image, summary, or metadata block - use a card from the card family instead
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) - 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
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** - 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
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 ct-navigation-card--with-image ct-navigation-card--image-as-icon "
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 ct-navigation-card--with-link ct-navigation-card--card-clickable "
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
- Card Title
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 ct-navigation-card--with-image ct-navigation-card--with-link ct-navigation-card--card-clickable custom-class"
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
- Card Title
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 ct-navigation-card--with-image ct-navigation-card--with-link ct-navigation-card--image-as-icon ct-navigation-card--card-clickable custom-class"
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
- Card Title
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
- {% set modifier_class = '%s %s %s %s %s %s'|format(theme_class, with_image_class, with_link_class, image_as_icon_class, card_clickable_class, modifier_class|default('')) %}
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
- >{{ title }}</a>
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 - 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.
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** - 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
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 - [status]" as one unit
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 - never a disabled link
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 - drive it from the form's own step state
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 - 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.
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 - use the [Progress nav](?path=/docs/forms-progress-nav--docs) task list, which has room for status text and hints
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** - 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
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
  //
@@ -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);
@@ -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')",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dta-au/civictheme-twig",
3
- "version": "1.16.0",
3
+ "version": "1.16.1",
4
4
  "license": "GPL-2.0-or-later",
5
5
  "description": "CivicTheme UI Kit for Twig",
6
6
  "keywords": [