@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.
- package/package.json +1 -1
- package/src/admin/cache.ts +217 -0
- package/src/admin/index.ts +1 -0
- package/src/auth/firebase/client.ts +37 -7
- package/src/client/formSubmission.ts +177 -0
- package/src/client/index.ts +1 -0
- package/src/components/actions/ScrollToTop/ScrollToTop.css +11 -0
- package/src/components/content/CardBody/CardBody.css +6 -2
- package/src/components/content/CardFooter/CardFooter.astro +9 -0
- package/src/components/content/CardFooter/CardFooter.css +45 -3
- package/src/components/content/CardLink/CardLink.astro +12 -3
- package/src/components/content/CardLink/CardLink.css +32 -0
- package/src/components/content/MarkdownContent/MarkdownContent.css +14 -4
- package/src/components/content/MarkdownContent/MarkdownInline.astro +1 -1
- package/src/components/content/MarkdownContent/contracts.ts +1 -1
- package/src/components/content/MarkdownContent/markdownContentRenderer.ts +7 -4
- package/src/components/data-display/TextPair/TextPair.css +8 -2
- package/src/components/forms/PreparedFormBootstrap/PreparedFormBootstrap.astro +203 -0
- package/src/components/forms/PreparedFormBootstrap/contracts.ts +15 -0
- package/src/components/forms/Textarea/Textarea.astro +3 -0
- package/src/components/forms/Textarea/Textarea.css +8 -0
- package/src/components/index.ts +8 -0
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.astro +5 -1
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.css +12 -17
- package/src/components/navigation/Footer/Footer.astro +102 -0
- package/src/components/navigation/Footer/Footer.css +155 -0
- package/src/components/navigation/Footer/contracts.ts +17 -0
- package/src/composition/Grid/Grid.css +16 -0
- package/src/data/firestore/client/contracts.ts +23 -0
- package/src/data/firestore/client/firebase.ts +45 -1
- package/src/display/contracts.ts +4 -1
- package/src/layouts/BaseLayout.astro +8 -1
- package/src/patterns/Combobox/comboboxController.ts +69 -0
- package/src/patterns/Table/TableBody.css +3 -3
- package/src/patterns/Table/TableCell.css +5 -1
- package/src/styles/base.css +24 -7
- package/src/styles/style.css +1 -0
- package/src/styles/tokens.css +5 -0
package/package.json
CHANGED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { AdminPage } from './contracts';
|
|
2
|
+
|
|
3
|
+
export type AdminResourceCacheSerializer<TItem, TStored> = {
|
|
4
|
+
parse(value: unknown): TItem | null;
|
|
5
|
+
serialize(item: TItem): TStored;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type AdminResourceCacheConfig<TItem, TStored> = {
|
|
9
|
+
collectionKey: string;
|
|
10
|
+
editorKey: string;
|
|
11
|
+
getKey(item: TItem): string;
|
|
12
|
+
serializer: AdminResourceCacheSerializer<TItem, TStored>;
|
|
13
|
+
sort(items: readonly TItem[]): TItem[];
|
|
14
|
+
ttlMs?: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type StoredPage<TStored> = {
|
|
18
|
+
expiresAt: number;
|
|
19
|
+
items: TStored[];
|
|
20
|
+
nextCursor?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type StoredEditor<TStored> = {
|
|
24
|
+
actorId: string;
|
|
25
|
+
expiresAt: number;
|
|
26
|
+
item: TStored;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const localCursorPrefix = 'cache:';
|
|
30
|
+
|
|
31
|
+
export function createAdminResourceCache<TItem, TStored>({
|
|
32
|
+
collectionKey,
|
|
33
|
+
editorKey,
|
|
34
|
+
getKey,
|
|
35
|
+
serializer,
|
|
36
|
+
sort,
|
|
37
|
+
ttlMs = 10 * 60 * 1000,
|
|
38
|
+
}: AdminResourceCacheConfig<TItem, TStored>) {
|
|
39
|
+
const collectionCacheKey = (actorId: string) =>
|
|
40
|
+
`${collectionKey}:${encodeURIComponent(actorId)}`;
|
|
41
|
+
const editorCacheKey = (id: string) =>
|
|
42
|
+
`${editorKey}:${encodeURIComponent(id)}`;
|
|
43
|
+
|
|
44
|
+
const readSnapshot = (actorId: string): AdminPage<TItem> | null => {
|
|
45
|
+
try {
|
|
46
|
+
const key = collectionCacheKey(actorId);
|
|
47
|
+
const raw = sessionStorage.getItem(key);
|
|
48
|
+
if (!raw) return null;
|
|
49
|
+
const stored = JSON.parse(raw) as Partial<StoredPage<TStored>>;
|
|
50
|
+
if (
|
|
51
|
+
typeof stored.expiresAt !== 'number' ||
|
|
52
|
+
stored.expiresAt <= Date.now() ||
|
|
53
|
+
!Array.isArray(stored.items)
|
|
54
|
+
) {
|
|
55
|
+
sessionStorage.removeItem(key);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const items: TItem[] = [];
|
|
60
|
+
for (const value of stored.items) {
|
|
61
|
+
const item = serializer.parse(value);
|
|
62
|
+
if (!item) {
|
|
63
|
+
sessionStorage.removeItem(key);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
items.push(item);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
items: sort(items),
|
|
71
|
+
nextCursor:
|
|
72
|
+
typeof stored.nextCursor === 'string' ? stored.nextCursor : undefined,
|
|
73
|
+
};
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const write = (actorId: string, page: AdminPage<TItem>): void => {
|
|
80
|
+
try {
|
|
81
|
+
const payload: StoredPage<TStored> = {
|
|
82
|
+
expiresAt: Date.now() + ttlMs,
|
|
83
|
+
items: sort(page.items).map(serializer.serialize),
|
|
84
|
+
nextCursor: page.nextCursor,
|
|
85
|
+
};
|
|
86
|
+
sessionStorage.setItem(
|
|
87
|
+
collectionCacheKey(actorId),
|
|
88
|
+
JSON.stringify(payload),
|
|
89
|
+
);
|
|
90
|
+
} catch {
|
|
91
|
+
// Storage is an optimization and must never block administration.
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
editorKey,
|
|
97
|
+
isLocalCursor(cursor?: string): boolean {
|
|
98
|
+
return cursor?.startsWith(localCursorPrefix) === true;
|
|
99
|
+
},
|
|
100
|
+
read(
|
|
101
|
+
actorId: string,
|
|
102
|
+
input?: { cursor?: string; limit?: number },
|
|
103
|
+
): AdminPage<TItem> | null {
|
|
104
|
+
const cached = readSnapshot(actorId);
|
|
105
|
+
if (!cached) return null;
|
|
106
|
+
if (input?.cursor && !input.cursor.startsWith(localCursorPrefix)) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const offset = input?.cursor
|
|
110
|
+
? Number.parseInt(input.cursor.slice(localCursorPrefix.length), 10)
|
|
111
|
+
: 0;
|
|
112
|
+
if (!Number.isSafeInteger(offset) || offset < 0) return null;
|
|
113
|
+
const limit = Math.max(1, Math.floor(input?.limit ?? 25));
|
|
114
|
+
const end = Math.min(cached.items.length, offset + limit);
|
|
115
|
+
return {
|
|
116
|
+
items: cached.items.slice(offset, end),
|
|
117
|
+
nextCursor:
|
|
118
|
+
end < cached.items.length
|
|
119
|
+
? `${localCursorPrefix}${end}`
|
|
120
|
+
: cached.nextCursor,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
find(actorId: string, id: string): TItem | null {
|
|
124
|
+
return (
|
|
125
|
+
readSnapshot(actorId)?.items.find((item) => getKey(item) === id) ??
|
|
126
|
+
null
|
|
127
|
+
);
|
|
128
|
+
},
|
|
129
|
+
readEditor(actorId: string, id: string): TItem | null {
|
|
130
|
+
try {
|
|
131
|
+
const key = editorCacheKey(id);
|
|
132
|
+
const raw = sessionStorage.getItem(key);
|
|
133
|
+
if (!raw) return null;
|
|
134
|
+
const stored = JSON.parse(raw) as Partial<StoredEditor<TStored>>;
|
|
135
|
+
if (
|
|
136
|
+
stored.actorId !== actorId ||
|
|
137
|
+
typeof stored.expiresAt !== 'number' ||
|
|
138
|
+
stored.expiresAt <= Date.now()
|
|
139
|
+
) {
|
|
140
|
+
sessionStorage.removeItem(key);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return serializer.parse(stored.item);
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
write,
|
|
149
|
+
append(actorId: string, page: AdminPage<TItem>): void {
|
|
150
|
+
const cached = readSnapshot(actorId);
|
|
151
|
+
if (!cached) return;
|
|
152
|
+
const items = new Map(cached.items.map((item) => [getKey(item), item]));
|
|
153
|
+
page.items.forEach((item) => items.set(getKey(item), item));
|
|
154
|
+
write(actorId, {
|
|
155
|
+
items: [...items.values()],
|
|
156
|
+
nextCursor: page.nextCursor,
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
upsert(actorId: string, item: TItem): void {
|
|
160
|
+
const cached = readSnapshot(actorId);
|
|
161
|
+
if (!cached) return;
|
|
162
|
+
const items = new Map(
|
|
163
|
+
cached.items.map((candidate) => [getKey(candidate), candidate]),
|
|
164
|
+
);
|
|
165
|
+
items.set(getKey(item), item);
|
|
166
|
+
write(actorId, {
|
|
167
|
+
items: [...items.values()],
|
|
168
|
+
nextCursor: cached.nextCursor,
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
remove(actorId: string, id: string): void {
|
|
172
|
+
const cached = readSnapshot(actorId);
|
|
173
|
+
if (cached) {
|
|
174
|
+
write(actorId, {
|
|
175
|
+
items: cached.items.filter((item) => getKey(item) !== id),
|
|
176
|
+
nextCursor: cached.nextCursor,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
sessionStorage.removeItem(editorCacheKey(id));
|
|
181
|
+
} catch {
|
|
182
|
+
// Cleanup is best effort.
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
writeEditor(actorId: string, item: TItem): boolean {
|
|
186
|
+
try {
|
|
187
|
+
const payload: StoredEditor<TStored> = {
|
|
188
|
+
actorId,
|
|
189
|
+
expiresAt: Date.now() + ttlMs,
|
|
190
|
+
item: serializer.serialize(item),
|
|
191
|
+
};
|
|
192
|
+
sessionStorage.setItem(
|
|
193
|
+
editorCacheKey(getKey(item)),
|
|
194
|
+
JSON.stringify(payload),
|
|
195
|
+
);
|
|
196
|
+
return true;
|
|
197
|
+
} catch {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
clear(actorId: string): void {
|
|
202
|
+
try {
|
|
203
|
+
sessionStorage.removeItem(collectionCacheKey(actorId));
|
|
204
|
+
for (let index = sessionStorage.length - 1; index >= 0; index -= 1) {
|
|
205
|
+
const key = sessionStorage.key(index);
|
|
206
|
+
if (!key?.startsWith(`${editorKey}:`)) continue;
|
|
207
|
+
const raw = sessionStorage.getItem(key);
|
|
208
|
+
if (!raw) continue;
|
|
209
|
+
const stored = JSON.parse(raw) as Partial<StoredEditor<TStored>>;
|
|
210
|
+
if (stored.actorId === actorId) sessionStorage.removeItem(key);
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// Cleanup is best effort.
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
} as const;
|
|
217
|
+
}
|
package/src/admin/index.ts
CHANGED
|
@@ -1,21 +1,47 @@
|
|
|
1
|
-
import { getApps, initializeApp } from 'firebase/app';
|
|
1
|
+
import { getApps, initializeApp, type FirebaseApp } from 'firebase/app';
|
|
2
2
|
import {
|
|
3
|
+
browserLocalPersistence,
|
|
4
|
+
browserPopupRedirectResolver,
|
|
3
5
|
GoogleAuthProvider,
|
|
4
6
|
getAuth,
|
|
7
|
+
initializeAuth,
|
|
5
8
|
onAuthStateChanged,
|
|
6
9
|
signInWithPopup,
|
|
7
10
|
signOut,
|
|
11
|
+
type Auth,
|
|
8
12
|
type User,
|
|
9
13
|
} from 'firebase/auth';
|
|
10
14
|
import type { AuthActor } from '../contracts';
|
|
11
15
|
import type { FirebaseClientConfig } from './contracts';
|
|
12
16
|
|
|
13
17
|
export type FirebaseGoogleAuthClient = {
|
|
14
|
-
observe(
|
|
18
|
+
observe(
|
|
19
|
+
listener: (actor: AuthActor | null) => void,
|
|
20
|
+
onError?: (reason: unknown) => void,
|
|
21
|
+
): () => void;
|
|
15
22
|
signIn(): Promise<AuthActor>;
|
|
16
23
|
signOut(): Promise<void>;
|
|
17
24
|
};
|
|
18
25
|
|
|
26
|
+
function initializeBrowserAuth(app: FirebaseApp): Auth {
|
|
27
|
+
try {
|
|
28
|
+
return initializeAuth(app, {
|
|
29
|
+
persistence: browserLocalPersistence,
|
|
30
|
+
popupRedirectResolver: browserPopupRedirectResolver,
|
|
31
|
+
});
|
|
32
|
+
} catch (reason) {
|
|
33
|
+
if (
|
|
34
|
+
typeof reason === 'object' &&
|
|
35
|
+
reason !== null &&
|
|
36
|
+
'code' in reason &&
|
|
37
|
+
reason.code === 'auth/already-initialized'
|
|
38
|
+
) {
|
|
39
|
+
return getAuth(app);
|
|
40
|
+
}
|
|
41
|
+
throw reason;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
19
45
|
function toAuthActor(user: User): AuthActor {
|
|
20
46
|
return {
|
|
21
47
|
id: user.uid,
|
|
@@ -32,15 +58,19 @@ export function createFirebaseGoogleAuthClient(
|
|
|
32
58
|
const app =
|
|
33
59
|
getApps().find((candidate) => candidate.name === config.projectId) ??
|
|
34
60
|
initializeApp(config, config.projectId);
|
|
35
|
-
const auth =
|
|
61
|
+
const auth = initializeBrowserAuth(app);
|
|
36
62
|
const provider = new GoogleAuthProvider();
|
|
37
63
|
provider.setCustomParameters({ prompt: 'select_account' });
|
|
38
64
|
|
|
39
65
|
return {
|
|
40
|
-
observe(listener) {
|
|
41
|
-
return onAuthStateChanged(
|
|
42
|
-
|
|
43
|
-
|
|
66
|
+
observe(listener, onError) {
|
|
67
|
+
return onAuthStateChanged(
|
|
68
|
+
auth,
|
|
69
|
+
(user) => {
|
|
70
|
+
listener(user ? toAuthActor(user) : null);
|
|
71
|
+
},
|
|
72
|
+
onError,
|
|
73
|
+
);
|
|
44
74
|
},
|
|
45
75
|
async signIn() {
|
|
46
76
|
const result = await signInWithPopup(auth, provider);
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
export type FormSubmissionMode = 'create' | 'edit';
|
|
2
|
+
|
|
3
|
+
export type FormSubmissionState = {
|
|
4
|
+
dirty: boolean;
|
|
5
|
+
pending: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type FormSubmissionResult<TValue> = {
|
|
9
|
+
status: 'saved' | 'unchanged';
|
|
10
|
+
value: TValue;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type FormSubmissionControllerConfig<TValue> = {
|
|
14
|
+
form: HTMLFormElement;
|
|
15
|
+
mode: FormSubmissionMode;
|
|
16
|
+
readValue(): TValue;
|
|
17
|
+
save(value: TValue): Promise<void>;
|
|
18
|
+
onSuccess(result: FormSubmissionResult<TValue>): void | Promise<void>;
|
|
19
|
+
onError?(reason: unknown): void;
|
|
20
|
+
onStateChange?(state: FormSubmissionState): void;
|
|
21
|
+
changeEvents?: readonly string[];
|
|
22
|
+
equals?(left: TValue, right: TValue): boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type FormSubmissionController = {
|
|
26
|
+
destroy(): void;
|
|
27
|
+
refresh(): void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const defaultChangeEvents = ['input', 'change'] as const;
|
|
31
|
+
|
|
32
|
+
export function createFormSubmissionController<TValue>({
|
|
33
|
+
form,
|
|
34
|
+
mode,
|
|
35
|
+
readValue,
|
|
36
|
+
save,
|
|
37
|
+
onSuccess,
|
|
38
|
+
onError,
|
|
39
|
+
onStateChange,
|
|
40
|
+
changeEvents = defaultChangeEvents,
|
|
41
|
+
equals = areStructurallyEqual,
|
|
42
|
+
}: FormSubmissionControllerConfig<TValue>): FormSubmissionController {
|
|
43
|
+
let baseline!: TValue;
|
|
44
|
+
if (mode === 'edit') baseline = readValue();
|
|
45
|
+
let dirty = mode === 'create';
|
|
46
|
+
let pending = false;
|
|
47
|
+
let destroyed = false;
|
|
48
|
+
|
|
49
|
+
const publishState = (): void => {
|
|
50
|
+
onStateChange?.({ dirty, pending });
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const refresh = (): void => {
|
|
54
|
+
if (destroyed || pending) return;
|
|
55
|
+
|
|
56
|
+
let nextDirty = true;
|
|
57
|
+
if (mode === 'edit') {
|
|
58
|
+
try {
|
|
59
|
+
nextDirty = !equals(baseline, readValue());
|
|
60
|
+
} catch {
|
|
61
|
+
// An incomplete value is still a change; native validation owns errors.
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (nextDirty === dirty) return;
|
|
66
|
+
dirty = nextDirty;
|
|
67
|
+
publishState();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const handleChange = (): void => refresh();
|
|
71
|
+
|
|
72
|
+
const handleSubmit = async (event: SubmitEvent): Promise<void> => {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
if (pending || !form.reportValidity()) return;
|
|
75
|
+
|
|
76
|
+
let value: TValue;
|
|
77
|
+
try {
|
|
78
|
+
value = readValue();
|
|
79
|
+
} catch (reason) {
|
|
80
|
+
onError?.(reason);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (mode === 'edit' && equals(baseline, value)) {
|
|
85
|
+
await onSuccess({ status: 'unchanged', value });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
pending = true;
|
|
90
|
+
publishState();
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
await save(value);
|
|
94
|
+
baseline = value;
|
|
95
|
+
dirty = false;
|
|
96
|
+
pending = false;
|
|
97
|
+
publishState();
|
|
98
|
+
await onSuccess({ status: 'saved', value });
|
|
99
|
+
} catch (reason) {
|
|
100
|
+
pending = false;
|
|
101
|
+
refresh();
|
|
102
|
+
publishState();
|
|
103
|
+
onError?.(reason);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for (const eventName of new Set(changeEvents)) {
|
|
108
|
+
form.addEventListener(eventName, handleChange);
|
|
109
|
+
}
|
|
110
|
+
form.addEventListener('submit', handleSubmit);
|
|
111
|
+
publishState();
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
destroy(): void {
|
|
115
|
+
if (destroyed) return;
|
|
116
|
+
destroyed = true;
|
|
117
|
+
for (const eventName of new Set(changeEvents)) {
|
|
118
|
+
form.removeEventListener(eventName, handleChange);
|
|
119
|
+
}
|
|
120
|
+
form.removeEventListener('submit', handleSubmit);
|
|
121
|
+
},
|
|
122
|
+
refresh,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function areStructurallyEqual<TValue>(left: TValue, right: TValue): boolean {
|
|
127
|
+
return compareValues(left, right, new WeakMap<object, object>());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function compareValues(
|
|
131
|
+
left: unknown,
|
|
132
|
+
right: unknown,
|
|
133
|
+
visited: WeakMap<object, object>,
|
|
134
|
+
): boolean {
|
|
135
|
+
if (Object.is(left, right)) return true;
|
|
136
|
+
if (left instanceof Date || right instanceof Date) {
|
|
137
|
+
return (
|
|
138
|
+
left instanceof Date &&
|
|
139
|
+
right instanceof Date &&
|
|
140
|
+
left.getTime() === right.getTime()
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!left || !right || typeof left !== 'object' || typeof right !== 'object') {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const knownRight = visited.get(left);
|
|
148
|
+
if (knownRight) return knownRight === right;
|
|
149
|
+
visited.set(left, right);
|
|
150
|
+
|
|
151
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
152
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
153
|
+
return (
|
|
154
|
+
left.length === right.length &&
|
|
155
|
+
left.every((value, index) => compareValues(value, right[index], visited))
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const leftPrototype = Object.getPrototypeOf(left);
|
|
160
|
+
const rightPrototype = Object.getPrototypeOf(right);
|
|
161
|
+
if (leftPrototype !== rightPrototype) return false;
|
|
162
|
+
|
|
163
|
+
const leftKeys = Object.keys(left).sort();
|
|
164
|
+
const rightKeys = Object.keys(right).sort();
|
|
165
|
+
if (
|
|
166
|
+
leftKeys.length !== rightKeys.length ||
|
|
167
|
+
leftKeys.some((key, index) => key !== rightKeys[index])
|
|
168
|
+
) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const leftRecord = left as Record<string, unknown>;
|
|
173
|
+
const rightRecord = right as Record<string, unknown>;
|
|
174
|
+
return leftKeys.every((key) =>
|
|
175
|
+
compareValues(leftRecord[key], rightRecord[key], visited),
|
|
176
|
+
);
|
|
177
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -11,9 +11,20 @@
|
|
|
11
11
|
block-size: var(--nox-section-compact-block-size);
|
|
12
12
|
min-block-size: 0;
|
|
13
13
|
box-shadow: var(--nox-shadow-surface);
|
|
14
|
+
opacity: 0.8;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.scroll-to-top.nox-button:is(:focus-visible, :active) {
|
|
18
|
+
opacity: 1;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
html:has(dialog[open]) .scroll-to-top,
|
|
17
22
|
html:has(:fullscreen) .scroll-to-top {
|
|
18
23
|
display: none;
|
|
19
24
|
}
|
|
25
|
+
|
|
26
|
+
@media (hover: hover) and (pointer: fine) {
|
|
27
|
+
.scroll-to-top.nox-button:hover {
|
|
28
|
+
opacity: 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -56,8 +56,7 @@
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
.card-body a {
|
|
59
|
-
color: var(--nox-link);
|
|
60
|
-
text-underline-offset: 0.2em;
|
|
59
|
+
color: var(--nox-content-link-color);
|
|
61
60
|
}
|
|
62
61
|
|
|
63
62
|
.card-body strong {
|
|
@@ -65,6 +64,11 @@
|
|
|
65
64
|
font-weight: var(--nox-font-weight-strong);
|
|
66
65
|
}
|
|
67
66
|
|
|
67
|
+
.card-body a strong {
|
|
68
|
+
color: inherit;
|
|
69
|
+
font-weight: var(--nox-content-link-emphasis-font-weight);
|
|
70
|
+
}
|
|
71
|
+
|
|
68
72
|
@container (width < 32rem) {
|
|
69
73
|
.card-body__content {
|
|
70
74
|
--nox-content-padding: var(--nox-space-4);
|
|
@@ -18,6 +18,7 @@ const {
|
|
|
18
18
|
}: Props = Astro.props;
|
|
19
19
|
const resolvedTone = tone ?? dataTone;
|
|
20
20
|
const hasAction = Astro.slots.has('action');
|
|
21
|
+
const hasLeadingAction = Astro.slots.has('leading-action');
|
|
21
22
|
const hasMedia = Boolean(media);
|
|
22
23
|
---
|
|
23
24
|
|
|
@@ -28,10 +29,18 @@ const hasMedia = Boolean(media);
|
|
|
28
29
|
data-surface
|
|
29
30
|
data-surface-sizing="content"
|
|
30
31
|
data-has-action={hasAction ? true : undefined}
|
|
32
|
+
data-has-leading-action={hasLeadingAction ? true : undefined}
|
|
31
33
|
data-has-media={hasMedia ? true : undefined}
|
|
32
34
|
data-tone={resolvedTone}
|
|
33
35
|
>
|
|
34
36
|
<div class="card-footer__layout">
|
|
37
|
+
{
|
|
38
|
+
hasLeadingAction ? (
|
|
39
|
+
<div class="card-footer__leading-action">
|
|
40
|
+
<slot name="leading-action" />
|
|
41
|
+
</div>
|
|
42
|
+
) : null
|
|
43
|
+
}
|
|
35
44
|
{
|
|
36
45
|
media ? (
|
|
37
46
|
<div class="card-footer__media" aria-hidden="true">
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
grid-template-columns: minmax(0, 1fr) var(--card-footer-size);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
.card-footer[data-has-leading-action] .card-footer__layout {
|
|
35
|
+
grid-template-columns: var(--card-footer-size) minmax(0, 1fr);
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
.card-footer[data-has-media][data-has-action] .card-footer__layout {
|
|
35
39
|
grid-template-columns:
|
|
36
40
|
var(--card-footer-size)
|
|
@@ -38,6 +42,31 @@
|
|
|
38
42
|
var(--card-footer-size);
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
.card-footer[data-has-leading-action][data-has-action]
|
|
46
|
+
.card-footer__layout {
|
|
47
|
+
grid-template-columns:
|
|
48
|
+
var(--card-footer-size)
|
|
49
|
+
minmax(0, 1fr)
|
|
50
|
+
var(--card-footer-size);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.card-footer[data-has-leading-action][data-has-media]
|
|
54
|
+
.card-footer__layout {
|
|
55
|
+
grid-template-columns:
|
|
56
|
+
var(--card-footer-size)
|
|
57
|
+
var(--card-footer-size)
|
|
58
|
+
minmax(0, 1fr);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.card-footer[data-has-leading-action][data-has-media][data-has-action]
|
|
62
|
+
.card-footer__layout {
|
|
63
|
+
grid-template-columns:
|
|
64
|
+
var(--card-footer-size)
|
|
65
|
+
var(--card-footer-size)
|
|
66
|
+
minmax(0, 1fr)
|
|
67
|
+
var(--card-footer-size);
|
|
68
|
+
}
|
|
69
|
+
|
|
41
70
|
.card-footer__media {
|
|
42
71
|
display: grid;
|
|
43
72
|
min-width: 0;
|
|
@@ -64,29 +93,42 @@
|
|
|
64
93
|
align-items: center;
|
|
65
94
|
padding: var(--nox-space-3) var(--nox-space-5);
|
|
66
95
|
font-size: var(--nox-font-size-sm);
|
|
67
|
-
font-weight: var(--nox-font-weight-
|
|
96
|
+
font-weight: var(--nox-font-weight-regular);
|
|
68
97
|
line-height: var(--nox-line-height-tight);
|
|
69
98
|
overflow-wrap: anywhere;
|
|
70
99
|
}
|
|
71
100
|
|
|
72
101
|
.card-footer__content a {
|
|
73
|
-
color: var(--nox-link);
|
|
74
|
-
|
|
102
|
+
color: var(--nox-content-link-color);
|
|
103
|
+
font-weight: var(--nox-content-link-emphasis-font-weight);
|
|
75
104
|
}
|
|
76
105
|
|
|
77
106
|
.card-footer__content strong {
|
|
78
107
|
font-weight: var(--nox-font-weight-strong);
|
|
79
108
|
}
|
|
80
109
|
|
|
110
|
+
.card-footer__content a strong {
|
|
111
|
+
font-weight: var(--nox-content-link-emphasis-font-weight);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.card-footer__leading-action,
|
|
81
115
|
.card-footer__action {
|
|
82
116
|
display: grid;
|
|
83
117
|
min-width: 0;
|
|
84
118
|
min-height: var(--card-footer-size);
|
|
85
119
|
place-items: stretch;
|
|
86
120
|
overflow: hidden;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.card-footer__leading-action {
|
|
124
|
+
border-inline-end: var(--nox-border-width) solid var(--surface-border-color);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.card-footer__action {
|
|
87
128
|
border-inline-start: var(--nox-border-width) solid var(--surface-border-color);
|
|
88
129
|
}
|
|
89
130
|
|
|
131
|
+
.card-footer__leading-action > .nox-button[data-button-borderless],
|
|
90
132
|
.card-footer__action > .nox-button[data-button-borderless] {
|
|
91
133
|
--nox-button-background: transparent;
|
|
92
134
|
}
|
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
compactContent,
|
|
21
21
|
compactText,
|
|
22
22
|
compactVisual = 'show',
|
|
23
|
+
density = 'default',
|
|
23
24
|
disabled = false,
|
|
24
25
|
direction,
|
|
25
26
|
full = false,
|
|
@@ -32,11 +33,15 @@ const {
|
|
|
32
33
|
mediaSize = 'default',
|
|
33
34
|
meta,
|
|
34
35
|
mode = 'link',
|
|
36
|
+
textLayout = 'inline',
|
|
37
|
+
textTreatment = 'default',
|
|
35
38
|
tone,
|
|
36
39
|
title,
|
|
37
40
|
...attrs
|
|
38
41
|
}: Props = Astro.props;
|
|
39
|
-
const accessibleLabel =
|
|
42
|
+
const accessibleLabel = [label, title, meta]
|
|
43
|
+
.filter((value) => value !== undefined && value !== '')
|
|
44
|
+
.join(' ');
|
|
40
45
|
const isDisabled = disabled || (mode === 'link' && !href);
|
|
41
46
|
const isInteractive = mode === 'link' && !isDisabled;
|
|
42
47
|
const Root = isInteractive ? 'a' : 'span';
|
|
@@ -65,6 +70,7 @@ const resolvedTone = tone ?? dataTone;
|
|
|
65
70
|
data-collapse={collapse}
|
|
66
71
|
data-compact-content={resolvedCompactContent}
|
|
67
72
|
data-compact-visual={compactVisual}
|
|
73
|
+
data-density={density}
|
|
68
74
|
data-disabled={isDisabled ? 'true' : 'false'}
|
|
69
75
|
data-direction={direction}
|
|
70
76
|
data-full={full ? 'true' : 'false'}
|
|
@@ -74,6 +80,7 @@ const resolvedTone = tone ?? dataTone;
|
|
|
74
80
|
data-media-position={hasVisual ? mediaPosition : undefined}
|
|
75
81
|
data-media-size={hasVisual ? mediaSize : undefined}
|
|
76
82
|
data-mode={mode}
|
|
83
|
+
data-text-treatment={textTreatment}
|
|
77
84
|
data-tone={resolvedTone}
|
|
78
85
|
>
|
|
79
86
|
<span class="card-link__layout">
|
|
@@ -109,12 +116,14 @@ const resolvedTone = tone ?? dataTone;
|
|
|
109
116
|
<span class="card-link__compact-text" aria-hidden="true">
|
|
110
117
|
{compactText}
|
|
111
118
|
</span>
|
|
112
|
-
<span class="card-link__label">{label}</span>
|
|
119
|
+
{label ? <span class="card-link__label">{label}</span> : null}
|
|
113
120
|
<TextPair
|
|
114
121
|
class="card-link__text"
|
|
115
|
-
layout=
|
|
122
|
+
layout={textLayout}
|
|
116
123
|
primary={title}
|
|
124
|
+
primaryWeight={isInteractive ? 'medium' : 'strong'}
|
|
117
125
|
secondary={meta}
|
|
126
|
+
secondaryWeight={textTreatment === 'heading' ? 'regular' : 'medium'}
|
|
118
127
|
/>
|
|
119
128
|
</span>
|
|
120
129
|
</span>
|