@mapomodule/form 2.0.0-beta.0

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 (60) hide show
  1. package/README.md +155 -0
  2. package/dist/index.d.cts +1 -0
  3. package/dist/index.d.mts +1 -0
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.mjs +34 -0
  6. package/dist/module.d.cts +2 -0
  7. package/dist/module.d.mts +2 -0
  8. package/dist/module.d.ts +2 -0
  9. package/dist/module.json +9 -0
  10. package/dist/module.mjs +18 -0
  11. package/dist/types.d.mts +13 -0
  12. package/package.json +78 -0
  13. package/src/__tests__/__mocks__/nuxt-imports.ts +5 -0
  14. package/src/__tests__/registry.test.ts +105 -0
  15. package/src/__tests__/useFormFromSchema.test.ts +415 -0
  16. package/src/__tests__/useMapoForm.test.ts +319 -0
  17. package/src/index.ts +49 -0
  18. package/src/module.ts +213 -0
  19. package/src/runtime/components/MapoFocusPortal.vue +154 -0
  20. package/src/runtime/components/MapoForm.vue +247 -0
  21. package/src/runtime/components/MapoFormField.vue +350 -0
  22. package/src/runtime/components/MapoFormFlatSection.vue +163 -0
  23. package/src/runtime/components/MapoFormGroup.vue +283 -0
  24. package/src/runtime/components/MapoFormTabs.vue +116 -0
  25. package/src/runtime/components/MapoUnknownField.vue +74 -0
  26. package/src/runtime/components/fields/MapoDateField.vue +98 -0
  27. package/src/runtime/components/fields/MapoDateTimeField.vue +192 -0
  28. package/src/runtime/components/fields/MapoFileField.vue +106 -0
  29. package/src/runtime/components/fields/MapoFksField.vue +151 -0
  30. package/src/runtime/components/fields/MapoMapField.vue +117 -0
  31. package/src/runtime/components/fields/MapoMapFieldClient.vue +92 -0
  32. package/src/runtime/components/fields/MapoRepeater.vue +553 -0
  33. package/src/runtime/components/fields/MapoRepeaterItem.vue +409 -0
  34. package/src/runtime/components/fields/MapoSeoPreview.vue +137 -0
  35. package/src/runtime/components/fields/MapoTimeField.vue +59 -0
  36. package/src/runtime/components/fields/MapoWygEditor.vue +256 -0
  37. package/src/runtime/components/fields/nui/NuiCheckbox.vue +8 -0
  38. package/src/runtime/components/fields/nui/NuiInput.vue +8 -0
  39. package/src/runtime/components/fields/nui/NuiSelectMenu.vue +27 -0
  40. package/src/runtime/components/fields/nui/NuiSlider.vue +8 -0
  41. package/src/runtime/components/fields/nui/NuiSwitch.vue +8 -0
  42. package/src/runtime/components/fields/nui/NuiTextarea.vue +8 -0
  43. package/src/runtime/components/fields/nui/useNuiFieldAttrs.ts +30 -0
  44. package/src/runtime/composables/defineFormField.ts +62 -0
  45. package/src/runtime/composables/useCurrentLang.ts +16 -0
  46. package/src/runtime/composables/useFocusMode.ts +86 -0
  47. package/src/runtime/composables/useFormDraft.ts +119 -0
  48. package/src/runtime/composables/useFormFromSchema.ts +253 -0
  49. package/src/runtime/composables/useMapoForm.ts +484 -0
  50. package/src/runtime/composables/useProgressiveDisclosure.ts +101 -0
  51. package/src/runtime/composables/useUnsavedChangesGuard.ts +58 -0
  52. package/src/runtime/devtools/DevtoolsFormsPage.vue +256 -0
  53. package/src/runtime/plugins/form-registry.ts +43 -0
  54. package/src/runtime/registry/defaults.ts +97 -0
  55. package/src/runtime/types/descriptor.ts +411 -0
  56. package/src/runtime/types/index.ts +40 -0
  57. package/src/runtime/types/layout.ts +22 -0
  58. package/src/runtime/types/registry.ts +98 -0
  59. package/src/runtime/utils/flattenFieldGroups.ts +86 -0
  60. package/src/vue-shim.d.ts +12 -0
@@ -0,0 +1,553 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watchEffect } from "vue";
3
+ import { VueDraggable } from "vue-draggable-plus";
4
+ import { useConfirmStore } from "@mapomodule/store/runtime/stores/confirm";
5
+ import type { RepeaterDescriptor } from "../../types/index.js";
6
+ import { injectMapoForm } from "../../composables/useMapoForm.js";
7
+ import MapoRepeaterItem from "./MapoRepeaterItem.vue";
8
+
9
+ const props = defineProps<{
10
+ modelValue: unknown;
11
+ descriptor: RepeaterDescriptor;
12
+ errors?: string[];
13
+ readonly?: boolean;
14
+ disabled?: boolean;
15
+ }>();
16
+
17
+ const emit = defineEmits<{ "update:modelValue": [value: unknown[]] }>();
18
+
19
+ const form = injectMapoForm()!;
20
+
21
+ const attrs = computed(() => props.descriptor.attrs ?? {});
22
+
23
+ // ─── Item list (reactive local copy) ────────────────────────────────────────
24
+
25
+ const items = computed<Record<string, unknown>[]>({
26
+ get: () =>
27
+ (Array.isArray(props.modelValue) ? props.modelValue : []) as Record<
28
+ string,
29
+ unknown
30
+ >[],
31
+ set: (val) => emit("update:modelValue", val),
32
+ });
33
+
34
+ function emitItems(newItems: Record<string, unknown>[]) {
35
+ emit("update:modelValue", [...newItems]);
36
+ }
37
+
38
+ // Stable per-item UID: guarantees persistent :key values across reorder/duplicate
39
+ // AND identity-based selection that survives reordering and deletions.
40
+ const itemUids = new WeakMap<object, string>();
41
+ let _uidSeq = 0;
42
+ function uidFor(item: Record<string, unknown>): string {
43
+ let id = itemUids.get(item);
44
+ if (!id) {
45
+ id = `it_${++_uidSeq}`;
46
+ itemUids.set(item, id);
47
+ }
48
+ return id;
49
+ }
50
+
51
+ // ─── Undo stack ──────────────────────────────────────────────────────────────
52
+
53
+ const undoStack = ref<Record<string, unknown>[][]>([]);
54
+
55
+ function pushUndo() {
56
+ undoStack.value.push(items.value.map((i) => ({ ...i })));
57
+ if (undoStack.value.length > 20) undoStack.value.shift();
58
+ }
59
+
60
+ function undo() {
61
+ const prev = undoStack.value.pop();
62
+ if (prev) emit("update:modelValue", prev);
63
+ }
64
+
65
+ // ─── Template selection ──────────────────────────────────────────────────────
66
+
67
+ const templates = computed(() => attrs.value.templates);
68
+ const templateKeys = computed(() =>
69
+ templates.value ? Object.keys(templates.value) : [],
70
+ );
71
+ const hasTemplates = computed(() => templateKeys.value.length > 0);
72
+ const selectedTemplate = ref<string>("");
73
+
74
+ // Async-safe: when templates load after mount, auto-pick the first one.
75
+ watchEffect(() => {
76
+ if (!selectedTemplate.value && templateKeys.value[0]) {
77
+ selectedTemplate.value = templateKeys.value[0];
78
+ }
79
+ });
80
+
81
+ function fieldsForItem(item: Record<string, unknown>) {
82
+ if (!templates.value) return props.descriptor.fields ?? [];
83
+ const key = (item._template as string) ?? selectedTemplate.value;
84
+ return templates.value[key] ?? props.descriptor.fields ?? [];
85
+ }
86
+
87
+ // ─── Item CRUD ───────────────────────────────────────────────────────────────
88
+
89
+ const canAdd = computed(
90
+ () =>
91
+ !props.readonly &&
92
+ (!attrs.value.maxItems || items.value.length < attrs.value.maxItems),
93
+ );
94
+ const canDelete = computed(
95
+ () =>
96
+ !props.readonly &&
97
+ (!attrs.value.minItems || items.value.length > attrs.value.minItems),
98
+ );
99
+
100
+ // Helper: how many additional items can still be added.
101
+ function remainingSlots(): number {
102
+ if (!attrs.value.maxItems) return Number.POSITIVE_INFINITY;
103
+ return Math.max(0, attrs.value.maxItems - items.value.length);
104
+ }
105
+
106
+ // Helper: the lower bound on items.length given minItems.
107
+ function minBound(): number {
108
+ return attrs.value.minItems ?? 0;
109
+ }
110
+
111
+ async function askConfirm(
112
+ message: string,
113
+ title = "Confirm",
114
+ ): Promise<boolean> {
115
+ try {
116
+ // Falls back to native confirm when the Pinia store is not registered.
117
+ return await useConfirmStore().ask({ title, message });
118
+ } catch {
119
+ return typeof globalThis.confirm === "function"
120
+ ? globalThis.confirm(message)
121
+ : false;
122
+ }
123
+ }
124
+
125
+ function addItem() {
126
+ if (!canAdd.value) return;
127
+ pushUndo();
128
+ const newItem: Record<string, unknown> = hasTemplates.value
129
+ ? { _template: selectedTemplate.value }
130
+ : {};
131
+ emitItems([...items.value, newItem]);
132
+ }
133
+
134
+ async function deleteItem(index: number) {
135
+ if (!canDelete.value) return;
136
+ if (attrs.value.confirmDelete !== false) {
137
+ const confirmed = await askConfirm(
138
+ "Are you sure you want to delete this item?",
139
+ "Delete item",
140
+ );
141
+ if (!confirmed) return;
142
+ }
143
+ pushUndo();
144
+ const next = [...items.value];
145
+ next.splice(index, 1);
146
+ emitItems(next);
147
+ }
148
+
149
+ function duplicateItem(index: number) {
150
+ if (remainingSlots() < 1) return;
151
+ const source = items.value[index];
152
+ if (!source) return;
153
+ pushUndo();
154
+ const copy = { ...source };
155
+ const next = [...items.value];
156
+ next.splice(index + 1, 0, copy);
157
+ emitItems(next);
158
+ }
159
+
160
+ function updateItem(index: number, val: Record<string, unknown>) {
161
+ const oldItem = items.value[index];
162
+ if (oldItem && oldItem !== val) {
163
+ const existingUid = itemUids.get(oldItem);
164
+ if (existingUid) itemUids.set(val, existingUid);
165
+ }
166
+ const next = [...items.value];
167
+ next[index] = val;
168
+ emitItems(next);
169
+ }
170
+
171
+ function moveUp(index: number) {
172
+ moveTo(index, index - 1);
173
+ }
174
+
175
+ function moveDown(index: number) {
176
+ moveTo(index, index + 1);
177
+ }
178
+
179
+ function moveTo(from: number, to: number) {
180
+ if (to < 0 || to >= items.value.length || from === to) return;
181
+ pushUndo();
182
+ const next = [...items.value];
183
+ const [item] = next.splice(from, 1);
184
+ if (item === undefined) return;
185
+ next.splice(to, 0, item);
186
+ emitItems(next);
187
+ }
188
+
189
+ // ─── Global collapse/expand ──────────────────────────────────────────────────
190
+
191
+ const collapseVersion = ref(0);
192
+ const globalExpanded = ref(attrs.value.defaultExpanded ?? false);
193
+
194
+ function collapseAll() {
195
+ globalExpanded.value = false;
196
+ collapseVersion.value++;
197
+ }
198
+ function expandAll() {
199
+ globalExpanded.value = true;
200
+ collapseVersion.value++;
201
+ }
202
+
203
+ // ─── Drag & drop (SSR-safe via ClientOnly) ───────────────────────────────────
204
+
205
+ function onDragStart() {
206
+ pushUndo();
207
+ }
208
+
209
+ // ─── Bulk actions ────────────────────────────────────────────────────────────
210
+
211
+ const selectionMode = ref(false);
212
+ // Identity-based selection: survives reorder, delete, undo.
213
+ const selectedUids = ref<Set<string>>(new Set());
214
+ const anchorUid = ref<string | null>(null);
215
+
216
+ function toggleSelectionMode() {
217
+ selectionMode.value = !selectionMode.value;
218
+ if (!selectionMode.value) {
219
+ selectedUids.value = new Set();
220
+ anchorUid.value = null;
221
+ // Auto-collapse all when leaving selection mode for tidiness.
222
+ collapseVersion.value++;
223
+ } else {
224
+ // Auto-collapse all when entering selection mode so the user sees the whole list.
225
+ globalExpanded.value = false;
226
+ collapseVersion.value++;
227
+ }
228
+ }
229
+
230
+ function isSelected(uid: string): boolean {
231
+ return selectedUids.value.has(uid);
232
+ }
233
+
234
+ function toggleSelect(uid: string, withShift = false) {
235
+ if (withShift && anchorUid.value && anchorUid.value !== uid) {
236
+ // Range select between anchor and current.
237
+ const uids = items.value.map(uidFor);
238
+ const a = uids.indexOf(anchorUid.value);
239
+ const b = uids.indexOf(uid);
240
+ if (a !== -1 && b !== -1) {
241
+ const [lo, hi] = a < b ? [a, b] : [b, a];
242
+ const next = new Set(selectedUids.value);
243
+ for (let i = lo; i <= hi; i++) {
244
+ const uid = uids[i];
245
+ if (uid) next.add(uid);
246
+ }
247
+ selectedUids.value = next;
248
+ return;
249
+ }
250
+ }
251
+ const next = new Set(selectedUids.value);
252
+ if (next.has(uid)) next.delete(uid);
253
+ else next.add(uid);
254
+ selectedUids.value = next;
255
+ anchorUid.value = uid;
256
+ }
257
+
258
+ function selectAll() {
259
+ selectedUids.value = new Set(items.value.map(uidFor));
260
+ }
261
+
262
+ function clearSelection() {
263
+ selectedUids.value = new Set();
264
+ anchorUid.value = null;
265
+ }
266
+
267
+ // Resolve current selection to the array of items, in current order.
268
+ function selectedItems(): Record<string, unknown>[] {
269
+ return items.value.filter((i) => selectedUids.value.has(uidFor(i)));
270
+ }
271
+
272
+ async function bulkDelete() {
273
+ const count = selectedUids.value.size;
274
+ if (count === 0) return;
275
+ // Enforce minItems: would deletion drop below the lower bound?
276
+ if (items.value.length - count < minBound()) {
277
+ await askConfirm(
278
+ `Cannot delete: at least ${minBound()} items must remain.`,
279
+ "Action blocked",
280
+ );
281
+ return;
282
+ }
283
+ const confirmed = await askConfirm(
284
+ `Delete ${count} selected items?`,
285
+ "Delete items",
286
+ );
287
+ if (!confirmed) return;
288
+ pushUndo();
289
+ emitItems(items.value.filter((i) => !selectedUids.value.has(uidFor(i))));
290
+ clearSelection();
291
+ }
292
+
293
+ function bulkDuplicate() {
294
+ const count = selectedUids.value.size;
295
+ if (count === 0) return;
296
+ // Cap by maxItems.
297
+ const slots = remainingSlots();
298
+ if (slots <= 0) return;
299
+ const toDup = selectedItems().slice(0, slots);
300
+ pushUndo();
301
+ // Insert each copy right after its original. Walk from the end so indices stay valid.
302
+ const next = [...items.value];
303
+ const dupSet = new Set(toDup.map(uidFor));
304
+ for (let i = next.length - 1; i >= 0; i--) {
305
+ const item = next[i];
306
+ if (item && dupSet.has(uidFor(item))) {
307
+ next.splice(i + 1, 0, { ...item });
308
+ }
309
+ }
310
+ emitItems(next);
311
+ clearSelection();
312
+ }
313
+
314
+ function bulkMoveToTop() {
315
+ if (selectedUids.value.size === 0) return;
316
+ pushUndo();
317
+ const selected = items.value.filter((i) => selectedUids.value.has(uidFor(i)));
318
+ const rest = items.value.filter((i) => !selectedUids.value.has(uidFor(i)));
319
+ emitItems([...selected, ...rest]);
320
+ }
321
+
322
+ const hasSelection = computed(() => selectedUids.value.size > 0);
323
+ const selectionCount = computed(() => selectedUids.value.size);
324
+
325
+ // ─── Errors ──────────────────────────────────────────────────────────────────
326
+
327
+ const parentErrors = computed(() => form.errors.value);
328
+ const errorPrefix = (index: number) =>
329
+ `${props.descriptor.key as string}.${index}`;
330
+ </script>
331
+
332
+ <template>
333
+ <div class="mapo-repeater space-y-2">
334
+ <!-- ── Main toolbar ────────────────────────────────────────────────────── -->
335
+ <div
336
+ class="flex flex-wrap items-center gap-2 rounded border border-gray-200 bg-gray-50 px-3 py-2"
337
+ >
338
+ <span class="text-sm font-medium text-gray-700">
339
+ {{ items.length }} {{ items.length === 1 ? "item" : "items" }}
340
+ <span v-if="attrs.maxItems" class="text-gray-400"
341
+ >/ {{ attrs.maxItems }}</span
342
+ >
343
+ </span>
344
+
345
+ <div class="flex flex-1 flex-wrap items-center justify-end gap-2">
346
+ <UButton size="xs" variant="ghost" color="neutral" @click="collapseAll">
347
+ Collapse all
348
+ </UButton>
349
+ <UButton size="xs" variant="ghost" color="neutral" @click="expandAll">
350
+ Expand all
351
+ </UButton>
352
+ <UButton
353
+ v-if="undoStack.length"
354
+ size="xs"
355
+ variant="ghost"
356
+ color="neutral"
357
+ icon="i-lucide-undo-2"
358
+ title="Undo last action"
359
+ @click="undo"
360
+ />
361
+
362
+ <!-- Bulk selection mode -->
363
+ <UButton
364
+ size="xs"
365
+ :variant="selectionMode ? 'solid' : 'ghost'"
366
+ color="neutral"
367
+ icon="i-lucide-check-square"
368
+ title="Bulk selection"
369
+ :label="selectionMode ? 'Exit selection' : 'Select'"
370
+ @click="toggleSelectionMode"
371
+ />
372
+
373
+ <!-- Template selector -->
374
+ <USelectMenu
375
+ v-if="hasTemplates"
376
+ v-model="selectedTemplate"
377
+ :items="templateKeys"
378
+ size="xs"
379
+ class="w-32"
380
+ />
381
+
382
+ <UButton
383
+ size="sm"
384
+ color="primary"
385
+ icon="i-lucide-plus"
386
+ :disabled="!canAdd"
387
+ @click="addItem"
388
+ >
389
+ Add
390
+ </UButton>
391
+ </div>
392
+ </div>
393
+
394
+ <!-- ── Bulk actions bar (visible when there is a selection) ───────────── -->
395
+ <Transition name="slide-down">
396
+ <div
397
+ v-if="selectionMode && hasSelection"
398
+ class="flex items-center gap-2 rounded border border-primary-200 bg-primary-50 px-3 py-2"
399
+ >
400
+ <span class="text-sm font-medium text-primary-700">
401
+ {{ selectionCount }} selected
402
+ </span>
403
+ <div class="flex flex-1 flex-wrap items-center gap-2">
404
+ <UButton size="xs" variant="ghost" color="neutral" @click="selectAll">
405
+ All
406
+ </UButton>
407
+ <UButton
408
+ size="xs"
409
+ variant="ghost"
410
+ color="neutral"
411
+ @click="clearSelection"
412
+ >
413
+ Clear
414
+ </UButton>
415
+ <UButton
416
+ size="xs"
417
+ variant="outline"
418
+ color="neutral"
419
+ icon="i-lucide-copy"
420
+ :disabled="remainingSlots() <= 0"
421
+ :title="
422
+ remainingSlots() < selectionCount
423
+ ? `Only ${remainingSlots()} of ${selectionCount} can be duplicated (maxItems reached)`
424
+ : 'Duplicate selected'
425
+ "
426
+ @click="bulkDuplicate"
427
+ >
428
+ Duplicate ({{ Math.min(selectionCount, remainingSlots()) }})
429
+ </UButton>
430
+ <UButton
431
+ size="xs"
432
+ variant="outline"
433
+ color="neutral"
434
+ icon="i-lucide-arrow-up"
435
+ @click="bulkMoveToTop"
436
+ >
437
+ Move to top
438
+ </UButton>
439
+ <UButton
440
+ size="xs"
441
+ variant="outline"
442
+ color="error"
443
+ icon="i-lucide-trash-2"
444
+ :disabled="!canDelete"
445
+ @click="bulkDelete"
446
+ >
447
+ Delete ({{ selectionCount }})
448
+ </UButton>
449
+ </div>
450
+ </div>
451
+ </Transition>
452
+
453
+ <!-- ── Item list with drag&drop (ClientOnly for SSR) ───────────────────── -->
454
+ <ClientOnly>
455
+ <VueDraggable
456
+ v-model="items"
457
+ handle=".drag-handle"
458
+ :animation="150"
459
+ :disabled="readonly || selectionMode"
460
+ @start="onDragStart"
461
+ >
462
+ <MapoRepeaterItem
463
+ v-for="(item, index) in items"
464
+ :key="`${uidFor(item)}-${collapseVersion}`"
465
+ :uid="uidFor(item)"
466
+ :item="item"
467
+ :fields="fieldsForItem(item)"
468
+ :index="index"
469
+ :error-prefix="errorPrefix(index)"
470
+ :parent-errors="parentErrors"
471
+ :languages="form.languages"
472
+ :current-lang="form.currentLang.value"
473
+ :readonly="readonly ?? false"
474
+ :registry="form.registry"
475
+ :preview-label="attrs.previewLabel"
476
+ :default-expanded="globalExpanded"
477
+ :allow-duplicate="attrs.allowDuplicate"
478
+ :repeater-descriptor="descriptor"
479
+ :total-items="items.length"
480
+ :selected="isSelected(uidFor(item))"
481
+ :selection-mode="selectionMode"
482
+ @update:item="updateItem(index, $event)"
483
+ @delete="deleteItem(index)"
484
+ @duplicate="duplicateItem(index)"
485
+ @move-up="moveUp(index)"
486
+ @move-down="moveDown(index)"
487
+ @move-to="moveTo(index, $event)"
488
+ @toggle-select="toggleSelect(uidFor(item), $event)"
489
+ />
490
+ </VueDraggable>
491
+
492
+ <!-- SSR fallback: list without drag -->
493
+ <template #fallback>
494
+ <MapoRepeaterItem
495
+ v-for="(item, index) in items"
496
+ :key="`${uidFor(item)}-${collapseVersion}`"
497
+ :uid="uidFor(item)"
498
+ :item="item"
499
+ :fields="fieldsForItem(item)"
500
+ :index="index"
501
+ :error-prefix="errorPrefix(index)"
502
+ :parent-errors="parentErrors"
503
+ :languages="form.languages"
504
+ :current-lang="form.currentLang.value"
505
+ :readonly="readonly ?? false"
506
+ :registry="form.registry"
507
+ :preview-label="attrs.previewLabel"
508
+ :default-expanded="globalExpanded"
509
+ :allow-duplicate="attrs.allowDuplicate"
510
+ :repeater-descriptor="descriptor"
511
+ :total-items="items.length"
512
+ :selected="isSelected(uidFor(item))"
513
+ :selection-mode="selectionMode"
514
+ @update:item="updateItem(index, $event)"
515
+ @delete="deleteItem(index)"
516
+ @duplicate="duplicateItem(index)"
517
+ @move-up="moveUp(index)"
518
+ @move-down="moveDown(index)"
519
+ @move-to="moveTo(index, $event)"
520
+ @toggle-select="toggleSelect(uidFor(item), $event)"
521
+ />
522
+ </template>
523
+ </ClientOnly>
524
+
525
+ <!-- ── Empty state ─────────────────────────────────────────────────────── -->
526
+ <div
527
+ v-if="items.length === 0"
528
+ class="rounded border border-dashed border-gray-300 py-8 text-center text-sm text-gray-400"
529
+ >
530
+ No items. Click "Add" to start.
531
+ </div>
532
+ </div>
533
+ </template>
534
+
535
+ <style scoped>
536
+ .slide-down-enter-active,
537
+ .slide-down-leave-active {
538
+ transition:
539
+ grid-template-rows 250ms ease,
540
+ opacity 200ms ease;
541
+ display: grid;
542
+ }
543
+ .slide-down-enter-from,
544
+ .slide-down-leave-to {
545
+ grid-template-rows: 0fr;
546
+ opacity: 0;
547
+ }
548
+ .slide-down-enter-to,
549
+ .slide-down-leave-from {
550
+ grid-template-rows: 1fr;
551
+ opacity: 1;
552
+ }
553
+ </style>