@astronox/core 0.2.2 → 0.2.4

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 (59) 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/Button/Button.css +1 -1
  8. package/src/components/actions/CardAction/CardAction.css +3 -3
  9. package/src/components/actions/ScrollToTop/ScrollToTop.css +11 -0
  10. package/src/components/actions/ThemeToggle/ThemeToggle.astro +59 -0
  11. package/src/components/actions/ThemeToggle/ThemeToggle.css +83 -0
  12. package/src/components/actions/ThemeToggle/themeToggleController.ts +111 -0
  13. package/src/components/content/CardBody/CardBody.css +6 -2
  14. package/src/components/content/CardFooter/CardFooter.astro +9 -0
  15. package/src/components/content/CardFooter/CardFooter.css +45 -3
  16. package/src/components/content/CardLink/CardLink.astro +12 -3
  17. package/src/components/content/CardLink/CardLink.css +32 -0
  18. package/src/components/content/MarkdownContent/MarkdownContent.css +14 -4
  19. package/src/components/content/MarkdownContent/MarkdownInline.astro +1 -1
  20. package/src/components/content/MarkdownContent/contracts.ts +1 -1
  21. package/src/components/content/MarkdownContent/markdownContentRenderer.ts +7 -4
  22. package/src/components/data-display/TextPair/TextPair.css +8 -2
  23. package/src/components/feedback/ThemeBootstrap/ThemeBootstrap.astro +31 -0
  24. package/src/components/forms/ChoiceGroup/ChoiceGroup.css +1 -1
  25. package/src/components/forms/PreparedFormBootstrap/PreparedFormBootstrap.astro +203 -0
  26. package/src/components/forms/PreparedFormBootstrap/contracts.ts +15 -0
  27. package/src/components/forms/Textarea/Textarea.astro +3 -0
  28. package/src/components/forms/Textarea/Textarea.css +8 -0
  29. package/src/components/icons/CircleSymbolIcon.css +3 -3
  30. package/src/components/index.ts +10 -0
  31. package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +5 -1
  32. package/src/components/navigation/Breadcrumbs/Breadcrumbs.css +12 -17
  33. package/src/components/navigation/DesktopNavigation.css +4 -1
  34. package/src/components/navigation/Footer/Footer.astro +102 -0
  35. package/src/components/navigation/Footer/Footer.css +155 -0
  36. package/src/components/navigation/Footer/contracts.ts +17 -0
  37. package/src/components/navigation/Header.astro +20 -2
  38. package/src/components/navigation/MobileNavigation.astro +107 -43
  39. package/src/components/navigation/MobileNavigation.css +109 -24
  40. package/src/components/navigation/NavigationBrand.css +1 -1
  41. package/src/components/navigation/NavigationControl.css +16 -2
  42. package/src/components/navigation/NavigationItemContent.astro +6 -1
  43. package/src/components/navigation/mobileNavigationController.ts +0 -16
  44. package/src/components/navigation/navigationTypes.ts +9 -1
  45. package/src/composition/Grid/Grid.css +16 -0
  46. package/src/data/firestore/client/contracts.ts +23 -0
  47. package/src/data/firestore/client/firebase.ts +45 -1
  48. package/src/display/contracts.ts +5 -1
  49. package/src/layouts/BaseLayout.astro +13 -1
  50. package/src/patterns/Combobox/comboboxController.ts +69 -0
  51. package/src/patterns/MapViewport/MapViewport.css +3 -3
  52. package/src/patterns/Table/TableBody.css +3 -3
  53. package/src/patterns/Table/TableCell.css +5 -1
  54. package/src/styles/base.css +24 -7
  55. package/src/styles/style.css +2 -0
  56. package/src/styles/tokens.css +60 -15
  57. package/src/theme/contracts.ts +2 -0
  58. package/src/theme/index.ts +1 -0
  59. package/src/theme/storage.ts +1 -0
@@ -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
  }
@@ -3,17 +3,17 @@
3
3
  --circle-symbol-icon-surface: color-mix(
4
4
  in srgb,
5
5
  var(--circle-symbol-icon-seed) 32%,
6
- var(--nox-mix-light)
6
+ var(--nox-theme-surface-base)
7
7
  );
8
8
  --circle-symbol-icon-border: color-mix(
9
9
  in srgb,
10
10
  var(--circle-symbol-icon-seed) 74%,
11
- var(--nox-mix-dark)
11
+ var(--nox-theme-text-base)
12
12
  );
13
13
  --circle-symbol-icon-mark: color-mix(
14
14
  in srgb,
15
15
  var(--circle-symbol-icon-seed) 82%,
16
- var(--nox-mix-dark)
16
+ var(--nox-theme-text-base)
17
17
  );
18
18
 
19
19
  color: var(--circle-symbol-icon-mark);
@@ -1,5 +1,6 @@
1
1
  export { default as ActionListItem } from './actions/ActionListItem/ActionListItem.astro';
2
2
  export { default as Button } from './actions/Button/Button.astro';
3
+ export { default as ThemeToggle } from './actions/ThemeToggle/ThemeToggle.astro';
3
4
  export { default as CardAction } from './actions/CardAction/CardAction.astro';
4
5
  export { default as IdentityProviderAction } from './actions/IdentityProviderAction/IdentityProviderAction.astro';
5
6
  export { default as MediaTrigger } from './actions/MediaTrigger/MediaTrigger.astro';
@@ -23,10 +24,17 @@ export { default as DateTimeInput } from './forms/DateTimeInput/DateTimeInput.as
23
24
  export { default as FieldError } from './forms/FieldError/FieldError.astro';
24
25
  export { default as FieldGroup } from './forms/FieldGroup/FieldGroup.astro';
25
26
  export { default as FieldLabel } from './forms/FieldLabel/FieldLabel.astro';
27
+ export { default as PreparedFormBootstrap } from './forms/PreparedFormBootstrap/PreparedFormBootstrap.astro';
28
+ export type {
29
+ PreparedFormCombobox,
30
+ PreparedFormOption,
31
+ PreparedFormState,
32
+ } from './forms/PreparedFormBootstrap/contracts';
26
33
  export { default as TextInput } from './forms/TextInput/TextInput.astro';
27
34
  export { default as Textarea } from './forms/Textarea/Textarea.astro';
28
35
  export { default as Spinner } from './feedback/Spinner/Spinner.astro';
29
36
  export { default as PreferenceBootstrap } from './feedback/PreferenceBootstrap/PreferenceBootstrap.astro';
37
+ export { default as ThemeBootstrap } from './feedback/ThemeBootstrap/ThemeBootstrap.astro';
30
38
  export { default as Tooltip } from './feedback/Tooltip/Tooltip.astro';
31
39
  export { default as CanvasFrame } from './media/CanvasFrame/CanvasFrame.astro';
32
40
  export { default as EmbedFrame } from './media/EmbedFrame/EmbedFrame.astro';
@@ -41,6 +49,7 @@ export { default as NavigationLink } from './navigation/NavigationLink.astro';
41
49
  export { default as NavigationLinks } from './navigation/NavigationLinks.astro';
42
50
  export { default as NavigationMenuList } from './navigation/NavigationMenuList.astro';
43
51
  export { default as SkipLink } from './navigation/SkipLink/SkipLink.astro';
52
+ export { default as Footer } from './navigation/Footer/Footer.astro';
44
53
  export { default as SubNavigation } from './navigation/SubNavigation/SubNavigation.astro';
45
54
  export { default as SeoHead } from './seo/SeoHead.astro';
46
55
  export * from './content/CodeBlock/contracts';
@@ -55,6 +64,7 @@ export * from './media/EmbedFrame/embedFrameController';
55
64
  export * from './navigation/mobileNavigationController';
56
65
  export * from './navigation/navigationState';
57
66
  export * from './navigation/navigationTypes';
67
+ export * from './navigation/Footer/contracts';
58
68
  export * from './widgetTypes';
59
69
  export { default as ArrowDownIcon } from './icons/ArrowDownIcon.astro';
60
70
  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
  }
@@ -81,7 +81,10 @@
81
81
 
82
82
  .site-nav__action > * {
83
83
  min-width: 5.75rem;
84
- border-radius: var(--nox-radius-md);
84
+ }
85
+
86
+ .site-nav__action > [data-theme-toggle-appearance='circle'] {
87
+ min-width: 4.75rem;
85
88
  }
86
89
 
87
90
  .site-nav__action
@@ -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>
@@ -0,0 +1,155 @@
1
+ .site-footer {
2
+ color: var(--nox-text-muted);
3
+ }
4
+
5
+ .site-footer__content {
6
+ --site-footer-item-gap: var(--nox-space-2);
7
+ --site-footer-marker-gap: 0.0625rem;
8
+
9
+ display: grid;
10
+ width: var(--nox-page-width);
11
+ margin-inline: auto;
12
+ justify-items: center;
13
+ gap: var(--nox-space-1);
14
+ padding-block-end: calc(
15
+ var(--nox-section-compact-block-size) + var(--nox-space-7)
16
+ );
17
+ font-size: var(--nox-font-size-sm);
18
+ line-height: 1.25;
19
+ text-align: center;
20
+ }
21
+
22
+ .site-footer__group {
23
+ display: inline-flex;
24
+ min-width: 0;
25
+ align-items: center;
26
+ justify-content: center;
27
+ flex-wrap: wrap;
28
+ }
29
+
30
+ .site-footer__content[data-footer-columns='2'] {
31
+ grid-template-columns: repeat(2, max-content);
32
+ justify-content: center;
33
+ column-gap: var(--nox-space-4);
34
+ }
35
+
36
+ .site-footer__content[data-footer-columns='2'][
37
+ data-footer-column-separator='divider'
38
+ ] {
39
+ column-gap: 0;
40
+ }
41
+
42
+ .site-footer__content[data-footer-columns='2'][
43
+ data-footer-column-separator='divider'
44
+ ]
45
+ > .site-footer__group:nth-child(even)::before {
46
+ display: inline-grid;
47
+ width: calc(
48
+ var(--site-footer-item-gap) + var(--site-footer-item-gap) + 0.5em
49
+ );
50
+ flex: 0 0 auto;
51
+ place-items: center;
52
+ color: var(--nox-border);
53
+ content: '|';
54
+ line-height: 1;
55
+ }
56
+
57
+ .site-footer__content[data-footer-columns='2']
58
+ > .site-footer__group:last-child:nth-child(odd) {
59
+ grid-column: 1 / -1;
60
+ }
61
+
62
+ .site-footer__item {
63
+ display: inline-flex;
64
+ min-width: 0;
65
+ align-items: center;
66
+ gap: 0;
67
+ }
68
+
69
+ .site-footer__separator {
70
+ display: inline-grid;
71
+ width: calc(
72
+ var(--site-footer-item-gap) + var(--site-footer-item-gap) + 0.5em
73
+ );
74
+ flex: 0 0 auto;
75
+ place-items: center;
76
+ color: var(--nox-border);
77
+ line-height: 1;
78
+ }
79
+
80
+ .site-footer__spacer {
81
+ width: var(--site-footer-item-gap);
82
+ flex: 0 0 auto;
83
+ }
84
+
85
+ .site-footer__prefix + :is(a, .site-footer__label) {
86
+ margin-inline-start: var(--nox-space-1);
87
+ }
88
+
89
+ .site-footer a,
90
+ .site-footer__label {
91
+ display: inline-flex;
92
+ min-width: 0;
93
+ align-items: center;
94
+ gap: var(--site-footer-marker-gap);
95
+ }
96
+
97
+ .site-footer__marker {
98
+ display: block;
99
+ width: 0.625rem;
100
+ height: 0.625rem;
101
+ flex: 0 0 auto;
102
+ color: var(--nox-text-muted);
103
+ }
104
+
105
+ .site-footer a {
106
+ color: var(--nox-content-link-color);
107
+ font-weight: var(--nox-content-link-emphasis-font-weight);
108
+ text-decoration: none;
109
+ }
110
+
111
+ .site-footer a[data-tone],
112
+ .site-footer__label[data-tone] {
113
+ color: var(--nox-tone-color);
114
+ }
115
+
116
+ .site-footer a:focus-visible {
117
+ border-radius: var(--nox-radius-sm);
118
+ outline: var(--nox-focus-ring-width) solid var(--nox-tone-focus-ring);
119
+ outline-offset: var(--nox-focus-ring-offset);
120
+ color: var(--nox-content-link-color);
121
+ }
122
+
123
+ .site-footer a[data-tone]:focus-visible {
124
+ color: var(--nox-tone-color);
125
+ }
126
+
127
+ @media (hover: hover) and (pointer: fine) {
128
+ .site-footer a:hover {
129
+ color: var(--nox-content-link-color);
130
+ text-decoration-line: underline;
131
+ }
132
+
133
+ .site-footer a[data-tone]:hover {
134
+ color: var(--nox-tone-color);
135
+ }
136
+ }
137
+
138
+ @media (width < 48rem) {
139
+ .site-footer__content[data-footer-columns='2'] {
140
+ grid-template-columns: minmax(0, 1fr);
141
+ column-gap: 0;
142
+ }
143
+
144
+ .site-footer__content[data-footer-columns='2']
145
+ > .site-footer__group:last-child:nth-child(odd) {
146
+ grid-column: auto;
147
+ }
148
+
149
+ .site-footer__content[data-footer-columns='2'][
150
+ data-footer-column-separator='divider'
151
+ ]
152
+ > .site-footer__group:nth-child(even)::before {
153
+ content: none;
154
+ }
155
+ }