@riducms/admin 0.1.5 → 0.2.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.
- package/package.json +11 -9
- package/src/app.css +63 -4
- package/src/app.svelte +12 -14
- package/src/components/ui/date-value-control/date-value-control-loader.svelte +15 -4
- package/src/components/ui/date-value-control/date-value-control.svelte +17 -17
- package/src/components/ui/select/select-trigger.svelte +1 -1
- package/src/core/forms/embedded-fields.ts +132 -0
- package/src/core/forms/embedded-schema-draft.svelte.ts +335 -0
- package/src/core/forms/field-editor-authoring.ts +47 -0
- package/src/core/forms/field-editor-binding.ts +228 -0
- package/src/core/forms/field-issue-focus.ts +42 -0
- package/src/core/forms/field-occurrence.ts +122 -0
- package/src/core/forms/form-controller.svelte.ts +475 -29
- package/src/core/forms/form-issue-correlation.ts +159 -0
- package/src/core/forms/form-schema.ts +211 -17
- package/src/core/forms/form-validation.ts +183 -17
- package/src/core/forms/live-validation.svelte.ts +317 -0
- package/src/core/forms/plugin-field-authoring.ts +160 -0
- package/src/core/forms/plugin-field-binding.ts +258 -0
- package/src/core/forms/plugin-field-write.ts +57 -0
- package/src/core/i18n/admin-i18n.svelte.ts +8 -1
- package/src/core/i18n/localized-schema.ts +33 -10
- package/src/core/plugins/field-registry.ts +29 -49
- package/src/core/plugins/row-label-registry.ts +25 -5
- package/src/core/runtime/admin-runtime.svelte.ts +25 -13
- package/src/features/auth/account-route.svelte +10 -5
- package/src/features/auth/account-security-route.svelte +1 -1
- package/src/features/collections/collection-list-bulk-editor.svelte +6 -6
- package/src/features/collections/collection-list-controller.svelte.ts +5 -5
- package/src/features/collections/collection-list-filter-popover.svelte +6 -6
- package/src/features/collections/collection-list-route.svelte +13 -14
- package/src/features/collections/list-workspace.ts +22 -8
- package/src/features/documents/document-controller.svelte.ts +8 -2
- package/src/features/documents/document-route.svelte +36 -43
- package/src/features/reference-browser/reference-browser-workflow.svelte.ts +30 -15
- package/src/features/reference-browser/reference-browser.svelte +215 -189
- package/src/features/uploads/upload-metadata-fields.svelte +6 -6
- package/src/features/versions/version-history-route.svelte +1 -1
- package/src/fields/embedded-schema-draft-editor.svelte +74 -0
- package/src/fields/embedded-schema-fields.svelte +44 -0
- package/src/fields/field-clipboard.ts +76 -16
- package/src/fields/field-collapsible.svelte +49 -0
- package/src/fields/field-layout.svelte +2 -18
- package/src/fields/field-messages.svelte +13 -18
- package/src/fields/field-renderer.svelte +53 -14
- package/src/fields/join/join-field.svelte +8 -1
- package/src/fields/live-validation-feedback.svelte +21 -0
- package/src/fields/local-field-editor.svelte +52 -0
- package/src/fields/nested/nested-field.svelte +159 -91
- package/src/fields/nested/scoped-field.ts +7 -9
- package/src/fields/plugin-field.svelte +156 -0
- package/src/fields/point/point-field.svelte +12 -2
- package/src/fields/primitive-list/primitive-list-field.svelte +170 -0
- package/src/fields/primitive-list/primitive-number-input.ts +6 -0
- package/src/fields/radio/radio-field.svelte +13 -9
- package/src/fields/relationship/relationship-field.svelte +36 -12
- package/src/fields/relationship/relationship-query.ts +4 -2
- package/src/fields/relationship/relationship-quick-picker.svelte +38 -10
- package/src/fields/scalar/date-control-value.ts +5 -5
- package/src/fields/scalar/date-value.ts +5 -5
- package/src/fields/scalar/scalar-field.svelte +47 -41
- package/src/fields/select/select-field.svelte +24 -24
- package/src/fields/select/select-value.ts +3 -3
- package/src/fields/text/slug-field.svelte +24 -8
- package/src/fields/text/text-field.svelte +8 -6
- package/src/index.ts +4 -12
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { SchemaField } from "@riducms/protocol";
|
|
3
|
+
import { fieldControlARIA } from "@riducms/ui";
|
|
3
4
|
|
|
4
5
|
import { Input } from "@admin/components/ui/input";
|
|
5
6
|
import type { FormController } from "@admin/core/forms/form-controller.svelte";
|
|
@@ -12,10 +13,15 @@
|
|
|
12
13
|
Array.isArray(form.get(field.path)) ? (form.get(field.path) as number[]) : []
|
|
13
14
|
);
|
|
14
15
|
const issues = $derived(form.issuesFor(field.path));
|
|
16
|
+
const editingBlocked = $derived(field.admin.readOnly === true || form.editingBlocked);
|
|
17
|
+
const controlARIA = $derived(
|
|
18
|
+
fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
|
|
19
|
+
);
|
|
15
20
|
|
|
16
21
|
$effect(() => form.register(field.path));
|
|
17
22
|
|
|
18
23
|
function setCoordinate(index: number, next: number) {
|
|
24
|
+
if (editingBlocked) return;
|
|
19
25
|
const coordinates = [Number(value[0] ?? 0), Number(value[1] ?? 0)];
|
|
20
26
|
coordinates[index] = next;
|
|
21
27
|
form.set(field.path, coordinates);
|
|
@@ -27,24 +33,28 @@
|
|
|
27
33
|
<label class="grid gap-1.5 text-[12px] text-foreground-muted">
|
|
28
34
|
{runtime.i18n.t("fields:longitude")}
|
|
29
35
|
<Input
|
|
36
|
+
id={field.id}
|
|
30
37
|
type="number"
|
|
31
38
|
min="-180"
|
|
32
39
|
max="180"
|
|
33
40
|
step="any"
|
|
34
41
|
value={value[0] ?? ""}
|
|
35
|
-
readonly={
|
|
42
|
+
readonly={editingBlocked}
|
|
43
|
+
{...controlARIA}
|
|
36
44
|
oninput={(event) => setCoordinate(0, event.currentTarget.valueAsNumber)}
|
|
37
45
|
/>
|
|
38
46
|
</label>
|
|
39
47
|
<label class="grid gap-1.5 text-[12px] text-foreground-muted">
|
|
40
48
|
{runtime.i18n.t("fields:latitude")}
|
|
41
49
|
<Input
|
|
50
|
+
id={`${field.id}-latitude`}
|
|
42
51
|
type="number"
|
|
43
52
|
min="-90"
|
|
44
53
|
max="90"
|
|
45
54
|
step="any"
|
|
46
55
|
value={value[1] ?? ""}
|
|
47
|
-
readonly={
|
|
56
|
+
readonly={editingBlocked}
|
|
57
|
+
{...controlARIA}
|
|
48
58
|
oninput={(event) => setCoordinate(1, event.currentTarget.valueAsNumber)}
|
|
49
59
|
/>
|
|
50
60
|
</label>
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick } from "svelte";
|
|
3
|
+
import type { SchemaField } from "@riducms/protocol";
|
|
4
|
+
import { fieldControlARIA } from "@riducms/ui";
|
|
5
|
+
import ArrowUpIcon from "~icons/lucide/arrow-up";
|
|
6
|
+
import ArrowDownIcon from "~icons/lucide/arrow-down";
|
|
7
|
+
import XIcon from "~icons/lucide/x";
|
|
8
|
+
import { Button } from "@admin/components/ui/button";
|
|
9
|
+
import { Input } from "@admin/components/ui/input";
|
|
10
|
+
import type { FormController } from "@admin/core/forms/form-controller.svelte";
|
|
11
|
+
import { getAdminI18n } from "@riducms/plugin";
|
|
12
|
+
import FieldShell from "@admin/fields/field-shell.svelte";
|
|
13
|
+
import { primitiveNumberInput } from "@admin/fields/primitive-list/primitive-number-input";
|
|
14
|
+
|
|
15
|
+
let { field, form }: { field: SchemaField; form: FormController } = $props();
|
|
16
|
+
const i18n = getAdminI18n();
|
|
17
|
+
|
|
18
|
+
$effect(() => form.register(field.path));
|
|
19
|
+
const values = $derived.by(() => {
|
|
20
|
+
const value = form.get(field.path);
|
|
21
|
+
return Array.isArray(value) ? value : [];
|
|
22
|
+
});
|
|
23
|
+
const issues = $derived(form.issuesFor(field.path));
|
|
24
|
+
const editingBlocked = $derived(field.admin.readOnly === true || form.editingBlocked);
|
|
25
|
+
const canAdd = $derived(
|
|
26
|
+
!editingBlocked && (!field.list?.maxRows || values.length < field.list.maxRows)
|
|
27
|
+
);
|
|
28
|
+
const minimum = $derived(Math.max(field.required ? 1 : 0, field.list?.minRows ?? 0));
|
|
29
|
+
const feedbackARIA = $derived(
|
|
30
|
+
fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
|
|
31
|
+
);
|
|
32
|
+
const controlARIA = $derived({
|
|
33
|
+
...feedbackARIA,
|
|
34
|
+
"aria-describedby": [`${field.id}-count`, feedbackARIA["aria-describedby"]]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join(" "),
|
|
37
|
+
});
|
|
38
|
+
let container: HTMLDivElement;
|
|
39
|
+
let announcement = $state("");
|
|
40
|
+
// Only DOM identity lives here. The form owns all values, including unfinished numeric input.
|
|
41
|
+
let keys = $state<string[]>([]);
|
|
42
|
+
const itemKey = (index: number) => keys[index] ?? `initial-${index}`;
|
|
43
|
+
|
|
44
|
+
async function focusItem(index: number) {
|
|
45
|
+
// Insertion/removal must render before moving focus to the surviving input or Add button.
|
|
46
|
+
await tick();
|
|
47
|
+
container.querySelector<HTMLInputElement>(`[data-list-item="${index}"]`)?.focus();
|
|
48
|
+
if (index < 0) container.querySelector<HTMLButtonElement>("[data-list-add]")?.focus();
|
|
49
|
+
}
|
|
50
|
+
function update(next: unknown[], nextKeys = values.map((_, index) => itemKey(index))) {
|
|
51
|
+
if (editingBlocked) return;
|
|
52
|
+
keys = nextKeys;
|
|
53
|
+
form.set(field.path, next);
|
|
54
|
+
}
|
|
55
|
+
async function add() {
|
|
56
|
+
if (!canAdd) return;
|
|
57
|
+
const index = values.length;
|
|
58
|
+
update([...values, ""], [...values.map((_, index) => itemKey(index)), crypto.randomUUID()]);
|
|
59
|
+
announcement = i18n.t("fields:listAdded", { number: index + 1 });
|
|
60
|
+
await focusItem(index);
|
|
61
|
+
}
|
|
62
|
+
function edit(index: number, value: string) {
|
|
63
|
+
if (editingBlocked) return;
|
|
64
|
+
const next = [...values];
|
|
65
|
+
next[index] = field.type === "number-list" ? primitiveNumberInput(value) : value;
|
|
66
|
+
update(next);
|
|
67
|
+
}
|
|
68
|
+
async function remove(index: number) {
|
|
69
|
+
if (editingBlocked) return;
|
|
70
|
+
const next = values.filter((_, at) => at !== index);
|
|
71
|
+
update(
|
|
72
|
+
next,
|
|
73
|
+
values.map((_, at) => itemKey(at)).filter((_, at) => at !== index)
|
|
74
|
+
);
|
|
75
|
+
announcement = i18n.t("fields:listRemoved", { number: index + 1 });
|
|
76
|
+
await focusItem(Math.min(index, next.length - 1));
|
|
77
|
+
}
|
|
78
|
+
async function move(index: number, direction: -1 | 1) {
|
|
79
|
+
const destination = index + direction;
|
|
80
|
+
if (editingBlocked || destination < 0 || destination >= values.length) return;
|
|
81
|
+
const next = [...values];
|
|
82
|
+
const nextKeys = values.map((_, at) => itemKey(at));
|
|
83
|
+
[next[index], next[destination]] = [next[destination], next[index]];
|
|
84
|
+
[nextKeys[index], nextKeys[destination]] = [nextKeys[destination]!, nextKeys[index]!];
|
|
85
|
+
update(next, nextKeys);
|
|
86
|
+
announcement = i18n.t("fields:listMoved", { from: index + 1, to: destination + 1 });
|
|
87
|
+
await focusItem(destination);
|
|
88
|
+
}
|
|
89
|
+
function keyboard(event: KeyboardEvent, index: number) {
|
|
90
|
+
if (!event.altKey || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
return move(index, event.key === "ArrowUp" ? -1 : 1);
|
|
93
|
+
}
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<FieldShell {field} {issues}>
|
|
97
|
+
<div bind:this={container} class="grid gap-2">
|
|
98
|
+
<p id="{field.id}-count" class="text-xs text-foreground-muted">
|
|
99
|
+
{i18n.t("fields:itemCount", { count: values.length })}
|
|
100
|
+
{#if minimum > 0}
|
|
101
|
+
· {i18n.t("fields:minimum", { count: minimum })}{/if}
|
|
102
|
+
{#if field.list?.maxRows}
|
|
103
|
+
· {i18n.t("fields:maximum", { count: field.list.maxRows })}{/if}
|
|
104
|
+
</p>
|
|
105
|
+
{#if values.length === 0}<p class="text-sm text-foreground-muted">
|
|
106
|
+
{i18n.t("fields:listEmpty")}
|
|
107
|
+
</p>{/if}
|
|
108
|
+
<ol class="grid gap-2" aria-label={field.admin.label}>
|
|
109
|
+
{#each values as value, index (itemKey(index))}
|
|
110
|
+
<li class="flex items-center gap-1">
|
|
111
|
+
<Input
|
|
112
|
+
id={index === 0 ? field.id : `${field.id}-item-${itemKey(index)}`}
|
|
113
|
+
data-list-item={index}
|
|
114
|
+
aria-label={i18n.t("fields:listItem", {
|
|
115
|
+
label: field.admin.label,
|
|
116
|
+
number: index + 1,
|
|
117
|
+
})}
|
|
118
|
+
{...controlARIA}
|
|
119
|
+
inputmode={field.type === "number-list" ? "decimal" : undefined}
|
|
120
|
+
placeholder={field.admin.placeholder}
|
|
121
|
+
value={typeof value === "string" || typeof value === "number" ? String(value) : ""}
|
|
122
|
+
readonly={editingBlocked}
|
|
123
|
+
oninput={(event) => edit(index, event.currentTarget.value)}
|
|
124
|
+
onkeydown={(event) => keyboard(event, index)}
|
|
125
|
+
/>
|
|
126
|
+
<Button
|
|
127
|
+
variant="ghost"
|
|
128
|
+
size="icon"
|
|
129
|
+
aria-label={i18n.t("fields:listMoveUp", { number: index + 1 })}
|
|
130
|
+
disabled={editingBlocked || index === 0}
|
|
131
|
+
onclick={() => move(index, -1)}
|
|
132
|
+
>
|
|
133
|
+
<ArrowUpIcon class="size-4" />
|
|
134
|
+
</Button>
|
|
135
|
+
<Button
|
|
136
|
+
variant="ghost"
|
|
137
|
+
size="icon"
|
|
138
|
+
aria-label={i18n.t("fields:listMoveDown", { number: index + 1 })}
|
|
139
|
+
disabled={editingBlocked || index === values.length - 1}
|
|
140
|
+
onclick={() => move(index, 1)}
|
|
141
|
+
>
|
|
142
|
+
<ArrowDownIcon class="size-4" />
|
|
143
|
+
</Button>
|
|
144
|
+
<Button
|
|
145
|
+
variant="ghost"
|
|
146
|
+
size="icon"
|
|
147
|
+
aria-label={i18n.t("fields:listRemove", { number: index + 1 })}
|
|
148
|
+
disabled={editingBlocked}
|
|
149
|
+
onclick={() => remove(index)}
|
|
150
|
+
>
|
|
151
|
+
<XIcon class="size-4" />
|
|
152
|
+
</Button>
|
|
153
|
+
</li>
|
|
154
|
+
{/each}
|
|
155
|
+
</ol>
|
|
156
|
+
<Button
|
|
157
|
+
id={values.length === 0 ? field.id : undefined}
|
|
158
|
+
data-list-add
|
|
159
|
+
aria-label={i18n.t("fields:listAdd")}
|
|
160
|
+
{...controlARIA}
|
|
161
|
+
variant="outline"
|
|
162
|
+
class="w-fit"
|
|
163
|
+
disabled={!canAdd}
|
|
164
|
+
onclick={add}
|
|
165
|
+
>
|
|
166
|
+
{i18n.t("fields:listAdd")}
|
|
167
|
+
</Button>
|
|
168
|
+
<p class="sr-only" role="status">{announcement}</p>
|
|
169
|
+
</div>
|
|
170
|
+
</FieldShell>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Keep unfinished input in the unsaved form so collapse/remount cannot discard it. */
|
|
2
|
+
export function primitiveNumberInput(input: string): number | string {
|
|
3
|
+
if (!/^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(input)) return input;
|
|
4
|
+
const value = Number(input);
|
|
5
|
+
return Number.isFinite(value) ? value : input;
|
|
6
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { SchemaField } from "@riducms/protocol";
|
|
3
|
+
import { fieldControlARIA } from "@riducms/ui";
|
|
3
4
|
|
|
4
5
|
import { RadioCardItem, RadioGroup } from "@admin/components/ui/radio-group";
|
|
5
6
|
import type { FormController } from "@admin/core/forms/form-controller.svelte";
|
|
@@ -8,7 +9,10 @@
|
|
|
8
9
|
let { field, form }: { field: SchemaField; form: FormController } = $props();
|
|
9
10
|
const value = $derived(String(form.get(field.path) ?? ""));
|
|
10
11
|
const issues = $derived(form.issuesFor(field.path));
|
|
11
|
-
const
|
|
12
|
+
const editingBlocked = $derived(field.admin.readOnly === true || form.editingBlocked);
|
|
13
|
+
const controlARIA = $derived(
|
|
14
|
+
fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
|
|
15
|
+
);
|
|
12
16
|
|
|
13
17
|
$effect(() => form.register(field.path));
|
|
14
18
|
</script>
|
|
@@ -19,16 +23,16 @@
|
|
|
19
23
|
class="flex flex-wrap gap-x-7 gap-y-1"
|
|
20
24
|
name={field.path}
|
|
21
25
|
{value}
|
|
22
|
-
required={field.required}
|
|
23
|
-
readonly={
|
|
26
|
+
required={field.required && (!field.dynamicDefault || form.get(field.path) !== undefined)}
|
|
27
|
+
readonly={editingBlocked}
|
|
24
28
|
aria-labelledby={`${field.id}-label`}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
{...controlARIA}
|
|
30
|
+
onValueChange={(next) => {
|
|
31
|
+
if (!editingBlocked) form.set(field.path, next);
|
|
32
|
+
}}
|
|
29
33
|
>
|
|
30
|
-
{#each field.select?.
|
|
31
|
-
<RadioCardItem value={
|
|
34
|
+
{#each field.select?.options ?? [] as option (option.value)}
|
|
35
|
+
<RadioCardItem value={option.value} label={option.label} />
|
|
32
36
|
{/each}
|
|
33
37
|
</RadioGroup>
|
|
34
38
|
</FieldShell>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { FieldAuthoringHost } from "@riducms/plugin";
|
|
3
3
|
import type { SchemaField } from "@riducms/protocol";
|
|
4
|
+
import { fieldControlARIA } from "@riducms/ui";
|
|
4
5
|
import FileIcon from "~icons/lucide/file";
|
|
5
6
|
import PencilIcon from "~icons/lucide/pencil";
|
|
6
7
|
import PlusIcon from "~icons/lucide/plus";
|
|
@@ -59,9 +60,12 @@
|
|
|
59
60
|
selectTarget,
|
|
60
61
|
setBrowserOpen,
|
|
61
62
|
} = controller;
|
|
62
|
-
const
|
|
63
|
+
const controlARIA = $derived(
|
|
64
|
+
fieldControlARIA(field.id, field.admin.description !== undefined, issues.length > 0)
|
|
65
|
+
);
|
|
66
|
+
const editingBlocked = $derived(field.admin.readOnly === true || form.editingBlocked);
|
|
63
67
|
const optionFilter = $derived(
|
|
64
|
-
relationshipOptionFilters(field, form.
|
|
68
|
+
relationshipOptionFilters(field, (path) => form.get(path), targetCollection?.slug)
|
|
65
69
|
);
|
|
66
70
|
const quickPickerTargets = $derived(
|
|
67
71
|
targets.flatMap((target) => {
|
|
@@ -73,18 +77,20 @@
|
|
|
73
77
|
: [
|
|
74
78
|
{
|
|
75
79
|
collection: localizeSchemaCollection(collection, runtime.i18n),
|
|
76
|
-
filter: relationshipOptionFilters(field, form.
|
|
80
|
+
filter: relationshipOptionFilters(field, (path) => form.get(path), collection.slug),
|
|
77
81
|
},
|
|
78
82
|
];
|
|
79
83
|
})
|
|
80
84
|
);
|
|
81
85
|
|
|
82
86
|
function pickRelationship(target: string, id: string) {
|
|
87
|
+
if (editingBlocked) return;
|
|
83
88
|
selectTarget(target);
|
|
84
89
|
commit([id]);
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
function browseRelationships(target: string) {
|
|
93
|
+
if (form.editingBlocked) return;
|
|
88
94
|
selectTarget(target);
|
|
89
95
|
openBrowser();
|
|
90
96
|
}
|
|
@@ -101,13 +107,17 @@
|
|
|
101
107
|
{:else if field.upload !== undefined && !hasMany && selectedID !== undefined}
|
|
102
108
|
{const document = documents[selectedID]}
|
|
103
109
|
<div
|
|
104
|
-
class="flex min-h-17 items-center gap-2.5 rounded-[4px] border border-control-border bg-control p-2"
|
|
110
|
+
class="flex min-h-17 items-center gap-2.5 rounded-[4px] border border-control-border bg-control p-2 aria-invalid:!border-destructive/65"
|
|
111
|
+
aria-invalid={issues.length > 0}
|
|
105
112
|
>
|
|
106
113
|
<button
|
|
114
|
+
id={field.id}
|
|
107
115
|
type="button"
|
|
116
|
+
disabled={form.editingBlocked}
|
|
108
117
|
class="group flex min-w-0 flex-1 items-center gap-2.5 rounded-[3px] text-start outline-none focus-visible:ring-2 focus-visible:ring-ring/70"
|
|
109
118
|
onclick={() => openBrowser(selectedID)}
|
|
110
119
|
aria-label={runtime.i18n.t("fields:edit", { label: label(selectedID) })}
|
|
120
|
+
{...controlARIA}
|
|
111
121
|
>
|
|
112
122
|
<span
|
|
113
123
|
class="grid size-14 shrink-0 place-items-center overflow-hidden rounded-[3px] border border-control-border bg-background"
|
|
@@ -137,11 +147,16 @@
|
|
|
137
147
|
</button>
|
|
138
148
|
{#if !field.admin.readOnly}
|
|
139
149
|
<div class="flex shrink-0 flex-wrap justify-end gap-1">
|
|
140
|
-
<Button
|
|
150
|
+
<Button
|
|
151
|
+
variant="outline"
|
|
152
|
+
size="xs"
|
|
153
|
+
disabled={form.editingBlocked}
|
|
154
|
+
onclick={() => openBrowser()}
|
|
155
|
+
>
|
|
141
156
|
<SearchIcon class="size-3" />
|
|
142
157
|
{runtime.i18n.t("general:replace")}
|
|
143
158
|
</Button>
|
|
144
|
-
<Button variant="ghost" size="xs" onclick={() => commit([])}>
|
|
159
|
+
<Button variant="ghost" size="xs" disabled={editingBlocked} onclick={() => commit([])}>
|
|
145
160
|
<XIcon class="size-3" />
|
|
146
161
|
{runtime.i18n.t("general:remove")}
|
|
147
162
|
</Button>
|
|
@@ -160,27 +175,31 @@
|
|
|
160
175
|
selectedInitials={selectedID === undefined ? undefined : initials(selectedID)}
|
|
161
176
|
placeholder={field.admin.placeholder}
|
|
162
177
|
readOnly={field.admin.readOnly}
|
|
178
|
+
blocked={form.editingBlocked}
|
|
163
179
|
locale={form.contentLocale}
|
|
164
180
|
upload={field.upload !== undefined}
|
|
165
181
|
invalid={issues.length > 0}
|
|
166
|
-
|
|
182
|
+
hasDescription={field.admin.description !== undefined}
|
|
167
183
|
onPick={pickRelationship}
|
|
168
|
-
onRemove={() =>
|
|
184
|
+
onRemove={() => {
|
|
185
|
+
if (!editingBlocked) commit([]);
|
|
186
|
+
}}
|
|
169
187
|
onBrowse={browseRelationships}
|
|
170
188
|
onEdit={selectedID === undefined ? undefined : () => openBrowser(selectedID)}
|
|
171
189
|
/>
|
|
172
190
|
{:else}
|
|
173
191
|
<div
|
|
174
|
-
class="flex min-h-10 min-w-0 rounded-[3px] border border-control-border bg-control transition-colors hover:border-control-border-hover aria-invalid
|
|
192
|
+
class="flex min-h-10 min-w-0 rounded-[3px] border border-control-border bg-control transition-colors hover:border-control-border-hover aria-invalid:!border-destructive/65"
|
|
175
193
|
aria-invalid={issues.length > 0}
|
|
176
194
|
>
|
|
177
195
|
{#if selectedIDs.length === 0}
|
|
178
196
|
<button
|
|
179
197
|
id={field.id}
|
|
180
198
|
type="button"
|
|
199
|
+
disabled={form.editingBlocked}
|
|
181
200
|
class="flex min-h-10 min-w-0 flex-1 items-center px-3 text-start text-[13px] text-foreground-placeholder outline-none hover:text-foreground focus-visible:text-primary"
|
|
182
201
|
onclick={() => openBrowser()}
|
|
183
|
-
|
|
202
|
+
{...controlARIA}
|
|
184
203
|
>
|
|
185
204
|
<span class="px-1.5 text-[13px] text-foreground-placeholder">
|
|
186
205
|
{field.admin.placeholder ??
|
|
@@ -197,6 +216,7 @@
|
|
|
197
216
|
>
|
|
198
217
|
<button
|
|
199
218
|
type="button"
|
|
219
|
+
disabled={form.editingBlocked}
|
|
200
220
|
class="min-w-0 flex-1 truncate text-start outline-none hover:text-foreground focus-visible:text-primary"
|
|
201
221
|
onclick={() => openBrowser(id)}
|
|
202
222
|
aria-label={runtime.i18n.t("fields:edit", { label: label(id) })}
|
|
@@ -207,6 +227,7 @@
|
|
|
207
227
|
<Button
|
|
208
228
|
variant="ghost"
|
|
209
229
|
size="icon-xs"
|
|
230
|
+
disabled={editingBlocked}
|
|
210
231
|
onclick={() => remove(id)}
|
|
211
232
|
aria-label={runtime.i18n.t("fields:remove", { label: label(id) })}
|
|
212
233
|
>
|
|
@@ -221,8 +242,9 @@
|
|
|
221
242
|
variant="ghost"
|
|
222
243
|
size="icon"
|
|
223
244
|
class="h-auto min-h-10 w-10 rounded-s-none border-s border-control-border"
|
|
245
|
+
disabled={form.editingBlocked}
|
|
224
246
|
onclick={() => openBrowser()}
|
|
225
|
-
|
|
247
|
+
{...controlARIA}
|
|
226
248
|
aria-label={runtime.i18n.t("fields:browse", {
|
|
227
249
|
label: targetCollection.labels.plural.toLocaleLowerCase(runtime.i18n.language),
|
|
228
250
|
})}
|
|
@@ -252,7 +274,9 @@
|
|
|
252
274
|
{initialDocumentID}
|
|
253
275
|
{optionFilter}
|
|
254
276
|
locale={form.contentLocale}
|
|
255
|
-
onCommit={
|
|
277
|
+
onCommit={(ids) => {
|
|
278
|
+
if (!editingBlocked) commit(ids);
|
|
279
|
+
}}
|
|
256
280
|
onClose={() => setBrowserOpen(false)}
|
|
257
281
|
/>
|
|
258
282
|
{/if}
|
|
@@ -33,7 +33,7 @@ export function combineRelationshipFilters(
|
|
|
33
33
|
|
|
34
34
|
export function relationshipOptionFilters(
|
|
35
35
|
field: SchemaField,
|
|
36
|
-
values: Record<string, unknown
|
|
36
|
+
values: Record<string, unknown> | ((path: string) => unknown),
|
|
37
37
|
targetCollection: string | undefined
|
|
38
38
|
): readonly FieldReferenceFilter[] | undefined {
|
|
39
39
|
const reference = field.relationship ?? field.upload;
|
|
@@ -46,7 +46,9 @@ export function relationshipOptionFilters(
|
|
|
46
46
|
candidate.value === undefined
|
|
47
47
|
? candidate.sourcePath === undefined
|
|
48
48
|
? undefined
|
|
49
|
-
:
|
|
49
|
+
: typeof values === "function"
|
|
50
|
+
? values(candidate.sourcePath)
|
|
51
|
+
: readPath(values, candidate.sourcePath)
|
|
50
52
|
: relationshipFilterLiteral(candidate.value);
|
|
51
53
|
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
52
54
|
return [];
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import PencilIcon from "~icons/lucide/pencil";
|
|
8
8
|
import PlusIcon from "~icons/lucide/plus";
|
|
9
9
|
import XIcon from "~icons/lucide/x";
|
|
10
|
-
import { TooltipContent, TooltipRoot, TooltipTrigger } from "@riducms/ui";
|
|
10
|
+
import { fieldControlARIA, TooltipContent, TooltipRoot, TooltipTrigger } from "@riducms/ui";
|
|
11
11
|
|
|
12
12
|
import { Skeleton } from "@admin/components/ui/skeleton";
|
|
13
13
|
import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
selectedInitials,
|
|
28
28
|
placeholder,
|
|
29
29
|
readOnly = false,
|
|
30
|
+
blocked = false,
|
|
30
31
|
locale,
|
|
31
32
|
onPick,
|
|
32
33
|
onRemove,
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
onEdit,
|
|
35
36
|
upload = false,
|
|
36
37
|
invalid = false,
|
|
37
|
-
|
|
38
|
+
hasDescription = false,
|
|
38
39
|
}: {
|
|
39
40
|
id: string;
|
|
40
41
|
label: string;
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
selectedInitials?: string;
|
|
46
47
|
placeholder?: string;
|
|
47
48
|
readOnly?: boolean;
|
|
49
|
+
blocked?: boolean;
|
|
48
50
|
locale?: string;
|
|
49
51
|
onPick: (target: string, id: string) => void;
|
|
50
52
|
onRemove: () => void;
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
onEdit?: () => void;
|
|
53
55
|
upload?: boolean;
|
|
54
56
|
invalid?: boolean;
|
|
55
|
-
|
|
57
|
+
hasDescription?: boolean;
|
|
56
58
|
} = $props();
|
|
57
59
|
|
|
58
60
|
const runtime = getAdminRuntime();
|
|
@@ -77,7 +79,7 @@
|
|
|
77
79
|
const { comboboxItems, groups, open, query, selectedValue } = $derived(controller);
|
|
78
80
|
const {
|
|
79
81
|
browseAll,
|
|
80
|
-
changeOpen,
|
|
82
|
+
changeOpen: setOpen,
|
|
81
83
|
documentLabel,
|
|
82
84
|
initials,
|
|
83
85
|
pick,
|
|
@@ -92,31 +94,52 @@
|
|
|
92
94
|
const searchLabel = $derived(
|
|
93
95
|
targets.length === 1 ? (selectedCollection?.labels.plural ?? label) : label
|
|
94
96
|
);
|
|
97
|
+
const controlARIA = $derived(fieldControlARIA(id, hasDescription, invalid));
|
|
98
|
+
const editingBlocked = $derived(readOnly || blocked);
|
|
95
99
|
let triggerElement = $state<HTMLButtonElement | null>(null);
|
|
96
100
|
let searchInput = $state<HTMLInputElement | null>(null);
|
|
97
101
|
|
|
102
|
+
$effect(() => {
|
|
103
|
+
if (editingBlocked) setOpen(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function changePickerOpen(next: boolean) {
|
|
107
|
+
if (editingBlocked && next) return;
|
|
108
|
+
setOpen(next);
|
|
109
|
+
}
|
|
110
|
+
|
|
98
111
|
async function togglePicker() {
|
|
112
|
+
if (editingBlocked) return;
|
|
99
113
|
if (!toggleOpen()) return;
|
|
100
114
|
await tick();
|
|
101
115
|
searchInput?.focus();
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
function beginSearch(event: Event) {
|
|
119
|
+
if (editingBlocked) return;
|
|
105
120
|
setQuery((event.currentTarget as HTMLInputElement).value);
|
|
106
121
|
}
|
|
107
122
|
|
|
123
|
+
function choose(value: string) {
|
|
124
|
+
if (editingBlocked) return;
|
|
125
|
+
pick(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
108
128
|
function remove(event: MouseEvent) {
|
|
109
129
|
event.stopPropagation();
|
|
130
|
+
if (editingBlocked) return;
|
|
110
131
|
removeSelection();
|
|
111
132
|
}
|
|
112
133
|
|
|
113
134
|
function browse(event: MouseEvent, target?: string) {
|
|
114
135
|
event.stopPropagation();
|
|
136
|
+
if (blocked) return;
|
|
115
137
|
browseAll(target);
|
|
116
138
|
}
|
|
117
139
|
|
|
118
140
|
function edit(event: MouseEvent) {
|
|
119
141
|
event.stopPropagation();
|
|
142
|
+
if (blocked) return;
|
|
120
143
|
onEdit?.();
|
|
121
144
|
}
|
|
122
145
|
</script>
|
|
@@ -127,13 +150,13 @@
|
|
|
127
150
|
inputValue={query}
|
|
128
151
|
items={comboboxItems}
|
|
129
152
|
{open}
|
|
130
|
-
onOpenChange={
|
|
131
|
-
onValueChange={
|
|
153
|
+
onOpenChange={changePickerOpen}
|
|
154
|
+
onValueChange={choose}
|
|
132
155
|
allowDeselect={false}
|
|
133
156
|
loop
|
|
134
157
|
>
|
|
135
158
|
<div
|
|
136
|
-
class="flex h-10.5 min-w-0 rounded-[3px] border border-control-border bg-control transition-[background-color,border-color] duration-150 hover:bg-background focus-within:border-primary/65 aria-invalid
|
|
159
|
+
class="flex h-10.5 min-w-0 rounded-[3px] border border-control-border bg-control transition-[background-color,border-color] duration-150 hover:bg-background focus-within:border-primary/65 aria-invalid:!border-destructive/65"
|
|
137
160
|
aria-invalid={invalid}
|
|
138
161
|
>
|
|
139
162
|
<button
|
|
@@ -141,9 +164,9 @@
|
|
|
141
164
|
{id}
|
|
142
165
|
type="button"
|
|
143
166
|
class="flex min-w-0 flex-1 items-center gap-2 rounded-s-[3px] px-2.75 text-start outline-none disabled:cursor-not-allowed disabled:opacity-45"
|
|
144
|
-
disabled={
|
|
167
|
+
disabled={editingBlocked}
|
|
145
168
|
aria-label={label}
|
|
146
|
-
|
|
169
|
+
{...controlARIA}
|
|
147
170
|
aria-haspopup="listbox"
|
|
148
171
|
aria-expanded={open}
|
|
149
172
|
onclick={togglePicker}
|
|
@@ -181,7 +204,7 @@
|
|
|
181
204
|
{...props}
|
|
182
205
|
type="button"
|
|
183
206
|
class="grid w-9 shrink-0 place-items-center border-s border-control-border text-foreground-sub outline-none transition-colors hover:bg-background hover:text-foreground focus-visible:text-primary disabled:cursor-not-allowed disabled:opacity-45"
|
|
184
|
-
disabled={
|
|
207
|
+
disabled={blocked}
|
|
185
208
|
onpointerdown={(event) => event.preventDefault()}
|
|
186
209
|
onclick={edit}
|
|
187
210
|
aria-label={runtime.i18n.t("fields:edit", { label: selectedLabel ?? "" })}
|
|
@@ -201,6 +224,7 @@
|
|
|
201
224
|
<button
|
|
202
225
|
{...props}
|
|
203
226
|
type="button"
|
|
227
|
+
disabled={editingBlocked}
|
|
204
228
|
class="grid w-9 shrink-0 place-items-center border-s border-control-border text-foreground-sub outline-none transition-colors hover:bg-destructive/7 hover:text-destructive focus-visible:text-destructive"
|
|
205
229
|
onpointerdown={(event) => event.preventDefault()}
|
|
206
230
|
onclick={remove}
|
|
@@ -220,6 +244,7 @@
|
|
|
220
244
|
<button
|
|
221
245
|
{...props}
|
|
222
246
|
type="button"
|
|
247
|
+
disabled={blocked}
|
|
223
248
|
class="grid w-9 shrink-0 place-items-center rounded-e-[3px] border-s border-control-border text-foreground-sub outline-none transition-colors hover:bg-background hover:text-foreground focus-visible:text-primary"
|
|
224
249
|
onpointerdown={(event) => event.preventDefault()}
|
|
225
250
|
onclick={(event) => browse(event, selectedCollection?.slug)}
|
|
@@ -247,6 +272,7 @@
|
|
|
247
272
|
>
|
|
248
273
|
<Combobox.Input
|
|
249
274
|
bind:ref={searchInput}
|
|
275
|
+
disabled={editingBlocked}
|
|
250
276
|
aria-label={runtime.i18n.t("fields:searchLabel", { label: searchLabel })}
|
|
251
277
|
placeholder={runtime.i18n.t("fields:search", {
|
|
252
278
|
label: searchLabel.toLocaleLowerCase(runtime.i18n.language),
|
|
@@ -291,6 +317,7 @@
|
|
|
291
317
|
<Combobox.Item
|
|
292
318
|
value={JSON.stringify([group.collection.slug, document.id])}
|
|
293
319
|
label={optionLabel}
|
|
320
|
+
disabled={editingBlocked}
|
|
294
321
|
class="flex w-full cursor-default items-center gap-[9px] rounded-[3px] px-2 py-1.5 text-start text-[12.5px] text-foreground-muted outline-none select-none data-[highlighted]:bg-control data-[selected]:text-foreground-strong"
|
|
295
322
|
>
|
|
296
323
|
{#snippet children({ selected })}
|
|
@@ -310,6 +337,7 @@
|
|
|
310
337
|
{/if}
|
|
311
338
|
<button
|
|
312
339
|
type="button"
|
|
340
|
+
disabled={editingBlocked}
|
|
313
341
|
class="mt-0.5 flex w-full items-center gap-1 px-2 py-1.5 text-start text-[11.5px] text-primary outline-none hover:text-primary-hover focus-visible:text-primary-hover"
|
|
314
342
|
onpointerdown={(event) => event.preventDefault()}
|
|
315
343
|
onclick={(event) => browse(event, group.collection.slug)}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type DateValue,
|
|
9
9
|
type Time,
|
|
10
10
|
} from "@internationalized/date";
|
|
11
|
-
import type {
|
|
11
|
+
import type { SchemaDateFormat } from "@riducms/protocol";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Converts a Ridu wire value into the timezone-free value used by Bits' date
|
|
@@ -16,13 +16,13 @@ import type { SchemaDatePickerAppearance } from "@riducms/protocol";
|
|
|
16
16
|
*/
|
|
17
17
|
export function datePickerValue(
|
|
18
18
|
value: unknown,
|
|
19
|
-
appearance:
|
|
19
|
+
appearance: SchemaDateFormat | undefined,
|
|
20
20
|
timeZone = getLocalTimeZone()
|
|
21
21
|
): DateValue | undefined {
|
|
22
22
|
const encoded = String(value ?? "");
|
|
23
23
|
if (encoded === "") return undefined;
|
|
24
24
|
try {
|
|
25
|
-
if (appearance === "
|
|
25
|
+
if (appearance === "date-time") {
|
|
26
26
|
return toCalendarDateTime(parseAbsolute(encoded, timeZone));
|
|
27
27
|
}
|
|
28
28
|
return parseDate(encoded.slice(0, 10));
|
|
@@ -34,11 +34,11 @@ export function datePickerValue(
|
|
|
34
34
|
/** Converts a Bits date selection to Ridu's canonical wire value. */
|
|
35
35
|
export function datePickerFormValue(
|
|
36
36
|
value: DateValue | undefined,
|
|
37
|
-
appearance:
|
|
37
|
+
appearance: SchemaDateFormat | undefined,
|
|
38
38
|
timeZone = getLocalTimeZone()
|
|
39
39
|
) {
|
|
40
40
|
if (value === undefined) return "";
|
|
41
|
-
if (appearance === "
|
|
41
|
+
if (appearance === "date-time") {
|
|
42
42
|
return toCalendarDateTime(value).toDate(timeZone).toISOString();
|
|
43
43
|
}
|
|
44
44
|
return toCalendarDate(value).toString();
|