@astronox/core 0.2.1 → 0.2.2

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 (64) hide show
  1. package/package.json +7 -1
  2. package/src/admin/contracts.ts +28 -0
  3. package/src/admin/index.ts +2 -0
  4. package/src/admin/module.ts +28 -0
  5. package/src/auth/contracts.ts +53 -0
  6. package/src/auth/firebase/client.ts +53 -0
  7. package/src/auth/firebase/contracts.ts +8 -0
  8. package/src/auth/firebase/index.ts +1 -0
  9. package/src/auth/index.ts +2 -0
  10. package/src/auth/module.ts +99 -0
  11. package/src/client/index.ts +3 -0
  12. package/src/client/preferences.ts +400 -0
  13. package/src/components/actions/Button/Button.astro +42 -6
  14. package/src/components/actions/Button/Button.css +19 -0
  15. package/src/components/actions/IdentityProviderAction/IdentityProviderAction.astro +35 -0
  16. package/src/components/content/CardBody/CardBody.astro +3 -0
  17. package/src/components/content/CardBody/CardBody.css +10 -0
  18. package/src/components/content/MarkdownContent/markdownContentRenderer.ts +549 -0
  19. package/src/components/feedback/PreferenceBootstrap/PreferenceBootstrap.astro +403 -0
  20. package/src/components/forms/Checkbox/Checkbox.astro +6 -0
  21. package/src/components/forms/ChoiceGroup/ChoiceGroup.astro +57 -0
  22. package/src/components/forms/ChoiceGroup/ChoiceGroup.css +108 -0
  23. package/src/components/forms/ChoiceGroup/contracts.ts +9 -0
  24. package/src/components/forms/DateTimeInput/DateTimeInput.astro +14 -0
  25. package/src/components/forms/TextInput/TextInput.astro +7 -1
  26. package/src/components/forms/Textarea/Textarea.astro +32 -0
  27. package/src/components/forms/Textarea/Textarea.css +54 -0
  28. package/src/components/icons/BackIcon.astro +26 -0
  29. package/src/components/icons/DeleteIcon.astro +22 -0
  30. package/src/components/icons/StatusIndicatorIcon.astro +16 -0
  31. package/src/components/index.ts +10 -0
  32. package/src/components/media/CanvasFrame/CanvasFrame.astro +1 -17
  33. package/src/components/media/EmbedFrame/EmbedFrame.astro +1 -19
  34. package/src/components/media/EmbedFrame/EmbedFrame.css +0 -2
  35. package/src/components/navigation/SubNavigation/SubNavigation.css +0 -5
  36. package/src/composition/SurfaceGroup/SurfaceGroup.css +21 -7
  37. package/src/composition/SurfaceGroup/stickySurfaceGroupController.ts +3 -0
  38. package/src/data/firestore/client/contracts.ts +55 -0
  39. package/src/data/firestore/client/firebase.ts +12 -0
  40. package/src/data/firestore/client/index.ts +3 -0
  41. package/src/data/firestore/client/repository.ts +111 -0
  42. package/src/display/contracts.ts +40 -0
  43. package/src/display/index.ts +1 -0
  44. package/src/display/tableProgressive.ts +62 -0
  45. package/src/patterns/Combobox/Combobox.astro +23 -0
  46. package/src/patterns/Fullscreen/FullscreenControl.astro +4 -14
  47. package/src/patterns/Fullscreen/FullscreenControl.css +0 -26
  48. package/src/patterns/Modal/Modal.astro +1 -1
  49. package/src/patterns/Modal/Modal.css +38 -1
  50. package/src/patterns/Table/Table.astro +122 -7
  51. package/src/patterns/Table/TableBody.astro +8 -0
  52. package/src/patterns/Table/TableCell.css +6 -0
  53. package/src/patterns/Table/TableCellValue.astro +31 -0
  54. package/src/patterns/Table/TableFooter.astro +3 -1
  55. package/src/patterns/Table/TableHeader.astro +1 -0
  56. package/src/patterns/Table/TablePagination.astro +63 -47
  57. package/src/patterns/Table/TableSkeleton.astro +18 -22
  58. package/src/patterns/Table/TableSkeleton.css +30 -5
  59. package/src/patterns/Table/tableClientController.ts +253 -0
  60. package/src/patterns/Table/tableFilterController.ts +23 -31
  61. package/src/patterns/Table/tableProgressiveController.ts +615 -227
  62. package/src/patterns/Table/tableSortController.ts +25 -7
  63. package/src/styles/interaction.css +16 -0
  64. package/src/styles/style.css +2 -0
@@ -0,0 +1,403 @@
1
+ ---
2
+ import type { AnyPreferenceDefinition } from '../../../client/preferences';
3
+
4
+ type Props = {
5
+ definitions?: readonly AnyPreferenceDefinition[];
6
+ };
7
+
8
+ const { definitions = [] }: Props = Astro.props;
9
+ const payload = definitions.map((definition) => ({
10
+ id: definition.id,
11
+ key: definition.key,
12
+ version: definition.version,
13
+ defaultValue: definition.defaultValue,
14
+ schema: definition.schema,
15
+ migrations: definition.migrations ?? [],
16
+ }));
17
+ ---
18
+
19
+ <script is:inline define:vars={{ definitions: payload }}>
20
+ (() => {
21
+ if (definitions.length === 0) return;
22
+
23
+ const globalKey = '__astronoxPreferenceSnapshot';
24
+ const changeEvent = 'astronox:preference-change';
25
+ const changeSource = 'bootstrap';
26
+ const definitionMap = new Map(
27
+ definitions.map((definition) => [definition.id, definition]),
28
+ );
29
+ const clone = (value) =>
30
+ typeof structuredClone === 'function'
31
+ ? structuredClone(value)
32
+ : JSON.parse(JSON.stringify(value));
33
+ const isRecord = (value) =>
34
+ typeof value === 'object' && value !== null && !Array.isArray(value);
35
+ const matches = (value, schema) => {
36
+ if (schema.type === 'boolean') return typeof value === 'boolean';
37
+ if (schema.type === 'string') {
38
+ return (
39
+ typeof value === 'string' &&
40
+ (!schema.values || schema.values.includes(value))
41
+ );
42
+ }
43
+ if (schema.type === 'number' || schema.type === 'integer') {
44
+ if (
45
+ typeof value !== 'number' ||
46
+ !Number.isFinite(value) ||
47
+ (schema.type === 'integer' && !Number.isInteger(value)) ||
48
+ (schema.min !== undefined && value < schema.min) ||
49
+ (schema.max !== undefined && value > schema.max)
50
+ ) {
51
+ return false;
52
+ }
53
+ return (
54
+ schema.type !== 'integer' ||
55
+ !schema.values ||
56
+ schema.values.includes(value)
57
+ );
58
+ }
59
+ if (schema.type === 'array') {
60
+ return (
61
+ Array.isArray(value) &&
62
+ value.every((item) => matches(item, schema.items))
63
+ );
64
+ }
65
+ return (
66
+ isRecord(value) &&
67
+ Object.entries(schema.properties).every(([key, propertySchema]) =>
68
+ matches(value[key], propertySchema),
69
+ )
70
+ );
71
+ };
72
+ const parse = (raw) => {
73
+ if (raw === null) return undefined;
74
+ try {
75
+ return JSON.parse(raw);
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ };
80
+ const read = (key) => {
81
+ try {
82
+ return window.localStorage.getItem(key);
83
+ } catch {
84
+ return null;
85
+ }
86
+ };
87
+ const write = (key, value) => {
88
+ try {
89
+ window.localStorage.setItem(key, JSON.stringify(value));
90
+ } catch {
91
+ // The document-scoped snapshot remains usable without storage.
92
+ }
93
+ };
94
+ const storageKey = (definition, version = definition.version, key) =>
95
+ `${key ?? definition.key}:v${version}`;
96
+ const migrate = (value, migration) => {
97
+ if (!isRecord(value)) return value;
98
+ const next = { ...value };
99
+ for (const [from, to] of Object.entries(migration.rename ?? {})) {
100
+ if (from in next && !(to in next)) next[to] = next[from];
101
+ delete next[from];
102
+ }
103
+ for (const [key, fallback] of Object.entries(
104
+ migration.defaults ?? {},
105
+ )) {
106
+ if (!(key in next)) next[key] = fallback;
107
+ }
108
+ return next;
109
+ };
110
+ const resolveDefinition = (definition) => {
111
+ const current = parse(read(storageKey(definition)));
112
+ if (matches(current, definition.schema)) return current;
113
+
114
+ const migrations = [...definition.migrations].sort(
115
+ (left, right) => right.fromVersion - left.fromVersion,
116
+ );
117
+ for (const migration of migrations) {
118
+ const previous = parse(
119
+ read(
120
+ storageKey(
121
+ definition,
122
+ migration.fromVersion,
123
+ migration.fromKey,
124
+ ),
125
+ ),
126
+ );
127
+ const migrated = migrate(previous, migration);
128
+ if (matches(migrated, definition.schema)) {
129
+ write(storageKey(definition), migrated);
130
+ return migrated;
131
+ }
132
+ }
133
+
134
+ const fallback = clone(definition.defaultValue);
135
+ write(storageKey(definition), fallback);
136
+ return fallback;
137
+ };
138
+
139
+ const previousSnapshot = window[globalKey];
140
+ const snapshot = {
141
+ values: isRecord(previousSnapshot?.values)
142
+ ? previousSnapshot.values
143
+ : {},
144
+ rawValues: isRecord(previousSnapshot?.rawValues)
145
+ ? previousSnapshot.rawValues
146
+ : {},
147
+ };
148
+ for (const definition of definitions) {
149
+ const value = resolveDefinition(definition);
150
+ snapshot.values[definition.id] = value;
151
+ snapshot.rawValues[definition.id] = JSON.stringify(value);
152
+ }
153
+ window[globalKey] = snapshot;
154
+
155
+ const resolvePath = (binding) => {
156
+ let value = snapshot.values[binding.id];
157
+ for (const segment of (binding.path ?? '').split('.').filter(Boolean)) {
158
+ if (!isRecord(value) || !(segment in value)) return undefined;
159
+ value = value[segment];
160
+ }
161
+ return value;
162
+ };
163
+ const parseBinding = (element) => {
164
+ const id = element.dataset.preferenceId;
165
+ if (!id || !definitionMap.has(id)) return null;
166
+ return { id, path: element.dataset.preferencePath };
167
+ };
168
+ const resolveConditionalBounds = (element) => {
169
+ const raw = element.dataset.preferenceNumberBounds;
170
+ if (!raw) return null;
171
+ try {
172
+ const bounds = JSON.parse(raw);
173
+ if (!bounds.when) return bounds;
174
+ return Object.is(resolvePath(bounds.when), bounds.when.equals)
175
+ ? bounds
176
+ : null;
177
+ } catch {
178
+ return null;
179
+ }
180
+ };
181
+ const normalizeNumber = (root, requestedValue) => {
182
+ root.dataset.preferenceBaseMin ??= root.dataset.comboboxNumberMin ?? '';
183
+ root.dataset.preferenceBaseMax ??= root.dataset.comboboxNumberMax ?? '';
184
+ const override = resolveConditionalBounds(root);
185
+ const min = override?.min ?? Number(root.dataset.preferenceBaseMin);
186
+ const max = override?.max ?? Number(root.dataset.preferenceBaseMax);
187
+ const step = Number(root.dataset.comboboxNumberStep) || 1;
188
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return;
189
+
190
+ const options = [...root.querySelectorAll('[data-combobox-option]')];
191
+ const available = options
192
+ .map((option) => Number(option.dataset.comboboxOption))
193
+ .filter((value) => Number.isFinite(value) && value >= min && value <= max)
194
+ .sort((left, right) => left - right);
195
+ const bounded = Math.min(max, Math.max(min, Number(requestedValue)));
196
+ const value =
197
+ available.length > 0
198
+ ? available.reduce((closest, candidate) =>
199
+ Math.abs(candidate - bounded) < Math.abs(closest - bounded)
200
+ ? candidate
201
+ : closest,
202
+ )
203
+ : Math.min(
204
+ max,
205
+ Math.max(
206
+ min,
207
+ min + Math.round((bounded - min) / step) * step,
208
+ ),
209
+ );
210
+
211
+ root.dataset.comboboxNumberMin = String(min);
212
+ root.dataset.comboboxNumberMax = String(max);
213
+ for (const option of options) {
214
+ const optionValue = Number(option.dataset.comboboxOption);
215
+ option.hidden = optionValue < min || optionValue > max;
216
+ option.setAttribute('aria-selected', String(optionValue === value));
217
+ }
218
+ const input = root.querySelector('[data-combobox-number-input]');
219
+ if (input instanceof HTMLInputElement) {
220
+ input.value = String(value);
221
+ input.setAttribute('aria-valuemin', String(min));
222
+ input.setAttribute('aria-valuemax', String(max));
223
+ input.setAttribute('aria-valuenow', String(value));
224
+ }
225
+ const decrement = root.querySelector(
226
+ '[data-combobox-number-step="decrement"]',
227
+ );
228
+ const increment = root.querySelector(
229
+ '[data-combobox-number-step="increment"]',
230
+ );
231
+ if (decrement instanceof HTMLButtonElement) decrement.disabled = value <= min;
232
+ if (increment instanceof HTMLButtonElement) increment.disabled = value >= max;
233
+ };
234
+ const applyOptions = (root, requestedValue) => {
235
+ const requested = Array.isArray(requestedValue)
236
+ ? requestedValue.map(String)
237
+ : requestedValue === undefined
238
+ ? []
239
+ : [String(requestedValue)];
240
+ const selected = new Set(requested);
241
+ for (const option of root.querySelectorAll('[data-combobox-option]')) {
242
+ option.setAttribute(
243
+ 'aria-selected',
244
+ String(selected.has(option.dataset.comboboxOption ?? '')),
245
+ );
246
+ }
247
+ const valuesRoot = root.querySelector('[data-combobox-values]');
248
+ const placeholder = root.querySelector('[data-combobox-placeholder]');
249
+ if (
250
+ !(valuesRoot instanceof HTMLElement) ||
251
+ !(placeholder instanceof HTMLElement)
252
+ ) {
253
+ return;
254
+ }
255
+ for (const tag of valuesRoot.querySelectorAll('[data-combobox-tag]')) {
256
+ tag.remove();
257
+ }
258
+ for (const value of requested) {
259
+ const template = [
260
+ ...root.querySelectorAll('template[data-combobox-tag-template]'),
261
+ ].find((candidate) => candidate.dataset.comboboxTagTemplate === value);
262
+ const tag = template?.content.firstElementChild?.cloneNode(true);
263
+ if (tag) valuesRoot.insertBefore(tag, placeholder);
264
+ }
265
+ placeholder.hidden = requested.length > 0;
266
+ };
267
+ const applyBinding = (element) => {
268
+ if (!(element instanceof HTMLElement)) return;
269
+ const binding = parseBinding(element);
270
+ if (!binding) return;
271
+ const value = resolvePath(binding);
272
+ const control = element.dataset.preferenceControl;
273
+
274
+ if (control === 'checkbox') {
275
+ const input = element.querySelector('input[type="checkbox"]');
276
+ if (input instanceof HTMLInputElement && typeof value === 'boolean') {
277
+ input.checked = value;
278
+ }
279
+ } else if (control === 'text') {
280
+ const input = element.querySelector('[data-text-input-control]');
281
+ if (input instanceof HTMLInputElement && typeof value === 'string') {
282
+ input.value = value;
283
+ }
284
+ } else if (control === 'combobox-number' && typeof value === 'number') {
285
+ normalizeNumber(element, value);
286
+ } else if (control === 'combobox-options') {
287
+ applyOptions(element, value);
288
+ }
289
+ };
290
+ const applyBindings = (root) => {
291
+ if (root instanceof HTMLElement && root.matches('[data-preference-id]')) {
292
+ applyBinding(root);
293
+ }
294
+ for (const element of root.querySelectorAll?.('[data-preference-id]') ?? []) {
295
+ applyBinding(element);
296
+ }
297
+ };
298
+ const setPath = (binding, value) => {
299
+ const definition = definitionMap.get(binding.id);
300
+ if (!definition) return;
301
+ let next = snapshot.values[binding.id];
302
+ const path = (binding.path ?? '').split('.').filter(Boolean);
303
+ if (path.length === 0) {
304
+ next = value;
305
+ } else {
306
+ next = isRecord(next) ? clone(next) : {};
307
+ let cursor = next;
308
+ for (const segment of path.slice(0, -1)) {
309
+ cursor[segment] = isRecord(cursor[segment])
310
+ ? { ...cursor[segment] }
311
+ : {};
312
+ cursor = cursor[segment];
313
+ }
314
+ cursor[path[path.length - 1]] = value;
315
+ }
316
+ if (!matches(next, definition.schema)) return;
317
+
318
+ const raw = JSON.stringify(next);
319
+ if (snapshot.rawValues[binding.id] === raw) return;
320
+ snapshot.values[binding.id] = next;
321
+ snapshot.rawValues[binding.id] = raw;
322
+ write(storageKey(definition), next);
323
+ document.dispatchEvent(
324
+ new CustomEvent(changeEvent, {
325
+ detail: { id: binding.id, source: changeSource, value: next },
326
+ }),
327
+ );
328
+ };
329
+ const handleChange = (event) => {
330
+ const target = event.target;
331
+ if (!(target instanceof HTMLInputElement)) return;
332
+ const root = target.closest('[data-preference-id]');
333
+ if (!(root instanceof HTMLElement)) return;
334
+ const binding = parseBinding(root);
335
+ if (!binding) return;
336
+ if (root.dataset.preferenceControl === 'checkbox') {
337
+ setPath(binding, target.checked);
338
+ } else if (root.dataset.preferenceControl === 'text') {
339
+ setPath(binding, target.value);
340
+ }
341
+ };
342
+ const handleComboboxChange = (event) => {
343
+ const root = event.target;
344
+ if (!(root instanceof HTMLElement)) return;
345
+ const binding = parseBinding(root);
346
+ if (!binding) return;
347
+ const detail = event.detail;
348
+ setPath(
349
+ binding,
350
+ detail?.mode === 'number'
351
+ ? detail.value
352
+ : root.dataset.comboboxSelectionMode === 'multiple'
353
+ ? detail?.values ?? []
354
+ : detail?.values?.[0] ?? '',
355
+ );
356
+ };
357
+ const handlePreferenceChange = (event) => {
358
+ const detail = event.detail;
359
+ const definition = detail ? definitionMap.get(detail.id) : undefined;
360
+ if (!definition || !matches(detail.value, definition.schema)) return;
361
+ snapshot.values[detail.id] = detail.value;
362
+ snapshot.rawValues[detail.id] = JSON.stringify(detail.value);
363
+ applyBindings(document.documentElement);
364
+ };
365
+ const handleStorage = (event) => {
366
+ const definition = definitions.find(
367
+ (candidate) => storageKey(candidate) === event.key,
368
+ );
369
+ if (!definition) return;
370
+ const value = parse(event.newValue);
371
+ if (!matches(value, definition.schema)) return;
372
+ const raw = JSON.stringify(value);
373
+ if (snapshot.rawValues[definition.id] === raw) return;
374
+ snapshot.values[definition.id] = value;
375
+ snapshot.rawValues[definition.id] = raw;
376
+ document.dispatchEvent(
377
+ new CustomEvent(changeEvent, {
378
+ detail: { id: definition.id, source: changeSource, value },
379
+ }),
380
+ );
381
+ };
382
+
383
+ const observer = new MutationObserver((records) => {
384
+ for (const record of records) {
385
+ for (const node of record.addedNodes) applyBindings(node);
386
+ }
387
+ });
388
+ observer.observe(document.documentElement, { childList: true, subtree: true });
389
+ document.addEventListener('change', handleChange, true);
390
+ document.addEventListener('combobox:change', handleComboboxChange);
391
+ document.addEventListener(changeEvent, handlePreferenceChange);
392
+ window.addEventListener('storage', handleStorage);
393
+ applyBindings(document.documentElement);
394
+ document.addEventListener(
395
+ 'DOMContentLoaded',
396
+ () => {
397
+ applyBindings(document.documentElement);
398
+ observer.disconnect();
399
+ },
400
+ { once: true },
401
+ );
402
+ })();
403
+ </script>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types';
3
+ import type { PreferenceBinding } from '../../../client/preferences';
3
4
  import type { Tone } from '../../../theme/contracts';
4
5
  import CheckIcon from '../../icons/CheckIcon.astro';
5
6
  import CloseIcon from '../../icons/CloseIcon.astro';
@@ -9,6 +10,7 @@ type Props = Omit<HTMLAttributes<'input'>, 'class' | 'id' | 'type'> & {
9
10
  label: string;
10
11
  class?: string;
11
12
  invalid?: boolean;
13
+ preference?: PreferenceBinding;
12
14
  tone?: Tone;
13
15
  };
14
16
 
@@ -20,6 +22,7 @@ const {
20
22
  id,
21
23
  invalid = false,
22
24
  label,
25
+ preference,
23
26
  tone,
24
27
  ...attrs
25
28
  }: Props = Astro.props;
@@ -35,6 +38,9 @@ const resolvedTone = tone ?? dataTone;
35
38
  data-invalid={resolvedInvalid ? true : undefined}
36
39
  data-tone={resolvedTone}
37
40
  data-widget="checkbox"
41
+ data-preference-id={preference?.id}
42
+ data-preference-path={preference?.path}
43
+ data-preference-control={preference ? 'checkbox' : undefined}
38
44
  >
39
45
  <input
40
46
  {...attrs}
@@ -0,0 +1,57 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+ import type { ChoiceOption } from './contracts';
4
+
5
+ type Props = Omit<HTMLAttributes<'fieldset'>, 'name'> & {
6
+ label: string;
7
+ name: string;
8
+ options: readonly [ChoiceOption, ...ChoiceOption[]];
9
+ required?: boolean;
10
+ value?: string;
11
+ };
12
+
13
+ const {
14
+ class: className,
15
+ disabled = false,
16
+ label,
17
+ name,
18
+ options,
19
+ required = false,
20
+ value,
21
+ ...attrs
22
+ }: Props = Astro.props;
23
+ ---
24
+
25
+ <fieldset
26
+ {...attrs}
27
+ class:list={['choice-group', className]}
28
+ data-widget="choice-group"
29
+ disabled={disabled}
30
+ >
31
+ <legend class="choice-group__legend">{label}</legend>
32
+ <div
33
+ class="choice-group__options"
34
+ style={`--choice-group-columns: ${options.length}`}
35
+ >
36
+ {
37
+ options.map((option) => {
38
+ const Icon = option.icon;
39
+
40
+ return (
41
+ <label class="choice-group__option" data-tone={option.tone}>
42
+ <input
43
+ class="choice-group__input"
44
+ type="radio"
45
+ name={name}
46
+ value={option.value}
47
+ checked={option.value === value}
48
+ required={required}
49
+ />
50
+ {Icon ? <Icon class="choice-group__icon" /> : null}
51
+ <span class="choice-group__label">{option.label}</span>
52
+ </label>
53
+ );
54
+ })
55
+ }
56
+ </div>
57
+ </fieldset>
@@ -0,0 +1,108 @@
1
+ .choice-group {
2
+ min-inline-size: 0;
3
+ margin: 0;
4
+ padding: 0;
5
+ border: 0;
6
+ }
7
+
8
+ .choice-group__legend {
9
+ margin-block-end: var(--nox-space-2);
10
+ padding: 0;
11
+ color: var(--nox-text-muted);
12
+ font-size: var(--nox-font-size-xs);
13
+ font-weight: var(--nox-font-weight-strong);
14
+ letter-spacing: var(--nox-letter-spacing-wide);
15
+ line-height: var(--nox-line-height-tight);
16
+ text-transform: uppercase;
17
+ }
18
+
19
+ .choice-group__options {
20
+ display: grid;
21
+ min-inline-size: 0;
22
+ grid-template-columns: repeat(
23
+ var(--choice-group-columns),
24
+ minmax(0, 1fr)
25
+ );
26
+ gap: var(--nox-space-2);
27
+ }
28
+
29
+ .choice-group__option {
30
+ position: relative;
31
+ display: flex;
32
+ min-inline-size: 0;
33
+ block-size: var(--nox-field-control-block-size);
34
+ align-items: center;
35
+ justify-content: center;
36
+ gap: var(--nox-space-2);
37
+ padding: var(--nox-space-2) var(--nox-space-3);
38
+ overflow: hidden;
39
+ border: var(--nox-border-width) solid var(--nox-field-border);
40
+ border-radius: var(--nox-radius-sm);
41
+ background: var(--nox-field-background);
42
+ color: var(--nox-text);
43
+ cursor: pointer;
44
+ }
45
+
46
+ .choice-group__input {
47
+ position: absolute;
48
+ inset: 0;
49
+ margin: 0;
50
+ opacity: 0;
51
+ cursor: pointer;
52
+ }
53
+
54
+ .choice-group__icon {
55
+ flex: 0 0 auto;
56
+ inline-size: 1rem;
57
+ block-size: 1rem;
58
+ color: var(--nox-tone-color);
59
+ }
60
+
61
+ .choice-group__label {
62
+ min-inline-size: 0;
63
+ overflow: hidden;
64
+ font-size: var(--nox-font-size-xs);
65
+ font-weight: var(--nox-font-weight-strong);
66
+ letter-spacing: var(--nox-letter-spacing-wide);
67
+ line-height: 1;
68
+ text-overflow: ellipsis;
69
+ text-transform: uppercase;
70
+ white-space: nowrap;
71
+ }
72
+
73
+ .choice-group__option:not(:has(.choice-group__input:checked)) {
74
+ background: var(--nox-field-background-disabled);
75
+ color: var(--nox-text-disabled);
76
+ opacity: 0.58;
77
+ }
78
+
79
+ .choice-group__option:has(.choice-group__input:checked) {
80
+ border-color: var(--nox-tone-color);
81
+ background: color-mix(
82
+ in srgb,
83
+ var(--nox-tone-seed) 12%,
84
+ var(--nox-mix-light)
85
+ );
86
+ color: var(--nox-tone-text);
87
+ }
88
+
89
+ .choice-group__option:has(.choice-group__input:focus-visible) {
90
+ border-color: var(--nox-tone-color);
91
+ box-shadow: var(--nox-field-focus-shadow);
92
+ }
93
+
94
+ .choice-group:disabled .choice-group__option {
95
+ background: var(--nox-field-background-disabled);
96
+ color: var(--nox-text-disabled);
97
+ cursor: not-allowed;
98
+ }
99
+
100
+ .choice-group:disabled .choice-group__input {
101
+ cursor: not-allowed;
102
+ }
103
+
104
+ @media (hover: hover) and (pointer: fine) {
105
+ .choice-group:not(:disabled) .choice-group__option:hover {
106
+ border-color: var(--nox-tone-color);
107
+ }
108
+ }
@@ -0,0 +1,9 @@
1
+ import type { AstroComponent } from '../../widgetTypes';
2
+ import type { Tone } from '../../../theme/contracts';
3
+
4
+ export type ChoiceOption = {
5
+ icon?: AstroComponent;
6
+ label: string;
7
+ tone?: Tone;
8
+ value: string;
9
+ };
@@ -0,0 +1,14 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+ import type { Tone } from '../../../theme/contracts';
4
+ import TextInput from '../TextInput/TextInput.astro';
5
+
6
+ type Props = Omit<HTMLAttributes<'input'>, 'type'> & {
7
+ invalid?: boolean;
8
+ tone?: Tone;
9
+ };
10
+
11
+ const props: Props = Astro.props;
12
+ ---
13
+
14
+ <TextInput {...props} type="datetime-local" clearable={false} />
@@ -1,10 +1,11 @@
1
1
  ---
2
2
  import type { HTMLAttributes } from 'astro/types';
3
+ import type { PreferenceBinding } from '../../../client/preferences';
3
4
  import type { Tone } from '../../../theme/contracts';
4
5
  import CloseIcon from '../../icons/CloseIcon.astro';
5
6
 
6
7
  type TextInputFraming = 'embedded' | 'standalone';
7
- type TextInputType = 'search' | 'text';
8
+ type TextInputType = 'datetime-local' | 'search' | 'text';
8
9
  type TextInputAutofocus = boolean | 'desktop';
9
10
 
10
11
  type Props = Omit<HTMLAttributes<'input'>, 'autofocus' | 'type'> & {
@@ -13,6 +14,7 @@ type Props = Omit<HTMLAttributes<'input'>, 'autofocus' | 'type'> & {
13
14
  clearLabel?: string;
14
15
  framing?: TextInputFraming;
15
16
  invalid?: boolean;
17
+ preference?: PreferenceBinding;
16
18
  tone?: Tone;
17
19
  type?: TextInputType;
18
20
  };
@@ -26,6 +28,7 @@ const {
26
28
  'data-tone': dataTone,
27
29
  framing = 'standalone',
28
30
  invalid = false,
31
+ preference,
29
32
  tone,
30
33
  type = 'text',
31
34
  ...attrs
@@ -43,6 +46,9 @@ const resolvedTone = tone ?? dataTone;
43
46
  data-tone={resolvedTone}
44
47
  data-text-input-autofocus={autofocus === 'desktop' ? 'desktop' : undefined}
45
48
  data-text-input-field
49
+ data-preference-id={preference?.id}
50
+ data-preference-path={preference?.path}
51
+ data-preference-control={preference ? 'text' : undefined}
46
52
  >
47
53
  <input
48
54
  {...attrs}
@@ -0,0 +1,32 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+ import type { Tone } from '../../../theme/contracts';
4
+
5
+ type Props = HTMLAttributes<'textarea'> & {
6
+ invalid?: boolean;
7
+ tone?: Tone;
8
+ value?: string;
9
+ };
10
+
11
+ const {
12
+ 'aria-invalid': ariaInvalid,
13
+ class: className,
14
+ 'data-tone': dataTone,
15
+ invalid = false,
16
+ tone,
17
+ value = '',
18
+ ...attrs
19
+ }: Props = Astro.props;
20
+ const resolvedInvalid =
21
+ invalid || ariaInvalid === true || ariaInvalid === 'true';
22
+ const resolvedTone = tone ?? dataTone;
23
+ ---
24
+
25
+ <textarea
26
+ {...attrs}
27
+ class:list={['textarea', className]}
28
+ data-widget="textarea"
29
+ data-invalid={resolvedInvalid ? true : undefined}
30
+ data-tone={resolvedTone}
31
+ aria-invalid={resolvedInvalid ? 'true' : ariaInvalid}
32
+ >{value}</textarea>