@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
@@ -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
+ }
@@ -0,0 +1,17 @@
1
+ import type { HTMLAttributes } from 'astro/types';
2
+ import type { Tone } from '../../../theme/contracts';
3
+
4
+ export type FooterItem = {
5
+ href?: HTMLAttributes<'a'>['href'];
6
+ label: string;
7
+ marker?: 'arrow';
8
+ prefix?: string;
9
+ rel?: HTMLAttributes<'a'>['rel'];
10
+ separator?: 'divider' | 'none';
11
+ target?: HTMLAttributes<'a'>['target'];
12
+ tone?: Tone;
13
+ };
14
+
15
+ export type FooterGroup = {
16
+ items: readonly FooterItem[];
17
+ };
@@ -115,6 +115,10 @@
115
115
  grid-template-columns: 1fr;
116
116
  }
117
117
 
118
+ .grid[data-layout='fixed'][data-collapse-at='sm'][data-last-row='fill'] > * {
119
+ grid-column: auto;
120
+ }
121
+
118
122
  .grid:is(
119
123
  [data-layout='fixed'],
120
124
  [data-layout='content-sidebar']
@@ -163,6 +167,10 @@
163
167
  grid-template-columns: 1fr;
164
168
  }
165
169
 
170
+ .grid[data-layout='fixed'][data-collapse-at='md'][data-last-row='fill'] > * {
171
+ grid-column: auto;
172
+ }
173
+
166
174
  .grid:is(
167
175
  [data-layout='fixed'],
168
176
  [data-layout='content-sidebar']
@@ -211,6 +219,10 @@
211
219
  grid-template-columns: 1fr;
212
220
  }
213
221
 
222
+ .grid[data-layout='fixed'][data-collapse-at='lg'][data-last-row='fill'] > * {
223
+ grid-column: auto;
224
+ }
225
+
214
226
  .grid:is(
215
227
  [data-layout='fixed'],
216
228
  [data-layout='content-sidebar']
@@ -259,6 +271,10 @@
259
271
  grid-template-columns: 1fr;
260
272
  }
261
273
 
274
+ .grid[data-layout='fixed'][data-collapse-at='xl'][data-last-row='fill'] > * {
275
+ grid-column: auto;
276
+ }
277
+
262
278
  .grid:is(
263
279
  [data-layout='fixed'],
264
280
  [data-layout='content-sidebar']
@@ -53,3 +53,26 @@ export type ClientFirestoreRepositoryConfig<
53
53
  orderBy?: readonly ClientFirestoreOrder[];
54
54
  schema: ClientFirestoreSchema<TDocument>;
55
55
  };
56
+
57
+ export type ClientFirestoreBatchOperation =
58
+ | {
59
+ collection: string;
60
+ id?: string;
61
+ kind: 'create';
62
+ value: Record<string, unknown>;
63
+ }
64
+ | {
65
+ collection: string;
66
+ id: string;
67
+ kind: 'update';
68
+ value: Record<string, unknown>;
69
+ }
70
+ | {
71
+ collection: string;
72
+ id: string;
73
+ kind: 'delete';
74
+ };
75
+
76
+ export type ClientFirestoreBatchResult = {
77
+ createdIds: readonly string[];
78
+ };
@@ -1,7 +1,17 @@
1
1
  import { getApps, initializeApp } from 'firebase/app';
2
- import { getFirestore } from 'firebase/firestore';
2
+ import {
3
+ collection,
4
+ doc,
5
+ getFirestore,
6
+ serverTimestamp,
7
+ writeBatch,
8
+ } from 'firebase/firestore';
3
9
 
4
10
  import type { FirebaseClientConfig } from '../../../auth/firebase/contracts';
11
+ import type {
12
+ ClientFirestoreBatchOperation,
13
+ ClientFirestoreBatchResult,
14
+ } from './contracts';
5
15
 
6
16
  export function createFirebaseClientFirestore(config: FirebaseClientConfig) {
7
17
  const app =
@@ -10,3 +20,37 @@ export function createFirebaseClientFirestore(config: FirebaseClientConfig) {
10
20
 
11
21
  return getFirestore(app);
12
22
  }
23
+
24
+ export function createClientFirestoreServerTimestamp() {
25
+ return serverTimestamp();
26
+ }
27
+
28
+ export async function commitClientFirestoreBatch(
29
+ database: ReturnType<typeof createFirebaseClientFirestore>,
30
+ operations: readonly ClientFirestoreBatchOperation[],
31
+ ): Promise<ClientFirestoreBatchResult> {
32
+ const batch = writeBatch(database);
33
+ const createdIds: string[] = [];
34
+
35
+ for (const operation of operations) {
36
+ const documents = collection(database, operation.collection);
37
+ if (operation.kind === 'create') {
38
+ const reference = operation.id
39
+ ? doc(documents, operation.id)
40
+ : doc(documents);
41
+ batch.set(reference, operation.value);
42
+ createdIds.push(reference.id);
43
+ continue;
44
+ }
45
+
46
+ const reference = doc(documents, operation.id);
47
+ if (operation.kind === 'update') {
48
+ batch.update(reference, operation.value);
49
+ } else {
50
+ batch.delete(reference);
51
+ }
52
+ }
53
+
54
+ await batch.commit();
55
+ return { createdIds };
56
+ }
@@ -104,10 +104,13 @@ type DisplayCardLinkContent = {
104
104
  compactContent?: 'text' | 'media';
105
105
  compactText?: DisplayTextValue;
106
106
  compactVisual?: 'show' | 'hide';
107
+ density?: 'default' | 'relaxed';
107
108
  iconTone?: Tone;
108
- label: string;
109
+ label?: string;
109
110
  title: string;
110
111
  meta?: DisplayTextValue;
112
+ textLayout?: TextPairLayout;
113
+ textTreatment?: 'default' | 'heading';
111
114
  media?: DisplayMedia;
112
115
  mediaPosition?: 'start' | 'end';
113
116
  mediaSize?: 'default' | 'compact' | 'small';
@@ -16,6 +16,7 @@ type Props = HTMLAttributes<'html'> & {
16
16
  currentPath?: string;
17
17
  breadcrumbs?: readonly BreadcrumbItem[];
18
18
  breadcrumbLabel: string;
19
+ breadcrumbTone?: Tone;
19
20
  scrollToTopLabel?: string;
20
21
  structuredData?: readonly JsonLdNode[];
21
22
  tone?: Tone;
@@ -23,6 +24,7 @@ type Props = HTMLAttributes<'html'> & {
23
24
 
24
25
  const {
25
26
  breadcrumbLabel,
27
+ breadcrumbTone,
26
28
  breadcrumbs,
27
29
  currentPath = Astro.url.pathname,
28
30
  'data-tone': dataTone,
@@ -59,7 +61,11 @@ const resolvedTone = tone ?? dataTone;
59
61
  <div class="site-main__content">
60
62
  {
61
63
  breadcrumbs?.length ? (
62
- <Breadcrumbs items={breadcrumbs} label={breadcrumbLabel} />
64
+ <Breadcrumbs
65
+ items={breadcrumbs}
66
+ label={breadcrumbLabel}
67
+ tone={breadcrumbTone}
68
+ />
63
69
  ) : null
64
70
  }
65
71
 
@@ -68,6 +74,7 @@ const resolvedTone = tone ?? dataTone;
68
74
  </PageSurface>
69
75
  </div>
70
76
  </main>
77
+ <slot name="footer" />
71
78
  </div>
72
79
  {scrollToTopLabel ? <ScrollToTop label={scrollToTopLabel} /> : null}
73
80
  </body>
@@ -17,6 +17,13 @@ export type ComboboxNumberBounds = {
17
17
  max: number;
18
18
  };
19
19
 
20
+ export type DynamicComboboxOption = {
21
+ description?: string;
22
+ disabled?: boolean;
23
+ label: string;
24
+ value: string;
25
+ };
26
+
20
27
  type NumberBounds = {
21
28
  min: number;
22
29
  max: number;
@@ -159,6 +166,68 @@ export function clearCombobox(
159
166
  root.dispatchEvent(new Event('combobox:clear'));
160
167
  }
161
168
 
169
+ export function setComboboxOptions(
170
+ root: HTMLElement,
171
+ options: readonly DynamicComboboxOption[],
172
+ selectedValues: readonly string[] = [],
173
+ ): void {
174
+ if (root.dataset.comboboxMode === 'number') return;
175
+ const optionsRoot = root.querySelector<HTMLElement>('[data-combobox-options]');
176
+ const empty = root.querySelector<HTMLElement>('[data-combobox-empty]');
177
+ if (!optionsRoot || !empty) return;
178
+
179
+ optionsRoot.querySelectorAll('[data-combobox-option]').forEach((node) => node.remove());
180
+ root.querySelectorAll('template[data-combobox-tag-template]').forEach((node) => node.remove());
181
+
182
+ options.forEach((option, index) => {
183
+ const button = document.createElement('button');
184
+ button.className = 'combobox__option';
185
+ button.id = `${root.id}-option-${index}`;
186
+ button.type = 'button';
187
+ button.role = 'option';
188
+ button.tabIndex = -1;
189
+ button.disabled = option.disabled === true;
190
+ button.setAttribute('aria-selected', 'false');
191
+ if (option.disabled) button.setAttribute('aria-disabled', 'true');
192
+ button.dataset.comboboxOption = option.value;
193
+ button.dataset.comboboxSearchText = [option.label, option.description ?? ''].join(' ');
194
+ button.dataset.interactionAccent = 'start';
195
+
196
+ const content = document.createElement('span');
197
+ content.className = 'combobox__option-content';
198
+ const label = document.createElement('span');
199
+ label.className = 'combobox__option-label';
200
+ label.textContent = option.label;
201
+ content.append(label);
202
+ if (option.description) {
203
+ const description = document.createElement('span');
204
+ description.className = 'combobox__option-description';
205
+ description.textContent = option.description;
206
+ content.append(description);
207
+ }
208
+ button.append(content);
209
+ optionsRoot.insertBefore(button, empty);
210
+
211
+ const template = document.createElement('template');
212
+ template.dataset.comboboxTagTemplate = option.value;
213
+ const tag = document.createElement('span');
214
+ tag.className = 'tag';
215
+ tag.dataset.widget = 'tag';
216
+ tag.dataset.size = 'sm';
217
+ tag.dataset.comboboxTag = option.value;
218
+ const tagLabel = document.createElement('span');
219
+ tagLabel.className = 'tag__label';
220
+ tagLabel.dataset.tagLabel = '';
221
+ tagLabel.textContent = option.label;
222
+ tag.append(tagLabel);
223
+ template.content.append(tag);
224
+ root.append(template);
225
+ });
226
+
227
+ empty.hidden = options.length > 0;
228
+ setComboboxValues(root, selectedValues);
229
+ }
230
+
162
231
  export function setComboboxNumberValue(
163
232
  root: HTMLElement,
164
233
  requestedValue: number,
@@ -130,7 +130,7 @@
130
130
  }
131
131
 
132
132
  .table-body__row {
133
- --table-primary-link-color: var(--nox-text);
133
+ --table-primary-link-color: var(--nox-content-link-color);
134
134
  --table-row-intrinsic-block-size: var(--table-row-min-block-size);
135
135
 
136
136
  display: grid;
@@ -173,7 +173,7 @@
173
173
  }
174
174
 
175
175
  .table-body__row[data-table-row-href]:focus-within {
176
- --table-primary-link-color: var(--nox-link-hover);
176
+ --table-primary-link-color: var(--nox-content-link-color);
177
177
  }
178
178
 
179
179
  .table-body__cell {
@@ -253,7 +253,7 @@
253
253
 
254
254
  @media (hover: hover) and (pointer: fine) {
255
255
  .table-body__row[data-table-row-href]:hover {
256
- --table-primary-link-color: var(--nox-link-hover);
256
+ --table-primary-link-color: var(--nox-content-link-color);
257
257
  }
258
258
  }
259
259
 
@@ -68,6 +68,10 @@
68
68
  var(--nox-motion-easing-standard);
69
69
  }
70
70
 
71
+ .table-cell__link [data-text-weight='strong'] {
72
+ font-weight: var(--nox-content-link-emphasis-font-weight);
73
+ }
74
+
71
75
  .table-cell__empty {
72
76
  color: var(--nox-text-muted);
73
77
  font-size: var(--nox-font-size-sm);
@@ -93,7 +97,7 @@
93
97
 
94
98
  @media (hover: hover) and (pointer: fine) {
95
99
  .table-cell__link:hover {
96
- color: var(--nox-link-hover);
100
+ color: var(--nox-content-link-color);
97
101
  }
98
102
  }
99
103
 
@@ -70,7 +70,20 @@ body {
70
70
  }
71
71
 
72
72
  a {
73
- color: inherit;
73
+ color: var(--nox-content-link-color);
74
+ font-weight: var(--nox-content-link-font-weight);
75
+ text-decoration: none;
76
+ text-decoration-thickness: 0.0625rem;
77
+ text-underline-offset: 0.2em;
78
+ }
79
+
80
+ a[data-tone] {
81
+ color: var(--nox-tone-color);
82
+ }
83
+
84
+ strong a,
85
+ a strong {
86
+ font-weight: var(--nox-content-link-emphasis-font-weight);
74
87
  }
75
88
 
76
89
  a:focus-visible,
@@ -83,6 +96,12 @@ button {
83
96
  font: inherit;
84
97
  }
85
98
 
99
+ @media (hover: hover) and (pointer: fine) {
100
+ a:where([href]:hover) {
101
+ text-decoration-line: underline;
102
+ }
103
+ }
104
+
86
105
  img,
87
106
  svg,
88
107
  video {
@@ -104,7 +123,6 @@ p {
104
123
  overflow-y: auto;
105
124
  background: var(--nox-page-background);
106
125
  overscroll-behavior-y: contain;
107
- scrollbar-gutter: stable both-edges;
108
126
  }
109
127
 
110
128
  .site-main {
@@ -113,18 +131,19 @@ p {
113
131
  display: grid;
114
132
  width: var(--nox-page-width);
115
133
  margin: 0 auto;
116
- padding-block: var(--site-main-content-gap) var(--nox-space-7);
117
- gap: var(--nox-layout-gap);
134
+ padding-block: var(--site-main-content-gap);
135
+ align-content: start;
118
136
  }
119
137
 
120
138
  .site-main__content {
121
139
  display: grid;
122
140
  min-width: 0;
141
+ align-content: start;
123
142
  gap: var(--site-main-content-gap);
124
143
  }
125
144
 
126
145
  .site-main:has(> .site-main__content > .breadcrumbs) {
127
- padding-block-start: 0;
146
+ padding-block: 0 var(--site-main-content-gap);
128
147
  }
129
148
 
130
149
  .site-main__content:has(> .breadcrumbs) {
@@ -134,8 +153,6 @@ p {
134
153
  @media (width < 48rem) {
135
154
  .site-main {
136
155
  --site-main-content-gap: var(--nox-space-4);
137
-
138
- padding-block-end: var(--nox-space-6);
139
156
  }
140
157
  }
141
158
 
@@ -60,6 +60,7 @@
60
60
  @import '../components/navigation/Breadcrumbs/Breadcrumbs.css'
61
61
  layer(components);
62
62
  @import '../components/navigation/Header.css' layer(components);
63
+ @import '../components/navigation/Footer/Footer.css' layer(components);
63
64
  @import '../components/navigation/NavigationBrand.css' layer(components);
64
65
  @import '../components/navigation/NavigationControl.css' layer(components);
65
66
  @import '../components/navigation/NavigationMenuList.css' layer(components);
@@ -9,6 +9,11 @@
9
9
  --nox-source-tone-warning: #d97706;
10
10
  --nox-source-tone-danger: #dc2626;
11
11
 
12
+ /* Shared textual-link recipe. */
13
+ --nox-content-link-color: var(--nox-source-tone-info);
14
+ --nox-content-link-font-weight: var(--nox-font-weight-regular);
15
+ --nox-content-link-emphasis-font-weight: var(--nox-font-weight-medium);
16
+
12
17
  /* Public map seeds. Component states derive their full treatment from these. */
13
18
  --nox-source-map-available: #00b86b;
14
19
  --nox-source-map-unavailable: #f43f5e;