@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.
- package/package.json +7 -1
- package/src/admin/contracts.ts +28 -0
- package/src/admin/index.ts +2 -0
- package/src/admin/module.ts +28 -0
- package/src/auth/contracts.ts +53 -0
- package/src/auth/firebase/client.ts +53 -0
- package/src/auth/firebase/contracts.ts +8 -0
- package/src/auth/firebase/index.ts +1 -0
- package/src/auth/index.ts +2 -0
- package/src/auth/module.ts +99 -0
- package/src/client/index.ts +3 -0
- package/src/client/preferences.ts +400 -0
- package/src/components/actions/Button/Button.astro +42 -6
- package/src/components/actions/Button/Button.css +19 -0
- package/src/components/actions/IdentityProviderAction/IdentityProviderAction.astro +35 -0
- package/src/components/content/CardBody/CardBody.astro +3 -0
- package/src/components/content/CardBody/CardBody.css +10 -0
- package/src/components/content/MarkdownContent/markdownContentRenderer.ts +549 -0
- package/src/components/feedback/PreferenceBootstrap/PreferenceBootstrap.astro +403 -0
- package/src/components/forms/Checkbox/Checkbox.astro +6 -0
- package/src/components/forms/ChoiceGroup/ChoiceGroup.astro +57 -0
- package/src/components/forms/ChoiceGroup/ChoiceGroup.css +108 -0
- package/src/components/forms/ChoiceGroup/contracts.ts +9 -0
- package/src/components/forms/DateTimeInput/DateTimeInput.astro +14 -0
- package/src/components/forms/TextInput/TextInput.astro +7 -1
- package/src/components/forms/Textarea/Textarea.astro +32 -0
- package/src/components/forms/Textarea/Textarea.css +54 -0
- package/src/components/icons/BackIcon.astro +26 -0
- package/src/components/icons/DeleteIcon.astro +22 -0
- package/src/components/icons/StatusIndicatorIcon.astro +16 -0
- package/src/components/index.ts +10 -0
- package/src/components/media/CanvasFrame/CanvasFrame.astro +1 -17
- package/src/components/media/EmbedFrame/EmbedFrame.astro +1 -19
- package/src/components/media/EmbedFrame/EmbedFrame.css +0 -2
- package/src/components/navigation/SubNavigation/SubNavigation.css +0 -5
- package/src/composition/SurfaceGroup/SurfaceGroup.css +21 -7
- package/src/composition/SurfaceGroup/stickySurfaceGroupController.ts +3 -0
- package/src/data/firestore/client/contracts.ts +55 -0
- package/src/data/firestore/client/firebase.ts +12 -0
- package/src/data/firestore/client/index.ts +3 -0
- package/src/data/firestore/client/repository.ts +111 -0
- package/src/display/contracts.ts +40 -0
- package/src/display/index.ts +1 -0
- package/src/display/tableProgressive.ts +62 -0
- package/src/patterns/Combobox/Combobox.astro +23 -0
- package/src/patterns/Fullscreen/FullscreenControl.astro +4 -14
- package/src/patterns/Fullscreen/FullscreenControl.css +0 -26
- package/src/patterns/Modal/Modal.astro +1 -1
- package/src/patterns/Modal/Modal.css +38 -1
- package/src/patterns/Table/Table.astro +122 -7
- package/src/patterns/Table/TableBody.astro +8 -0
- package/src/patterns/Table/TableCell.css +6 -0
- package/src/patterns/Table/TableCellValue.astro +31 -0
- package/src/patterns/Table/TableFooter.astro +3 -1
- package/src/patterns/Table/TableHeader.astro +1 -0
- package/src/patterns/Table/TablePagination.astro +63 -47
- package/src/patterns/Table/TableSkeleton.astro +18 -22
- package/src/patterns/Table/TableSkeleton.css +30 -5
- package/src/patterns/Table/tableClientController.ts +253 -0
- package/src/patterns/Table/tableFilterController.ts +23 -31
- package/src/patterns/Table/tableProgressiveController.ts +615 -227
- package/src/patterns/Table/tableSortController.ts +25 -7
- package/src/styles/interaction.css +16 -0
- 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,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>
|