@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,400 @@
1
+ export type PreferenceScalar = boolean | number | string;
2
+
3
+ export type PreferenceSchema =
4
+ | { type: 'boolean' }
5
+ | {
6
+ type: 'integer';
7
+ min?: number;
8
+ max?: number;
9
+ values?: readonly number[];
10
+ }
11
+ | { type: 'number'; min?: number; max?: number }
12
+ | { type: 'string'; values?: readonly string[] }
13
+ | {
14
+ type: 'object';
15
+ properties: Readonly<Record<string, PreferenceSchema>>;
16
+ }
17
+ | { type: 'array'; items: PreferenceSchema };
18
+
19
+ export type PreferenceMigration = {
20
+ fromVersion: number;
21
+ fromKey?: string;
22
+ rename?: Readonly<Record<string, string>>;
23
+ defaults?: Readonly<Record<string, unknown>>;
24
+ };
25
+
26
+ export type PreferenceDefinition<T> = {
27
+ id: string;
28
+ key: string;
29
+ version: number;
30
+ defaultValue: T;
31
+ schema: PreferenceSchema;
32
+ migrations?: readonly PreferenceMigration[];
33
+ };
34
+
35
+ export type AnyPreferenceDefinition = PreferenceDefinition<unknown>;
36
+
37
+ export type PreferenceBinding = {
38
+ id: string;
39
+ path?: string;
40
+ };
41
+
42
+ export type PreferenceCondition = PreferenceBinding & {
43
+ equals: PreferenceScalar;
44
+ };
45
+
46
+ export type PreferenceNumberBounds = {
47
+ min: number;
48
+ max: number;
49
+ when?: PreferenceCondition;
50
+ };
51
+
52
+ export type PreferenceRegistry = {
53
+ get<T>(definition: PreferenceDefinition<T>): T;
54
+ set<T>(definition: PreferenceDefinition<T>, value: T): void;
55
+ reset<T>(definition: PreferenceDefinition<T>): void;
56
+ subscribe<T>(
57
+ definition: PreferenceDefinition<T>,
58
+ listener: (value: T) => void,
59
+ ): () => void;
60
+ destroy(): void;
61
+ };
62
+
63
+ export type PreferenceRegistryConfig = {
64
+ definitions: readonly AnyPreferenceDefinition[];
65
+ };
66
+
67
+ type PreferenceSnapshot = {
68
+ values: Record<string, unknown>;
69
+ rawValues: Record<string, string>;
70
+ };
71
+
72
+ type PreferenceChangeDetail = {
73
+ id: string;
74
+ source: string;
75
+ value: unknown;
76
+ };
77
+
78
+ const snapshotGlobalKey = '__astronoxPreferenceSnapshot';
79
+ const preferenceChangeEvent = 'astronox:preference-change';
80
+ let registrySequence = 0;
81
+
82
+ export function definePreference<T>(
83
+ definition: PreferenceDefinition<T>,
84
+ ): PreferenceDefinition<T> {
85
+ assertDefinition(definition);
86
+ if (!matchesPreferenceSchema(definition.defaultValue, definition.schema)) {
87
+ throw new TypeError(`Invalid default value for preference ${definition.id}`);
88
+ }
89
+ return definition;
90
+ }
91
+
92
+ export function createPreferenceRegistry({
93
+ definitions,
94
+ }: PreferenceRegistryConfig): PreferenceRegistry {
95
+ const source = `registry:${++registrySequence}`;
96
+ const definitionsById = new Map<string, AnyPreferenceDefinition>();
97
+ const definitionsByStorageKey = new Map<string, AnyPreferenceDefinition>();
98
+ for (const definition of definitions) {
99
+ assertDefinition(definition);
100
+ if (definitionsById.has(definition.id)) {
101
+ throw new Error(`Duplicate preference definition: ${definition.id}`);
102
+ }
103
+ const storageKey = getPreferenceStorageKey(definition);
104
+ if (definitionsByStorageKey.has(storageKey)) {
105
+ throw new Error(`Duplicate preference storage key: ${storageKey}`);
106
+ }
107
+ definitionsById.set(definition.id, definition);
108
+ definitionsByStorageKey.set(storageKey, definition);
109
+ }
110
+
111
+ const snapshot = resolveSnapshot(definitions);
112
+ const listeners = new Map<string, Set<(value: unknown) => void>>();
113
+
114
+ const requireDefinition = <T>(
115
+ definition: PreferenceDefinition<T>,
116
+ ): PreferenceDefinition<T> => {
117
+ if (!definitionsById.has(definition.id)) {
118
+ throw new Error(
119
+ `Preference ${definition.id} does not belong to this registry`,
120
+ );
121
+ }
122
+ return definition;
123
+ };
124
+
125
+ const notify = (id: string, value: unknown): void => {
126
+ for (const listener of listeners.get(id) ?? []) listener(value);
127
+ };
128
+
129
+ const apply = (
130
+ definition: AnyPreferenceDefinition,
131
+ value: unknown,
132
+ options: {
133
+ persist: boolean;
134
+ announce: boolean;
135
+ notifyIfEqual?: boolean;
136
+ },
137
+ ): void => {
138
+ if (!matchesPreferenceSchema(value, definition.schema)) return;
139
+ const raw = JSON.stringify(value);
140
+ if (snapshot.rawValues[definition.id] === raw) {
141
+ if (options.notifyIfEqual) {
142
+ notify(definition.id, structuredCloneValue(value));
143
+ }
144
+ return;
145
+ }
146
+
147
+ const cloned = structuredCloneValue(value);
148
+ snapshot.values[definition.id] = cloned;
149
+ snapshot.rawValues[definition.id] = raw;
150
+ syncGlobalSnapshot(definition.id, cloned, raw);
151
+ if (options.persist) {
152
+ writeStorage(getPreferenceStorageKey(definition), raw);
153
+ }
154
+ notify(definition.id, cloned);
155
+ if (options.announce) {
156
+ document.dispatchEvent(
157
+ new CustomEvent<PreferenceChangeDetail>(preferenceChangeEvent, {
158
+ detail: { id: definition.id, source, value: cloned },
159
+ }),
160
+ );
161
+ }
162
+ };
163
+
164
+ const handlePreferenceChange = (event: Event): void => {
165
+ const detail = (event as CustomEvent<PreferenceChangeDetail>).detail;
166
+ if (detail?.source === source) return;
167
+ const definition = detail ? definitionsById.get(detail.id) : undefined;
168
+ if (definition) {
169
+ apply(definition, detail.value, {
170
+ persist: false,
171
+ announce: false,
172
+ notifyIfEqual: true,
173
+ });
174
+ }
175
+ };
176
+
177
+ const handleStorage = (event: StorageEvent): void => {
178
+ const definition = event.key
179
+ ? definitionsByStorageKey.get(event.key)
180
+ : undefined;
181
+ if (!definition) return;
182
+ const value = parseStoredValue(event.newValue);
183
+ if (!matchesPreferenceSchema(value, definition.schema)) return;
184
+ apply(definition, value, { persist: false, announce: true });
185
+ };
186
+
187
+ document.addEventListener(preferenceChangeEvent, handlePreferenceChange);
188
+ window.addEventListener('storage', handleStorage);
189
+
190
+ return {
191
+ get<T>(definition: PreferenceDefinition<T>): T {
192
+ requireDefinition(definition);
193
+ return structuredCloneValue(snapshot.values[definition.id] as T);
194
+ },
195
+ set<T>(definition: PreferenceDefinition<T>, value: T): void {
196
+ requireDefinition(definition);
197
+ if (!matchesPreferenceSchema(value, definition.schema)) {
198
+ throw new TypeError(`Invalid value for preference ${definition.id}`);
199
+ }
200
+ apply(definition, value, { persist: true, announce: true });
201
+ },
202
+ reset<T>(definition: PreferenceDefinition<T>): void {
203
+ requireDefinition(definition);
204
+ apply(definition, definition.defaultValue, {
205
+ persist: true,
206
+ announce: true,
207
+ });
208
+ },
209
+ subscribe<T>(
210
+ definition: PreferenceDefinition<T>,
211
+ listener: (value: T) => void,
212
+ ): () => void {
213
+ requireDefinition(definition);
214
+ const definitionListeners = listeners.get(definition.id) ?? new Set();
215
+ const wrapped = (value: unknown): void => listener(value as T);
216
+ definitionListeners.add(wrapped);
217
+ listeners.set(definition.id, definitionListeners);
218
+ return () => {
219
+ definitionListeners.delete(wrapped);
220
+ if (definitionListeners.size === 0) listeners.delete(definition.id);
221
+ };
222
+ },
223
+ destroy(): void {
224
+ document.removeEventListener(preferenceChangeEvent, handlePreferenceChange);
225
+ window.removeEventListener('storage', handleStorage);
226
+ listeners.clear();
227
+ },
228
+ };
229
+ }
230
+
231
+ function getPreferenceStorageKey(
232
+ definition: Pick<AnyPreferenceDefinition, 'key' | 'version'>,
233
+ ): string {
234
+ return `${definition.key}:v${definition.version}`;
235
+ }
236
+
237
+ function matchesPreferenceSchema(
238
+ value: unknown,
239
+ schema: PreferenceSchema,
240
+ ): boolean {
241
+ if (schema.type === 'boolean') return typeof value === 'boolean';
242
+ if (schema.type === 'string') {
243
+ return (
244
+ typeof value === 'string' &&
245
+ (!schema.values || schema.values.includes(value))
246
+ );
247
+ }
248
+ if (schema.type === 'number' || schema.type === 'integer') {
249
+ if (
250
+ typeof value !== 'number' ||
251
+ !Number.isFinite(value) ||
252
+ (schema.type === 'integer' && !Number.isInteger(value)) ||
253
+ (schema.min !== undefined && value < schema.min) ||
254
+ (schema.max !== undefined && value > schema.max)
255
+ ) {
256
+ return false;
257
+ }
258
+ return (
259
+ schema.type !== 'integer' ||
260
+ !schema.values ||
261
+ schema.values.includes(value)
262
+ );
263
+ }
264
+ if (schema.type === 'array') {
265
+ return (
266
+ Array.isArray(value) &&
267
+ value.every((item) => matchesPreferenceSchema(item, schema.items))
268
+ );
269
+ }
270
+ if (!isRecord(value)) return false;
271
+
272
+ return Object.entries(schema.properties).every(([key, propertySchema]) =>
273
+ matchesPreferenceSchema(value[key], propertySchema),
274
+ );
275
+ }
276
+
277
+ function resolveSnapshot(
278
+ definitions: readonly AnyPreferenceDefinition[],
279
+ ): PreferenceSnapshot {
280
+ const snapshot = getGlobalSnapshot() ?? { values: {}, rawValues: {} };
281
+ setGlobalSnapshot(snapshot);
282
+
283
+ for (const definition of definitions) {
284
+ const bootstrapped = snapshot.values[definition.id];
285
+ if (matchesPreferenceSchema(bootstrapped, definition.schema)) {
286
+ snapshot.rawValues[definition.id] = JSON.stringify(bootstrapped);
287
+ continue;
288
+ }
289
+
290
+ const value = readPreference(definition);
291
+ snapshot.values[definition.id] = value;
292
+ snapshot.rawValues[definition.id] = JSON.stringify(value);
293
+ }
294
+ return snapshot;
295
+ }
296
+
297
+ function readPreference<T>(definition: PreferenceDefinition<T>): T {
298
+ const current = parseStoredValue(
299
+ readStorage(getPreferenceStorageKey(definition)),
300
+ );
301
+ if (matchesPreferenceSchema(current, definition.schema)) return current as T;
302
+
303
+ for (const migration of [...(definition.migrations ?? [])].toSorted(
304
+ (left, right) => right.fromVersion - left.fromVersion,
305
+ )) {
306
+ const key = `${migration.fromKey ?? definition.key}:v${migration.fromVersion}`;
307
+ const migrated = applyMigration(
308
+ parseStoredValue(readStorage(key)),
309
+ migration,
310
+ );
311
+ if (matchesPreferenceSchema(migrated, definition.schema)) {
312
+ writeStorage(
313
+ getPreferenceStorageKey(definition),
314
+ JSON.stringify(migrated),
315
+ );
316
+ return migrated as T;
317
+ }
318
+ }
319
+
320
+ const fallback = structuredCloneValue(definition.defaultValue);
321
+ writeStorage(getPreferenceStorageKey(definition), JSON.stringify(fallback));
322
+ return fallback;
323
+ }
324
+
325
+ function applyMigration(
326
+ value: unknown,
327
+ migration: PreferenceMigration,
328
+ ): unknown {
329
+ if (!isRecord(value)) return value;
330
+ const migrated: Record<string, unknown> = { ...value };
331
+ for (const [from, to] of Object.entries(migration.rename ?? {})) {
332
+ if (from in migrated && !(to in migrated)) migrated[to] = migrated[from];
333
+ delete migrated[from];
334
+ }
335
+ for (const [key, fallback] of Object.entries(migration.defaults ?? {})) {
336
+ if (!(key in migrated)) migrated[key] = fallback;
337
+ }
338
+ return migrated;
339
+ }
340
+
341
+ function getGlobalSnapshot(): PreferenceSnapshot | undefined {
342
+ return (globalThis as Record<string, unknown>)[snapshotGlobalKey] as
343
+ | PreferenceSnapshot
344
+ | undefined;
345
+ }
346
+
347
+ function setGlobalSnapshot(snapshot: PreferenceSnapshot): void {
348
+ (globalThis as Record<string, unknown>)[snapshotGlobalKey] = snapshot;
349
+ }
350
+
351
+ function syncGlobalSnapshot(id: string, value: unknown, raw: string): void {
352
+ const globalSnapshot = getGlobalSnapshot();
353
+ if (!globalSnapshot) return;
354
+ globalSnapshot.values[id] = value;
355
+ globalSnapshot.rawValues[id] = raw;
356
+ }
357
+
358
+ function readStorage(key: string): string | null {
359
+ try {
360
+ return window.localStorage.getItem(key);
361
+ } catch {
362
+ return null;
363
+ }
364
+ }
365
+
366
+ function writeStorage(key: string, value: string): void {
367
+ try {
368
+ window.localStorage.setItem(key, value);
369
+ } catch {
370
+ // The in-memory snapshot remains authoritative for this document.
371
+ }
372
+ }
373
+
374
+ function parseStoredValue(raw: string | null): unknown {
375
+ if (raw === null) return undefined;
376
+ try {
377
+ return JSON.parse(raw);
378
+ } catch {
379
+ return undefined;
380
+ }
381
+ }
382
+
383
+ function assertDefinition(definition: AnyPreferenceDefinition): void {
384
+ if (!definition.id.trim() || !definition.key.trim()) {
385
+ throw new Error('Preference ids and keys must be non-empty');
386
+ }
387
+ if (!Number.isInteger(definition.version) || definition.version < 1) {
388
+ throw new RangeError('Preference versions must be positive integers');
389
+ }
390
+ }
391
+
392
+ function structuredCloneValue<T>(value: T): T {
393
+ return typeof structuredClone === 'function'
394
+ ? structuredClone(value)
395
+ : (JSON.parse(JSON.stringify(value)) as T);
396
+ }
397
+
398
+ function isRecord(value: unknown): value is Record<string, unknown> {
399
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
400
+ }
@@ -57,9 +57,21 @@ const resolvedTone = tone ?? dataTone;
57
57
  rel={resolvedRel}
58
58
  target={target}
59
59
  >
60
- {hasLeading ? <slot name="leading" /> : null}
60
+ {
61
+ hasLeading ? (
62
+ <span class="nox-button__affix">
63
+ <slot name="leading" />
64
+ </span>
65
+ ) : null
66
+ }
61
67
  <slot />
62
- {hasTrailing ? <slot name="trailing" /> : null}
68
+ {
69
+ hasTrailing ? (
70
+ <span class="nox-button__affix">
71
+ <slot name="trailing" />
72
+ </span>
73
+ ) : null
74
+ }
63
75
  </a>
64
76
  ) : href ? (
65
77
  <span
@@ -74,9 +86,21 @@ const resolvedTone = tone ?? dataTone;
74
86
  data-button-size={size}
75
87
  data-tone={resolvedTone}
76
88
  >
77
- {hasLeading ? <slot name="leading" /> : null}
89
+ {
90
+ hasLeading ? (
91
+ <span class="nox-button__affix">
92
+ <slot name="leading" />
93
+ </span>
94
+ ) : null
95
+ }
78
96
  <slot />
79
- {hasTrailing ? <slot name="trailing" /> : null}
97
+ {
98
+ hasTrailing ? (
99
+ <span class="nox-button__affix">
100
+ <slot name="trailing" />
101
+ </span>
102
+ ) : null
103
+ }
80
104
  </span>
81
105
  ) : (
82
106
  <button
@@ -92,9 +116,21 @@ const resolvedTone = tone ?? dataTone;
92
116
  disabled={disabled}
93
117
  type={type}
94
118
  >
95
- {hasLeading ? <slot name="leading" /> : null}
119
+ {
120
+ hasLeading ? (
121
+ <span class="nox-button__affix">
122
+ <slot name="leading" />
123
+ </span>
124
+ ) : null
125
+ }
96
126
  <slot />
97
- {hasTrailing ? <slot name="trailing" /> : null}
127
+ {
128
+ hasTrailing ? (
129
+ <span class="nox-button__affix">
130
+ <slot name="trailing" />
131
+ </span>
132
+ ) : null
133
+ }
98
134
  </button>
99
135
  )
100
136
  }
@@ -80,6 +80,12 @@
80
80
  block-size: 2rem;
81
81
  }
82
82
 
83
+ .nox-button[data-button-icon-only] > [data-tone] {
84
+ display: grid;
85
+ place-items: center;
86
+ color: var(--nox-tone-color);
87
+ }
88
+
83
89
  .nox-button[data-button-icon-only][data-button-size='sm'] svg {
84
90
  inline-size: 1.5rem;
85
91
  block-size: 1.5rem;
@@ -90,6 +96,19 @@
90
96
  block-size: 2.25rem;
91
97
  }
92
98
 
99
+ .nox-button__affix {
100
+ display: grid;
101
+ inline-size: 1.25rem;
102
+ block-size: 1.25rem;
103
+ place-items: center;
104
+ }
105
+
106
+ .nox-button__affix svg {
107
+ display: block;
108
+ inline-size: 100%;
109
+ block-size: 100%;
110
+ }
111
+
93
112
  .nox-button[data-button-borderless] {
94
113
  border: 0;
95
114
  border-radius: 0;
@@ -0,0 +1,35 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+ import type { Tone } from '../../../theme/contracts';
4
+ import Button from '../Button/Button.astro';
5
+
6
+ type Props = HTMLAttributes<'button'> & {
7
+ label: string;
8
+ pendingLabel?: string;
9
+ tone?: Tone;
10
+ };
11
+
12
+ const {
13
+ label,
14
+ pendingLabel = label,
15
+ tone,
16
+ ...attrs
17
+ }: Props = Astro.props;
18
+ ---
19
+
20
+ <Button
21
+ {...attrs}
22
+ type="button"
23
+ prominent
24
+ tone={tone}
25
+ data-auth-provider-action
26
+ data-auth-provider-label={label}
27
+ data-auth-provider-pending-label={pendingLabel}
28
+ >
29
+ {
30
+ Astro.slots.has('leading') ? (
31
+ <span slot="leading"><slot name="leading" /></span>
32
+ ) : null
33
+ }
34
+ <span data-auth-provider-action-label>{label}</span>
35
+ </Button>
@@ -5,6 +5,7 @@ type Props = HTMLAttributes<'div'> & {
5
5
  id?: string;
6
6
  inset?: 'default' | 'none';
7
7
  overlayBoundary?: boolean;
8
+ scrollable?: boolean;
8
9
  };
9
10
 
10
11
  const {
@@ -12,6 +13,7 @@ const {
12
13
  id,
13
14
  inset = 'default',
14
15
  overlayBoundary = false,
16
+ scrollable = false,
15
17
  ...attrs
16
18
  }: Props = Astro.props;
17
19
  ---
@@ -24,6 +26,7 @@ const {
24
26
  data-surface
25
27
  data-inset={inset}
26
28
  data-overlay-boundary={overlayBoundary ? true : undefined}
29
+ data-scrollable={scrollable ? true : undefined}
27
30
  >
28
31
  <div class="card-body__content">
29
32
  <slot />
@@ -41,6 +41,16 @@
41
41
  overflow: clip;
42
42
  }
43
43
 
44
+ .card-body[data-scrollable] {
45
+ overflow: auto;
46
+ overscroll-behavior: contain;
47
+ }
48
+
49
+ .card-body[data-scrollable] .card-body__content {
50
+ min-block-size: 100%;
51
+ align-self: flex-start;
52
+ }
53
+
44
54
  .card-body p {
45
55
  margin: 0;
46
56
  }