@astronox/core 0.2.2 → 0.2.3

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.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/admin/cache.ts +217 -0
  3. package/src/admin/index.ts +1 -0
  4. package/src/auth/firebase/client.ts +37 -7
  5. package/src/client/formSubmission.ts +177 -0
  6. package/src/client/index.ts +1 -0
  7. package/src/components/actions/ScrollToTop/ScrollToTop.css +11 -0
  8. package/src/components/content/CardBody/CardBody.css +6 -2
  9. package/src/components/content/CardFooter/CardFooter.astro +9 -0
  10. package/src/components/content/CardFooter/CardFooter.css +45 -3
  11. package/src/components/content/CardLink/CardLink.astro +12 -3
  12. package/src/components/content/CardLink/CardLink.css +32 -0
  13. package/src/components/content/MarkdownContent/MarkdownContent.css +14 -4
  14. package/src/components/content/MarkdownContent/MarkdownInline.astro +1 -1
  15. package/src/components/content/MarkdownContent/contracts.ts +1 -1
  16. package/src/components/content/MarkdownContent/markdownContentRenderer.ts +7 -4
  17. package/src/components/data-display/TextPair/TextPair.css +8 -2
  18. package/src/components/forms/PreparedFormBootstrap/PreparedFormBootstrap.astro +203 -0
  19. package/src/components/forms/PreparedFormBootstrap/contracts.ts +15 -0
  20. package/src/components/forms/Textarea/Textarea.astro +3 -0
  21. package/src/components/forms/Textarea/Textarea.css +8 -0
  22. package/src/components/index.ts +8 -0
  23. package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +5 -1
  24. package/src/components/navigation/Breadcrumbs/Breadcrumbs.css +12 -17
  25. package/src/components/navigation/Footer/Footer.astro +102 -0
  26. package/src/components/navigation/Footer/Footer.css +155 -0
  27. package/src/components/navigation/Footer/contracts.ts +17 -0
  28. package/src/composition/Grid/Grid.css +16 -0
  29. package/src/data/firestore/client/contracts.ts +23 -0
  30. package/src/data/firestore/client/firebase.ts +45 -1
  31. package/src/display/contracts.ts +4 -1
  32. package/src/layouts/BaseLayout.astro +8 -1
  33. package/src/patterns/Combobox/comboboxController.ts +69 -0
  34. package/src/patterns/Table/TableBody.css +3 -3
  35. package/src/patterns/Table/TableCell.css +5 -1
  36. package/src/styles/base.css +24 -7
  37. package/src/styles/style.css +1 -0
  38. package/src/styles/tokens.css +5 -0
@@ -202,6 +202,20 @@
202
202
  gap: var(--nox-space-1);
203
203
  }
204
204
 
205
+ .card-link[data-density='relaxed'] .card-link__layout {
206
+ --card-link-size: calc(
207
+ var(--nox-navigation-block-size) + var(--nox-space-5)
208
+ );
209
+ }
210
+
211
+ .card-link[data-density='relaxed'] .card-link__content {
212
+ padding: var(--nox-space-5) var(--nox-space-6);
213
+ }
214
+
215
+ .card-link[data-density='relaxed'] .card-link__text {
216
+ gap: var(--nox-space-3);
217
+ }
218
+
205
219
  .card-link__compact-text {
206
220
  display: none;
207
221
  min-width: 0;
@@ -233,6 +247,14 @@
233
247
  align-items: center;
234
248
  }
235
249
 
250
+ .card-link[data-text-treatment='heading'] .card-link__text {
251
+ --text-pair-primary-font-size: var(--nox-font-size-lg);
252
+ --text-pair-primary-letter-spacing: var(--nox-letter-spacing-wide);
253
+ --text-pair-primary-text-transform: uppercase;
254
+ --text-pair-secondary-font-size: var(--nox-font-size-md);
255
+ line-height: var(--nox-line-height-tight);
256
+ }
257
+
236
258
  .card-link[data-interactive='true']:active {
237
259
  background: var(--card-link-active-background);
238
260
  }
@@ -287,6 +309,16 @@
287
309
  }
288
310
 
289
311
  @media (width < 48rem) {
312
+ .card-link[data-density='relaxed'] .card-link__layout {
313
+ --card-link-size: calc(
314
+ var(--nox-navigation-block-size) + var(--nox-space-4)
315
+ );
316
+ }
317
+
318
+ .card-link[data-density='relaxed'] .card-link__content {
319
+ padding: var(--nox-space-4) var(--nox-space-5);
320
+ }
321
+
290
322
  .card-link[data-compact-content] .card-link__layout {
291
323
  --card-link-size: var(--nox-navigation-compact-block-size);
292
324
  --card-link-media-size: 2.125rem;
@@ -171,7 +171,7 @@
171
171
 
172
172
  .markdown-content__resource-title {
173
173
  font-size: var(--nox-font-size-md);
174
- font-weight: var(--nox-font-weight-strong);
174
+ font-weight: var(--nox-font-weight-regular);
175
175
  line-height: var(--nox-line-height-tight);
176
176
  }
177
177
 
@@ -298,13 +298,23 @@
298
298
  }
299
299
 
300
300
  .markdown-content__link {
301
- color: var(--nox-tone-color);
301
+ color: var(--nox-content-link-color);
302
302
  overflow-wrap: anywhere;
303
303
  text-decoration: none;
304
304
  }
305
305
 
306
- .markdown-content__link[data-markdown-link-treatment='reference'] {
307
- font-weight: var(--nox-font-weight-strong);
306
+ .markdown-content__link[data-markdown-link-treatment='primary'] {
307
+ font-weight: var(--nox-content-link-emphasis-font-weight);
308
+ }
309
+
310
+ .markdown-content .markdown-content__strong {
311
+ font-weight: var(--nox-font-weight-medium);
312
+ }
313
+
314
+ @media (hover: hover) and (pointer: fine) {
315
+ .markdown-content__link:hover {
316
+ text-decoration-line: underline;
317
+ }
308
318
  }
309
319
 
310
320
  .markdown-content [data-tone] {
@@ -19,7 +19,7 @@ const { node }: Props = Astro.props;
19
19
  ) : node.type === 'symbol' ? (
20
20
  <MarkdownSymbol node={node} />
21
21
  ) : node.type === 'strong' ? (
22
- <strong>
22
+ <strong class="markdown-content__strong">
23
23
  {node.children.map((child) => <Astro.self node={child} />)}
24
24
  </strong>
25
25
  ) : node.type === 'emphasis' ? (
@@ -49,7 +49,7 @@ export type MarkdownLink = {
49
49
  external: boolean;
50
50
  href: string;
51
51
  lang?: string;
52
- treatment?: 'reference';
52
+ treatment?: 'primary';
53
53
  };
54
54
 
55
55
  export type MarkdownBreak = {
@@ -259,10 +259,13 @@ function renderInline(node: MarkdownInline): Node {
259
259
  return text;
260
260
  }
261
261
  if (node.type === 'symbol') return renderSymbol(node);
262
- if (node.type === 'strong' || node.type === 'emphasis') {
263
- const element = document.createElement(
264
- node.type === 'strong' ? 'strong' : 'em',
265
- );
262
+ if (node.type === 'strong') {
263
+ const element = createElement('strong', 'markdown-content__strong');
264
+ appendInline(element, node.children);
265
+ return element;
266
+ }
267
+ if (node.type === 'emphasis') {
268
+ const element = document.createElement('em');
266
269
  appendInline(element, node.children);
267
270
  return element;
268
271
  }
@@ -1,6 +1,10 @@
1
1
  .text-pair {
2
2
  --text-pair-primary-color: var(--nox-text);
3
+ --text-pair-primary-font-size: inherit;
4
+ --text-pair-primary-letter-spacing: inherit;
5
+ --text-pair-primary-text-transform: none;
3
6
  --text-pair-secondary-color: var(--nox-text-muted);
7
+ --text-pair-secondary-font-size: var(--nox-font-size-sm);
4
8
 
5
9
  display: grid;
6
10
  min-width: 0;
@@ -27,9 +31,11 @@
27
31
  .text-pair__primary {
28
32
  min-width: 0;
29
33
  color: var(--text-pair-primary-color);
30
- font-size: inherit;
34
+ font-size: var(--text-pair-primary-font-size);
35
+ letter-spacing: var(--text-pair-primary-letter-spacing);
31
36
  line-height: inherit;
32
37
  overflow-wrap: anywhere;
38
+ text-transform: var(--text-pair-primary-text-transform);
33
39
  }
34
40
 
35
41
  .text-pair__primary[data-text-color='muted'] {
@@ -58,7 +64,7 @@
58
64
  .text-pair__secondary {
59
65
  min-width: 0;
60
66
  color: var(--text-pair-secondary-color);
61
- font-size: var(--nox-font-size-sm);
67
+ font-size: var(--text-pair-secondary-font-size);
62
68
  line-height: 1.35;
63
69
  overflow-wrap: anywhere;
64
70
  }
@@ -0,0 +1,203 @@
1
+ ---
2
+ type Props = {
3
+ formId: string;
4
+ stateKey: string;
5
+ };
6
+
7
+ const { formId, stateKey }: Props = Astro.props;
8
+ ---
9
+
10
+ <script is:inline define:vars={{ formId, stateKey }}>
11
+ (() => {
12
+ const isRecord = (value) =>
13
+ typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ const readState = () => {
15
+ const state = window[stateKey];
16
+ return isRecord(state) ? state : null;
17
+ };
18
+ const createTag = (value, label) => {
19
+ const tag = document.createElement('span');
20
+ tag.className = 'tag';
21
+ tag.dataset.widget = 'tag';
22
+ tag.dataset.size = 'sm';
23
+ tag.dataset.comboboxTag = value;
24
+ const tagLabel = document.createElement('span');
25
+ tagLabel.className = 'tag__label';
26
+ tagLabel.dataset.tagLabel = '';
27
+ tagLabel.textContent = label;
28
+ tag.append(tagLabel);
29
+ return tag;
30
+ };
31
+ const getNamedFields = (form, name) => {
32
+ const item = form.elements.namedItem(name);
33
+ if (item instanceof RadioNodeList) return [...item];
34
+ return item ? [item] : [];
35
+ };
36
+ const getComboboxRoot = (form, key) =>
37
+ [...form.querySelectorAll('[data-prepared-form-combobox]')]
38
+ .find((candidate) => candidate.dataset.preparedFormCombobox === key);
39
+ const isComboboxReady = (root) =>
40
+ root instanceof HTMLElement &&
41
+ root.querySelector('[data-combobox-options]') instanceof HTMLElement &&
42
+ root.querySelector('[data-combobox-empty]') instanceof HTMLElement &&
43
+ root.querySelector('[data-combobox-values]') instanceof HTMLElement &&
44
+ root.querySelector('[data-combobox-placeholder]') instanceof HTMLElement;
45
+ const isFormReady = (form, state) => {
46
+ if (isRecord(state.values)) {
47
+ for (const name of Object.keys(state.values)) {
48
+ if (getNamedFields(form, name).length === 0) return false;
49
+ }
50
+ }
51
+ if (isRecord(state.choices)) {
52
+ for (const [name, value] of Object.entries(state.choices)) {
53
+ if (
54
+ typeof value !== 'string' ||
55
+ !getNamedFields(form, name).some(
56
+ (field) =>
57
+ field instanceof HTMLInputElement && field.value === value,
58
+ )
59
+ ) {
60
+ return false;
61
+ }
62
+ }
63
+ }
64
+ if (isRecord(state.comboboxes)) {
65
+ for (const key of Object.keys(state.comboboxes)) {
66
+ if (!isComboboxReady(getComboboxRoot(form, key))) return false;
67
+ }
68
+ }
69
+ return true;
70
+ };
71
+ const applyCombobox = (form, key, state) => {
72
+ if (!isRecord(state) || !Array.isArray(state.options)) return;
73
+ const root = getComboboxRoot(form, key);
74
+ if (!(root instanceof HTMLElement)) return;
75
+ const optionsRoot = root.querySelector('[data-combobox-options]');
76
+ const empty = root.querySelector('[data-combobox-empty]');
77
+ const valuesRoot = root.querySelector('[data-combobox-values]');
78
+ const placeholder = root.querySelector('[data-combobox-placeholder]');
79
+ if (
80
+ !(optionsRoot instanceof HTMLElement) ||
81
+ !(empty instanceof HTMLElement) ||
82
+ !(valuesRoot instanceof HTMLElement) ||
83
+ !(placeholder instanceof HTMLElement)
84
+ ) {
85
+ return;
86
+ }
87
+
88
+ optionsRoot
89
+ .querySelectorAll('[data-combobox-option]')
90
+ .forEach((node) => node.remove());
91
+ root
92
+ .querySelectorAll('template[data-combobox-tag-template]')
93
+ .forEach((node) => node.remove());
94
+ const options = state.options.filter(
95
+ (option) =>
96
+ isRecord(option) &&
97
+ typeof option.label === 'string' &&
98
+ typeof option.value === 'string',
99
+ );
100
+ const selected = new Set(
101
+ Array.isArray(state.values)
102
+ ? state.values.filter((value) => typeof value === 'string')
103
+ : [],
104
+ );
105
+
106
+ options.forEach((option, index) => {
107
+ const button = document.createElement('button');
108
+ button.className = 'combobox__option';
109
+ button.id = `${root.id || key}-option-${index}`;
110
+ button.type = 'button';
111
+ button.role = 'option';
112
+ button.tabIndex = -1;
113
+ button.setAttribute('aria-selected', String(selected.has(option.value)));
114
+ button.dataset.comboboxOption = option.value;
115
+ button.dataset.comboboxSearchText = option.label;
116
+ button.dataset.interactionAccent = 'start';
117
+ const content = document.createElement('span');
118
+ content.className = 'combobox__option-content';
119
+ const label = document.createElement('span');
120
+ label.className = 'combobox__option-label';
121
+ label.textContent = option.label;
122
+ content.append(label);
123
+ button.append(content);
124
+ optionsRoot.insertBefore(button, empty);
125
+
126
+ const template = document.createElement('template');
127
+ template.dataset.comboboxTagTemplate = option.value;
128
+ template.content.append(createTag(option.value, option.label));
129
+ root.append(template);
130
+ });
131
+
132
+ valuesRoot
133
+ .querySelectorAll('[data-combobox-tag]')
134
+ .forEach((node) => node.remove());
135
+ for (const value of selected) {
136
+ const option = options.find((candidate) => candidate.value === value);
137
+ if (option) {
138
+ valuesRoot.insertBefore(createTag(value, option.label), placeholder);
139
+ }
140
+ }
141
+ placeholder.hidden = selected.size > 0;
142
+ empty.hidden = options.length > 0;
143
+ };
144
+ const apply = () => {
145
+ const form = document.getElementById(formId);
146
+ const state = readState();
147
+ if (
148
+ !(form instanceof HTMLFormElement) ||
149
+ !state ||
150
+ !isFormReady(form, state)
151
+ ) {
152
+ return false;
153
+ }
154
+
155
+ if (isRecord(state.values)) {
156
+ for (const [name, value] of Object.entries(state.values)) {
157
+ if (typeof value !== 'string') continue;
158
+ for (const field of getNamedFields(form, name)) {
159
+ if (
160
+ field instanceof HTMLInputElement ||
161
+ field instanceof HTMLTextAreaElement ||
162
+ field instanceof HTMLSelectElement
163
+ ) {
164
+ field.value = value;
165
+ }
166
+ }
167
+ }
168
+ }
169
+ if (isRecord(state.choices)) {
170
+ for (const [name, value] of Object.entries(state.choices)) {
171
+ if (typeof value !== 'string') continue;
172
+ for (const field of getNamedFields(form, name)) {
173
+ if (field instanceof HTMLInputElement) {
174
+ field.checked = field.value === value;
175
+ }
176
+ }
177
+ }
178
+ }
179
+ if (isRecord(state.comboboxes)) {
180
+ for (const [key, combobox] of Object.entries(state.comboboxes)) {
181
+ applyCombobox(form, key, combobox);
182
+ }
183
+ }
184
+ form.dataset.preparedFormBootstrap = 'true';
185
+ return true;
186
+ };
187
+
188
+ if (apply()) return;
189
+ const observer = new MutationObserver(() => {
190
+ if (!apply()) return;
191
+ observer.disconnect();
192
+ });
193
+ observer.observe(document.documentElement, {
194
+ childList: true,
195
+ subtree: true,
196
+ });
197
+ document.addEventListener(
198
+ 'DOMContentLoaded',
199
+ () => observer.disconnect(),
200
+ { once: true },
201
+ );
202
+ })();
203
+ </script>
@@ -0,0 +1,15 @@
1
+ export type PreparedFormOption = {
2
+ label: string;
3
+ value: string;
4
+ };
5
+
6
+ export type PreparedFormCombobox = {
7
+ options: readonly PreparedFormOption[];
8
+ values: readonly string[];
9
+ };
10
+
11
+ export type PreparedFormState = {
12
+ choices?: Readonly<Record<string, string>>;
13
+ comboboxes?: Readonly<Record<string, PreparedFormCombobox>>;
14
+ values?: Readonly<Record<string, string>>;
15
+ };
@@ -4,6 +4,7 @@ import type { Tone } from '../../../theme/contracts';
4
4
 
5
5
  type Props = HTMLAttributes<'textarea'> & {
6
6
  invalid?: boolean;
7
+ size?: 'default' | 'compact';
7
8
  tone?: Tone;
8
9
  value?: string;
9
10
  };
@@ -13,6 +14,7 @@ const {
13
14
  class: className,
14
15
  'data-tone': dataTone,
15
16
  invalid = false,
17
+ size = 'default',
16
18
  tone,
17
19
  value = '',
18
20
  ...attrs
@@ -27,6 +29,7 @@ const resolvedTone = tone ?? dataTone;
27
29
  class:list={['textarea', className]}
28
30
  data-widget="textarea"
29
31
  data-invalid={resolvedInvalid ? true : undefined}
32
+ data-size={size}
30
33
  data-tone={resolvedTone}
31
34
  aria-invalid={resolvedInvalid ? 'true' : ariaInvalid}
32
35
  >{value}</textarea>
@@ -25,6 +25,10 @@
25
25
  opacity: 1;
26
26
  }
27
27
 
28
+ .textarea[data-size='compact'] {
29
+ min-height: calc(var(--nox-field-control-block-size) * 2);
30
+ }
31
+
28
32
  .textarea:focus {
29
33
  border-color: var(--nox-field-border-active);
30
34
  box-shadow: var(--nox-field-focus-shadow);
@@ -51,4 +55,8 @@
51
55
  min-height: 20rem;
52
56
  font-size: var(--nox-font-size-md);
53
57
  }
58
+
59
+ .textarea[data-size='compact'] {
60
+ min-height: calc(var(--nox-field-control-block-size) * 2);
61
+ }
54
62
  }
@@ -23,6 +23,12 @@ export { default as DateTimeInput } from './forms/DateTimeInput/DateTimeInput.as
23
23
  export { default as FieldError } from './forms/FieldError/FieldError.astro';
24
24
  export { default as FieldGroup } from './forms/FieldGroup/FieldGroup.astro';
25
25
  export { default as FieldLabel } from './forms/FieldLabel/FieldLabel.astro';
26
+ export { default as PreparedFormBootstrap } from './forms/PreparedFormBootstrap/PreparedFormBootstrap.astro';
27
+ export type {
28
+ PreparedFormCombobox,
29
+ PreparedFormOption,
30
+ PreparedFormState,
31
+ } from './forms/PreparedFormBootstrap/contracts';
26
32
  export { default as TextInput } from './forms/TextInput/TextInput.astro';
27
33
  export { default as Textarea } from './forms/Textarea/Textarea.astro';
28
34
  export { default as Spinner } from './feedback/Spinner/Spinner.astro';
@@ -41,6 +47,7 @@ export { default as NavigationLink } from './navigation/NavigationLink.astro';
41
47
  export { default as NavigationLinks } from './navigation/NavigationLinks.astro';
42
48
  export { default as NavigationMenuList } from './navigation/NavigationMenuList.astro';
43
49
  export { default as SkipLink } from './navigation/SkipLink/SkipLink.astro';
50
+ export { default as Footer } from './navigation/Footer/Footer.astro';
44
51
  export { default as SubNavigation } from './navigation/SubNavigation/SubNavigation.astro';
45
52
  export { default as SeoHead } from './seo/SeoHead.astro';
46
53
  export * from './content/CodeBlock/contracts';
@@ -55,6 +62,7 @@ export * from './media/EmbedFrame/embedFrameController';
55
62
  export * from './navigation/mobileNavigationController';
56
63
  export * from './navigation/navigationState';
57
64
  export * from './navigation/navigationTypes';
65
+ export * from './navigation/Footer/contracts';
58
66
  export * from './widgetTypes';
59
67
  export { default as ArrowDownIcon } from './icons/ArrowDownIcon.astro';
60
68
  export { default as ArrowLeftIcon } from './icons/ArrowLeftIcon.astro';
@@ -3,6 +3,7 @@ import type { HTMLAttributes } from 'astro/types';
3
3
  import type { BreadcrumbItem } from '../../../display/contracts';
4
4
  import type { Tone } from '../../../theme/contracts';
5
5
  import { defaultCoreMessages } from '../../../i18n/coreMessages';
6
+ import ArrowRightIcon from '../../icons/ArrowRightIcon.astro';
6
7
 
7
8
  type Props = HTMLAttributes<'nav'> & {
8
9
  items: readonly BreadcrumbItem[];
@@ -18,7 +19,7 @@ const {
18
19
  tone,
19
20
  ...attrs
20
21
  } = Astro.props;
21
- const resolvedTone = tone ?? dataTone;
22
+ const resolvedTone = tone ?? dataTone ?? 'info';
22
23
  ---
23
24
 
24
25
  {
@@ -38,6 +39,9 @@ const resolvedTone = tone ?? dataTone;
38
39
 
39
40
  return (
40
41
  <li class="breadcrumbs__item">
42
+ {index > 0 ? (
43
+ <ArrowRightIcon class="breadcrumbs__separator" />
44
+ ) : null}
41
45
  {isNavigable ? (
42
46
  <a href={href} aria-current={isCurrent ? 'page' : undefined}>
43
47
  {item.label}
@@ -1,7 +1,7 @@
1
1
  .breadcrumbs {
2
2
  min-width: 0;
3
3
  overflow: hidden;
4
- color: var(--nox-text-muted);
4
+ color: var(--nox-tone-color);
5
5
  font-size: var(--nox-font-size-sm);
6
6
  font-weight: var(--nox-font-weight-medium);
7
7
  line-height: 1.35;
@@ -25,7 +25,7 @@
25
25
  .breadcrumbs__item {
26
26
  display: inline-flex;
27
27
  flex: 0 0 auto;
28
- align-items: stretch;
28
+ align-items: center;
29
29
  min-width: 0;
30
30
  }
31
31
 
@@ -39,15 +39,13 @@
39
39
  align-items: center;
40
40
  }
41
41
 
42
- .breadcrumbs__item + .breadcrumbs__item::before {
43
- display: inline-flex;
44
- align-items: center;
42
+ .breadcrumbs__separator {
43
+ display: block;
44
+ width: 0.625rem;
45
+ height: 0.625rem;
46
+ flex: 0 0 auto;
45
47
  margin-inline: var(--nox-space-2);
46
- color: color-mix(in srgb, var(--nox-tone-color) 55%, var(--nox-text-subtle));
47
- content: '›';
48
- font-size: var(--nox-font-size-md);
49
- font-weight: var(--nox-font-weight-strong);
50
- line-height: 1;
48
+ color: var(--nox-tone-color);
51
49
  }
52
50
 
53
51
  .breadcrumbs a {
@@ -56,7 +54,8 @@
56
54
  align-items: center;
57
55
  align-self: stretch;
58
56
  border-radius: var(--nox-radius-sm);
59
- color: inherit;
57
+ color: var(--nox-tone-color);
58
+ font-weight: var(--nox-content-link-emphasis-font-weight);
60
59
  margin-inline: -0.1875rem;
61
60
  padding-inline: 0.1875rem;
62
61
  text-decoration: none;
@@ -71,19 +70,15 @@
71
70
 
72
71
  @media (hover: hover) and (pointer: fine) {
73
72
  .breadcrumbs a:hover {
74
- color: var(--nox-tone-text);
75
73
  text-decoration-line: underline;
76
- text-decoration-thickness: 0.125rem;
77
74
  }
78
75
  }
79
76
 
80
77
  .breadcrumbs a:focus-visible {
81
- color: var(--nox-tone-text);
82
- text-decoration-line: underline;
83
- text-decoration-thickness: 0.125rem;
78
+ color: var(--nox-tone-color);
84
79
  }
85
80
 
86
81
  .breadcrumbs [aria-current='page'] {
87
- color: var(--nox-tone-text);
82
+ color: var(--nox-tone-color);
88
83
  font-weight: var(--nox-font-weight-strong);
89
84
  }
@@ -0,0 +1,102 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+ import type { Tone } from '../../../theme/contracts';
4
+ import type { FooterGroup, FooterItem } from './contracts';
5
+ import ArrowRightIcon from '../../icons/ArrowRightIcon.astro';
6
+
7
+ type Props = HTMLAttributes<'footer'> & {
8
+ columnSeparator?: 'divider' | 'none';
9
+ columns?: 1 | 2;
10
+ groups: readonly FooterGroup[];
11
+ label: string;
12
+ tone?: Tone;
13
+ };
14
+
15
+ const {
16
+ class: className,
17
+ columnSeparator = 'none',
18
+ columns = 1,
19
+ 'data-tone': dataTone,
20
+ groups,
21
+ label,
22
+ tone,
23
+ ...attrs
24
+ }: Props = Astro.props;
25
+ const resolvedTone = tone ?? dataTone;
26
+ const visibleGroups = groups.filter(({ items }) => items.length > 0);
27
+ const resolveRel = (target: FooterItem['target'], rel?: FooterItem['rel']) =>
28
+ rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined);
29
+ ---
30
+
31
+ <footer
32
+ {...attrs}
33
+ class:list={['site-footer', className]}
34
+ data-tone={resolvedTone}
35
+ data-widget="site-footer"
36
+ >
37
+ <nav
38
+ class="site-footer__content"
39
+ aria-label={label}
40
+ data-footer-columns={columns}
41
+ data-footer-column-separator={columnSeparator}
42
+ >
43
+ {
44
+ visibleGroups.map(({ items }) => (
45
+ <span class="site-footer__group">
46
+ {items.map(
47
+ (
48
+ {
49
+ href,
50
+ label: itemLabel,
51
+ marker,
52
+ prefix,
53
+ rel,
54
+ separator = 'divider',
55
+ target,
56
+ tone: itemTone,
57
+ },
58
+ index,
59
+ ) => (
60
+ <>
61
+ {index > 0 ? (
62
+ separator === 'divider' ? (
63
+ <span class="site-footer__separator" aria-hidden="true">
64
+ |
65
+ </span>
66
+ ) : (
67
+ <span class="site-footer__spacer" aria-hidden="true" />
68
+ )
69
+ ) : null}
70
+ <span class="site-footer__item">
71
+ {prefix ? (
72
+ <span class="site-footer__prefix">{prefix}</span>
73
+ ) : null}
74
+ {href ? (
75
+ <a
76
+ href={href}
77
+ rel={resolveRel(target, rel)}
78
+ target={target}
79
+ data-tone={itemTone}
80
+ >
81
+ {marker === 'arrow' ? (
82
+ <ArrowRightIcon class="site-footer__marker" />
83
+ ) : null}
84
+ <span>{itemLabel}</span>
85
+ </a>
86
+ ) : (
87
+ <span class="site-footer__label" data-tone={itemTone}>
88
+ {marker === 'arrow' ? (
89
+ <ArrowRightIcon class="site-footer__marker" />
90
+ ) : null}
91
+ <span>{itemLabel}</span>
92
+ </span>
93
+ )}
94
+ </span>
95
+ </>
96
+ ),
97
+ )}
98
+ </span>
99
+ ))
100
+ }
101
+ </nav>
102
+ </footer>