smoodly 0.0.28 → 0.0.29
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/README.md +48 -8
- package/dist/admin/client-ops.js +1 -0
- package/dist/admin/drift-copy.d.ts +19 -0
- package/dist/admin/drift-copy.js +89 -0
- package/dist/admin/drift.d.ts +76 -0
- package/dist/admin/drift.js +264 -0
- package/dist/admin/editor/EditorView.js +67 -15
- package/dist/admin/editor/InlineRichtext.js +3 -1
- package/dist/admin/editor/Outline.d.ts +7 -0
- package/dist/admin/editor/Outline.js +8 -5
- package/dist/admin/editor/PageInspector.d.ts +6 -1
- package/dist/admin/editor/PageInspector.js +4 -2
- package/dist/admin/editor/PageSettings.js +26 -16
- package/dist/admin/editor/UnknownNode.d.ts +7 -0
- package/dist/admin/editor/UnknownNode.js +8 -0
- package/dist/admin/field-paths.d.ts +3 -0
- package/dist/admin/field-paths.js +26 -0
- package/dist/admin/forms/OrphanFields.d.ts +8 -0
- package/dist/admin/forms/OrphanFields.js +11 -0
- package/dist/admin/forms/RefPicker.js +6 -2
- package/dist/admin/forms/RichtextElementCard.js +9 -3
- package/dist/admin/forms/RichtextWidget.js +3 -1
- package/dist/admin/forms/TagsField.js +1 -1
- package/dist/admin/forms/richtext-form-context.d.ts +8 -0
- package/dist/admin/forms/richtext-form-context.js +5 -0
- package/dist/admin/health.d.ts +23 -0
- package/dist/admin/health.js +42 -0
- package/dist/admin/ops-impl.js +40 -9
- package/dist/admin/ops.d.ts +13 -0
- package/dist/admin/shell/AddRedirectModal.js +1 -1
- package/dist/admin/shell/ContentHealthPage.d.ts +10 -0
- package/dist/admin/shell/ContentHealthPage.js +37 -0
- package/dist/admin/shell/EntriesList.js +4 -4
- package/dist/admin/shell/EntryForm.js +20 -3
- package/dist/admin/shell/ErrorPane.d.ts +4 -2
- package/dist/admin/shell/ErrorPane.js +5 -4
- package/dist/admin/shell/GlobalForm.js +22 -2
- package/dist/admin/shell/PagesList.js +3 -3
- package/dist/admin/shell/SettingsView.js +6 -2
- package/dist/admin/shell/health-list.d.ts +27 -0
- package/dist/admin/shell/health-list.js +47 -0
- package/dist/admin/shell/settings.d.ts +4 -0
- package/dist/admin/shell/settings.js +5 -2
- package/dist/admin/tree-ops.d.ts +4 -0
- package/dist/admin/tree-ops.js +15 -1
- package/dist/admin/ui/Combobox.d.ts +8 -2
- package/dist/admin/ui/Combobox.js +48 -16
- package/dist/admin/ui/ConfirmDialog.js +3 -3
- package/dist/admin/ui/DriftNotice.d.ts +6 -0
- package/dist/admin/ui/DriftNotice.js +10 -0
- package/dist/admin/ui/combo-box.d.ts +13 -0
- package/dist/admin/ui/combo-box.js +8 -0
- package/dist/admin/ui/confirm.d.ts +8 -0
- package/dist/admin/ui/confirm.js +17 -4
- package/dist/admin/ui/theme.js +29 -3
- package/dist/admin/validate.js +15 -47
- package/dist/entry-store.d.ts +13 -0
- package/dist/entry-store.js +26 -3
- package/dist/index.d.ts +2 -2
- package/dist/render.js +7 -2
- package/dist/store.d.ts +9 -0
- package/dist/store.js +8 -0
- package/dist/supabase-entry-store.d.ts +2 -1
- package/dist/supabase-entry-store.js +27 -5
- package/dist/supabase-store.d.ts +2 -1
- package/dist/supabase-store.js +27 -0
- package/package.json +1 -1
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AdminRegistry } from "../serialize.ts";
|
|
2
|
+
import type { HealthReport } from "../health.ts";
|
|
3
|
+
export type HealthRow = {
|
|
4
|
+
key: string;
|
|
5
|
+
title: string;
|
|
6
|
+
kind: string;
|
|
7
|
+
locales: string[];
|
|
8
|
+
draft: boolean;
|
|
9
|
+
live: boolean;
|
|
10
|
+
href: string;
|
|
11
|
+
};
|
|
12
|
+
export type HealthGroup = {
|
|
13
|
+
key: string;
|
|
14
|
+
headline: string;
|
|
15
|
+
rows: HealthRow[];
|
|
16
|
+
};
|
|
17
|
+
export type HealthSections = {
|
|
18
|
+
broken: HealthGroup[];
|
|
19
|
+
orphan: HealthGroup[];
|
|
20
|
+
collections: {
|
|
21
|
+
name: string;
|
|
22
|
+
headline: string;
|
|
23
|
+
}[];
|
|
24
|
+
};
|
|
25
|
+
/** A group shows this many rows, then "Show all N". */
|
|
26
|
+
export declare const GROUP_PREVIEW = 10;
|
|
27
|
+
export declare function healthSections(report: HealthReport, registry: AdminRegistry): HealthSections;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { SEVERITY, groupKey } from "../drift.js";
|
|
2
|
+
import { driftHeadline } from "../drift-copy.js";
|
|
3
|
+
/** A group shows this many rows, then "Show all N". */
|
|
4
|
+
export const GROUP_PREVIEW = 10;
|
|
5
|
+
const kindLabel = (doc, registry) => doc.kind === "page" ? "Page" : doc.kind === "global" ? "Site" : registry.collections.find((c) => c.name === doc.collection)?.title ?? doc.collection ?? "";
|
|
6
|
+
const href = (doc, locale) => {
|
|
7
|
+
const q = `?locale=${encodeURIComponent(locale)}`;
|
|
8
|
+
if (doc.kind === "page")
|
|
9
|
+
return `/pages/${encodeURIComponent(doc.id)}${q}`;
|
|
10
|
+
if (doc.kind === "global")
|
|
11
|
+
return `/globals/${encodeURIComponent(doc.collection ?? "")}${q}`;
|
|
12
|
+
return `/${doc.collection}/${encodeURIComponent(doc.id)}${q}`;
|
|
13
|
+
};
|
|
14
|
+
export function healthSections(report, registry) {
|
|
15
|
+
const groups = new Map();
|
|
16
|
+
for (const doc of report.documents) {
|
|
17
|
+
for (const f of doc.findings) {
|
|
18
|
+
const key = groupKey(f);
|
|
19
|
+
let g = groups.get(key);
|
|
20
|
+
if (!g)
|
|
21
|
+
groups.set(key, (g = { severity: SEVERITY[f.kind], headline: driftHeadline(f, registry), rows: new Map() }));
|
|
22
|
+
const rowKey = `${doc.kind}:${doc.id}`;
|
|
23
|
+
const row = g.rows.get(rowKey);
|
|
24
|
+
if (!row) {
|
|
25
|
+
g.rows.set(rowKey, { key: rowKey, title: doc.title, kind: kindLabel(doc, registry), locales: [f.locale], draft: f.draft, live: f.live, href: href(doc, f.locale) });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
if (!row.locales.includes(f.locale))
|
|
29
|
+
row.locales.push(f.locale);
|
|
30
|
+
row.draft || (row.draft = f.draft);
|
|
31
|
+
row.live || (row.live = f.live);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const order = registry.locales.supported;
|
|
36
|
+
const out = (severity) => [...groups].filter(([, g]) => g.severity === severity)
|
|
37
|
+
.map(([key, g]) => ({ key, headline: g.headline, rows: [...g.rows.values()].map((r) => ({ ...r, locales: [...r.locales].sort((a, b) => order.indexOf(a) - order.indexOf(b)) })) }))
|
|
38
|
+
.sort((a, b) => b.rows.length - a.rows.length || a.headline.localeCompare(b.headline));
|
|
39
|
+
return {
|
|
40
|
+
broken: out("broken"),
|
|
41
|
+
orphan: out("orphan"),
|
|
42
|
+
collections: report.collections.map(({ name, count }) => ({
|
|
43
|
+
name,
|
|
44
|
+
headline: `Collection \`${name}\` no longer registered · ${count} ${count === 1 ? "entry" : "entries"}. Register it again or delete its ${count} ${count === 1 ? "entry" : "entries"}.`,
|
|
45
|
+
})),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -7,6 +7,10 @@ export declare const SETTINGS_PAGES: readonly [{
|
|
|
7
7
|
readonly id: "redirects";
|
|
8
8
|
readonly title: "Redirects";
|
|
9
9
|
readonly capability: "content.write";
|
|
10
|
+
}, {
|
|
11
|
+
readonly id: "health";
|
|
12
|
+
readonly title: "Content health";
|
|
13
|
+
readonly capability: "content.write";
|
|
10
14
|
}];
|
|
11
15
|
export type SettingsPage = (typeof SETTINGS_PAGES)[number];
|
|
12
16
|
export type SettingsPageId = SettingsPage["id"];
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// The Settings area's pages (spec 2026-09-25 §5; Roles and API Keys
|
|
2
|
-
// dropped 2026-09-25, Redirects added 2026-09-26 §6
|
|
2
|
+
// dropped 2026-09-25, Redirects added 2026-09-26 §6, Content health added
|
|
3
|
+
// 2026-09-27, editor safety §4): ids are URL
|
|
3
4
|
// segments under /settings, titles are what the secondary nav and each
|
|
4
5
|
// top bar show, and each page names the capability that opens it —
|
|
5
|
-
// Members for admins, Redirects for every member, as renaming is
|
|
6
|
+
// Members for admins, Redirects for every member, as renaming is,
|
|
7
|
+
// Content health for every member (editors do most of the fixing). Pure,
|
|
6
8
|
// so routing is testable without React.
|
|
7
9
|
import { can } from "../../permissions.js";
|
|
8
10
|
export const SETTINGS_PAGES = [
|
|
9
11
|
{ id: "members", title: "Members", capability: "settings" },
|
|
10
12
|
{ id: "redirects", title: "Redirects", capability: "content.write" },
|
|
13
|
+
{ id: "health", title: "Content health", capability: "content.write" },
|
|
11
14
|
];
|
|
12
15
|
/** The pages this role may open, in nav order. Every member has content.write, so the area is never empty. */
|
|
13
16
|
export const visibleSettingsPages = (role) => SETTINGS_PAGES.filter((p) => can(role, p.capability));
|
package/dist/admin/tree-ops.d.ts
CHANGED
|
@@ -36,3 +36,7 @@ export declare function fillLockedZones(tree: PageTree, policies: Record<string,
|
|
|
36
36
|
export declare function newNodeId(type: string): string;
|
|
37
37
|
/** A page-level value — the template's page fields or the SEO meta (spec 2026-09-10 page fields §5). */
|
|
38
38
|
export declare function patchPage(tree: PageTree, ns: "fields" | "meta", key: string, value: unknown): PageTree;
|
|
39
|
+
/** A zone the page type no longer declares, dropped from the draft (spec 2026-09-27 editor safety §5: Clear zone). */
|
|
40
|
+
export declare function clearZone(tree: PageTree, zone: string): PageTree;
|
|
41
|
+
/** A key removed from a node: `x` / `a.b` in fields, `settings.x`, `styles.x` (§5: an orphan's Clear). */
|
|
42
|
+
export declare function unsetNodePath(tree: PageTree, id: string, path: string): PageTree;
|
package/dist/admin/tree-ops.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { GLOBAL_NODE_TYPE } from "../global.js";
|
|
2
|
-
import { setAtPath } from "./field-paths.js";
|
|
2
|
+
import { setAtPath, unsetAtPath } from "./field-paths.js";
|
|
3
3
|
export function zoneOf(tree, id) {
|
|
4
4
|
for (const [zone, nodes] of Object.entries(tree.zones)) {
|
|
5
5
|
if (nodes.some((n) => n.id === id))
|
|
@@ -187,3 +187,17 @@ export function newNodeId(type) {
|
|
|
187
187
|
export function patchPage(tree, ns, key, value) {
|
|
188
188
|
return { ...tree, [ns]: { ...(tree[ns] ?? {}), [key]: value } };
|
|
189
189
|
}
|
|
190
|
+
/** A zone the page type no longer declares, dropped from the draft (spec 2026-09-27 editor safety §5: Clear zone). */
|
|
191
|
+
export function clearZone(tree, zone) {
|
|
192
|
+
const { [zone]: _gone, ...zones } = tree.zones;
|
|
193
|
+
return { ...tree, zones };
|
|
194
|
+
}
|
|
195
|
+
/** A key removed from a node: `x` / `a.b` in fields, `settings.x`, `styles.x` (§5: an orphan's Clear). */
|
|
196
|
+
export function unsetNodePath(tree, id, path) {
|
|
197
|
+
const zone = zoneOf(tree, id);
|
|
198
|
+
if (!zone)
|
|
199
|
+
return tree;
|
|
200
|
+
const ns = path.startsWith("settings.") ? "settings" : path.startsWith("styles.") ? "styles" : "fields";
|
|
201
|
+
const rest = ns === "fields" ? path : path.slice(ns.length + 1);
|
|
202
|
+
return withZone(tree, zone, tree.zones[zone].map((n) => (n.id === id ? { ...n, [ns]: unsetAtPath((n[ns] ?? {}), rest) } : n)));
|
|
203
|
+
}
|
|
@@ -18,6 +18,12 @@ export type ComboboxProps = {
|
|
|
18
18
|
createRow?: boolean;
|
|
19
19
|
/** Every query change passes through; the return value is the query kept. */
|
|
20
20
|
onQueryChange?(next: string): string;
|
|
21
|
+
/** A single select's pick: shown in the input until the editor types. */
|
|
22
|
+
selected?: string;
|
|
23
|
+
/** Inside the control, left of the chevron (a single ref's status, open and remove). */
|
|
24
|
+
suffix?: ReactNode;
|
|
25
|
+
/** A modifier for the floating list, which is not inside the root (the tags input's width). */
|
|
26
|
+
listClassName?: string;
|
|
21
27
|
/** Keys the owner wants first; return true when handled. */
|
|
22
28
|
onInputKey?(e: KeyboardEvent<HTMLInputElement>, query: string): boolean;
|
|
23
29
|
/** Escape, or focus leaving the control; `query` is what was typed and not picked. */
|
|
@@ -27,9 +33,9 @@ export type ComboboxProps = {
|
|
|
27
33
|
initialOpen?: boolean;
|
|
28
34
|
className?: string;
|
|
29
35
|
};
|
|
30
|
-
/** The vertical band
|
|
36
|
+
/** The vertical band a popover may occupy: the nearest clipping ancestor's box, cut to the viewport. */
|
|
31
37
|
export declare function clipBand(el: HTMLElement): {
|
|
32
38
|
top: number;
|
|
33
39
|
bottom: number;
|
|
34
40
|
};
|
|
35
|
-
export declare function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen, className, }: ComboboxProps): import("react").JSX.Element;
|
|
41
|
+
export declare function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen, className, selected, suffix, listClassName, }: ComboboxProps): import("react").JSX.Element;
|
|
@@ -4,13 +4,16 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
// picker and the tags input are both this. It knows nothing about entries
|
|
5
5
|
// or tags — options in, a pick out. Decisions live in ./combo-box.ts.
|
|
6
6
|
// Rows keep the input focused (mousedown is swallowed), so "focus left the
|
|
7
|
-
// control" is the one close signal besides Escape.
|
|
8
|
-
|
|
7
|
+
// control" is the one close signal besides Escape. The list is portaled
|
|
8
|
+
// into the admin wrapper and fixed to the viewport, so no scrolling form or
|
|
9
|
+
// modal body clips it (2026-09-27); static rendering keeps it inline.
|
|
10
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
11
|
+
import { createPortal } from "react-dom";
|
|
9
12
|
import { ChevronDown } from "lucide-react";
|
|
10
13
|
import { cx } from "./primitives.js";
|
|
11
|
-
import { filterOptions,
|
|
14
|
+
import { filterOptions, listPlacement, showsCreate, stepActive } from "./combo-box.js";
|
|
12
15
|
const keepFocus = (e) => e.preventDefault();
|
|
13
|
-
/** The vertical band
|
|
16
|
+
/** The vertical band a popover may occupy: the nearest clipping ancestor's box, cut to the viewport. */
|
|
14
17
|
export function clipBand(el) {
|
|
15
18
|
let top = 0;
|
|
16
19
|
let bottom = window.innerHeight;
|
|
@@ -24,29 +27,53 @@ export function clipBand(el) {
|
|
|
24
27
|
}
|
|
25
28
|
return { top, bottom };
|
|
26
29
|
}
|
|
27
|
-
export function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen = false, className, }) {
|
|
30
|
+
export function Combobox({ options, onPick, label, placeholder, disabled, keepOpen, emptyText, renderOption, onSubmit, createRow, onQueryChange, onInputKey, onClose, autoFocus, initialOpen = false, className, selected, suffix, listClassName, }) {
|
|
28
31
|
const rest = onSubmit ? -1 : 0; // the active row a fresh query starts from
|
|
29
|
-
|
|
32
|
+
// null: nothing typed since the last pick, so the input shows `selected`.
|
|
33
|
+
const [typed, setTyped] = useState(null);
|
|
34
|
+
const query = typed ?? "";
|
|
30
35
|
const [open, setOpen] = useState(initialOpen);
|
|
31
36
|
const [active, setActive] = useState(rest);
|
|
32
37
|
const root = useRef(null);
|
|
33
38
|
const list = useRef(null);
|
|
34
|
-
const [
|
|
39
|
+
const [host, setHost] = useState(null);
|
|
40
|
+
const [place, setPlace] = useState(null);
|
|
35
41
|
const id = useId();
|
|
42
|
+
useLayoutEffect(() => {
|
|
43
|
+
const el = root.current;
|
|
44
|
+
if (el)
|
|
45
|
+
setHost(el.closest(".sm-admin") ?? el.ownerDocument.body);
|
|
46
|
+
}, []);
|
|
36
47
|
const shown = filterOptions(options, query);
|
|
37
48
|
const create = onSubmit !== undefined && createRow === true && showsCreate(options, query);
|
|
38
49
|
const count = shown.length + (create ? 1 : 0);
|
|
39
50
|
// Free text with nothing to offer shows no popover; a picker says why it is empty.
|
|
40
51
|
const popover = open && !disabled && (count > 0 || !onSubmit);
|
|
41
|
-
//
|
|
52
|
+
// Under the input, or above it when the viewport has no room below; it follows the input on scroll.
|
|
53
|
+
const measure = useCallback(() => {
|
|
54
|
+
const el = root.current;
|
|
55
|
+
if (!el || !list.current)
|
|
56
|
+
return;
|
|
57
|
+
const box = el.getBoundingClientRect();
|
|
58
|
+
setPlace(listPlacement(box, el.ownerDocument.defaultView?.innerHeight ?? 0, list.current.offsetHeight));
|
|
59
|
+
}, []);
|
|
42
60
|
useLayoutEffect(() => {
|
|
43
|
-
if (
|
|
61
|
+
if (popover && host)
|
|
62
|
+
measure();
|
|
63
|
+
else
|
|
64
|
+
setPlace(null);
|
|
65
|
+
}, [popover, host, count, measure]);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
const view = root.current?.ownerDocument.defaultView;
|
|
68
|
+
if (!popover || !view)
|
|
44
69
|
return;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
70
|
+
const onScroll = (e) => { if (e.target !== list.current)
|
|
71
|
+
measure(); };
|
|
72
|
+
view.addEventListener("scroll", onScroll, true);
|
|
73
|
+
view.addEventListener("resize", measure);
|
|
74
|
+
return () => { view.removeEventListener("scroll", onScroll, true); view.removeEventListener("resize", measure); };
|
|
75
|
+
}, [popover, measure]);
|
|
76
|
+
const reset = () => { setTyped(null); setActive(rest); };
|
|
50
77
|
const close = (reason) => {
|
|
51
78
|
setOpen(false);
|
|
52
79
|
onClose?.(query, reason);
|
|
@@ -84,6 +111,11 @@ export function Combobox({ options, onPick, label, placeholder, disabled, keepOp
|
|
|
84
111
|
close("escape");
|
|
85
112
|
}
|
|
86
113
|
};
|
|
87
|
-
|
|
88
|
-
|
|
114
|
+
const floating = host
|
|
115
|
+
? { left: place?.left, top: place?.top, bottom: place?.bottom, ["--combo-w"]: place ? `${place.width}px` : undefined, visibility: place ? undefined : "hidden" }
|
|
116
|
+
: undefined;
|
|
117
|
+
const listbox = popover && (_jsxs("div", { ref: list, id: `${id}-list`, role: "listbox", "aria-label": label, onMouseDown: keepFocus, className: cx("sm-picker sm-combo__list", host && "sm-combo__list--floating", listClassName), style: floating, children: [count === 0 && _jsx("div", { className: "sm-combo__empty", children: options.length === 0 ? emptyText ?? "No matches" : "No matches" }), shown.map((o, i) => (_jsx("button", { id: `${id}-opt-${i}`, type: "button", role: "option", "aria-selected": i === active, className: cx("sm-picker__item", i === active && "sm-picker__item--active"), onMouseDown: keepFocus, onClick: () => pick(i), children: renderOption ? renderOption(o) : o.label }, o.value))), create && (_jsxs("button", { id: `${id}-opt-${shown.length}`, type: "button", role: "option", "aria-selected": active === shown.length, className: cx("sm-picker__item", active === shown.length && "sm-picker__item--active"), onMouseDown: keepFocus, onClick: () => pick(shown.length), children: ["Create \u201C", query.trim(), "\u201D"] }))] }));
|
|
118
|
+
return (_jsxs("div", { ref: root, className: cx("sm-combo", suffix != null && "sm-combo--suffix", className), children: [_jsx("input", { className: "sm-input", role: "combobox", "aria-label": label, "aria-autocomplete": "list", "aria-expanded": popover, "aria-controls": `${id}-list`, "aria-activedescendant": popover && active >= 0 ? `${id}-opt-${active}` : undefined, placeholder: placeholder, disabled: disabled, autoFocus: autoFocus, value: typed ?? selected ?? "", onFocus: (e) => { setOpen(true); if (typed === null && selected)
|
|
119
|
+
e.currentTarget.select(); }, onClick: () => setOpen(true), onChange: (e) => { setTyped(onQueryChange ? onQueryChange(e.target.value) : e.target.value); setActive(rest); setOpen(true); }, onKeyDown: onKeyDown, onBlur: (e) => { if (open && !root.current?.contains(e.relatedTarget))
|
|
120
|
+
close("blur"); } }), suffix != null && _jsx("span", { className: "sm-combo__suffix", children: suffix }), _jsx(ChevronDown, { className: "sm-combo__chevron", size: 14, strokeWidth: 1.75, "aria-hidden": "true" }), listbox && host ? createPortal(listbox, host) : listbox] }));
|
|
89
121
|
}
|
|
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
5
5
|
// the consequence, an optional context chip naming the subject, then
|
|
6
6
|
// Cancel and the action verb. Focus opens on Cancel for a destructive
|
|
7
7
|
// action and on the primary otherwise, so Enter runs the primary only
|
|
8
|
-
// when it is safe.
|
|
8
|
+
// when it is safe. A `single` dialog (notify) has the primary alone.
|
|
9
9
|
//
|
|
10
10
|
// <ConfirmHost/> renders the pending question from ui/confirm.ts, once,
|
|
11
11
|
// from the shell; the dialog itself is a plain component so a test can
|
|
@@ -15,12 +15,12 @@ import { DialogCard } from "./Dialog.js";
|
|
|
15
15
|
import { Button } from "./primitives.js";
|
|
16
16
|
import { pendingConfirm, subscribeConfirm } from "./confirm.js";
|
|
17
17
|
export function ConfirmDialog({ options, onAnswer }) {
|
|
18
|
-
const { title, body, confirmLabel, cancelLabel = "Cancel", destructive = false, subject, option } = options;
|
|
18
|
+
const { title, body, confirmLabel, cancelLabel = "Cancel", destructive = false, subject, option, single = false } = options;
|
|
19
19
|
const [checked, setChecked] = useState(option?.checked ?? false);
|
|
20
20
|
const uid = useId();
|
|
21
21
|
const bodyId = `sm-confirm-body${uid}`;
|
|
22
22
|
const cancel = () => onAnswer(false);
|
|
23
|
-
return (_jsxs(DialogCard, { title: title, role: "alertdialog", describedBy: body ? bodyId : undefined, onClose: cancel, className: "sm-confirm", actions: _jsxs(_Fragment, { children: [_jsx(Button, { variant: "secondary", autoFocus: destructive, onClick: cancel, children: cancelLabel }), _jsx(Button, { variant: destructive ? "destructive" : "primary", autoFocus: !destructive, onClick: () => onAnswer(true, checked), children: confirmLabel })] }), children: [body && _jsx("p", { className: "sm-dialog__body", id: bodyId, children: body }), option && (_jsxs("label", { className: "sm-choice sm-confirm__option", children: [_jsx("input", { type: "checkbox", checked: checked, onChange: (e) => setChecked(e.target.checked) }), _jsx("span", { children: option.label })] })), subject && (_jsxs("div", { className: "sm-dialog__context", children: [_jsx("strong", { children: subject.name }), subject.path && _jsx("code", { children: subject.path })] }))] }));
|
|
23
|
+
return (_jsxs(DialogCard, { title: title, role: "alertdialog", describedBy: body ? bodyId : undefined, onClose: cancel, className: "sm-confirm", actions: _jsxs(_Fragment, { children: [!single && _jsx(Button, { variant: "secondary", autoFocus: destructive, onClick: cancel, children: cancelLabel }), _jsx(Button, { variant: destructive ? "destructive" : "primary", autoFocus: !destructive, onClick: () => onAnswer(true, checked), children: confirmLabel })] }), children: [body && _jsx("p", { className: "sm-dialog__body", id: bodyId, children: body }), option && (_jsxs("label", { className: "sm-choice sm-confirm__option", children: [_jsx("input", { type: "checkbox", checked: checked, onChange: (e) => setChecked(e.target.checked) }), _jsx("span", { children: option.label })] })), subject && (_jsxs("div", { className: "sm-dialog__context", children: [_jsx("strong", { children: subject.name }), subject.path && _jsx("code", { children: subject.path })] }))] }));
|
|
24
24
|
}
|
|
25
25
|
/** Renders the pending question, once, from the shell. */
|
|
26
26
|
export function ConfirmHost() {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { driftNotice } from "../drift-copy.js";
|
|
4
|
+
import { Banner } from "./primitives.js";
|
|
5
|
+
export function DriftNotice({ draft, live }) {
|
|
6
|
+
const { summary, liveOnly, sentences } = driftNotice(draft, live);
|
|
7
|
+
if (!summary && !liveOnly && sentences.length === 0)
|
|
8
|
+
return null;
|
|
9
|
+
return (_jsx("div", { className: "sm-drift", "data-testid": "drift-notice", children: _jsxs(Banner, { tone: "warn", children: [summary && _jsx("p", { style: { margin: 0 }, children: summary }), liveOnly && _jsx("p", { style: { margin: 0 }, children: liveOnly }), sentences.map((s) => _jsx("p", { style: { margin: 0 }, children: s }, s))] }) }));
|
|
10
|
+
}
|
|
@@ -14,3 +14,16 @@ export declare const opensUp: (room: {
|
|
|
14
14
|
above: number;
|
|
15
15
|
height: number;
|
|
16
16
|
}) => boolean;
|
|
17
|
+
/** Where the floating list sits (fixed, in viewport pixels): under the input at its width,
|
|
18
|
+
* or above it, anchored by its bottom edge, when opensUp says so. */
|
|
19
|
+
export declare function listPlacement(box: {
|
|
20
|
+
top: number;
|
|
21
|
+
bottom: number;
|
|
22
|
+
left: number;
|
|
23
|
+
width: number;
|
|
24
|
+
}, viewport: number, height: number): {
|
|
25
|
+
left: number;
|
|
26
|
+
width: number;
|
|
27
|
+
top?: number;
|
|
28
|
+
bottom?: number;
|
|
29
|
+
};
|
|
@@ -19,3 +19,11 @@ export function stepActive(active, count, dir) {
|
|
|
19
19
|
}
|
|
20
20
|
/** Whether the list hangs above the input: only when it does not fit below and there is more room above. */
|
|
21
21
|
export const opensUp = (room) => room.height > room.below && room.above > room.below;
|
|
22
|
+
/** Where the floating list sits (fixed, in viewport pixels): under the input at its width,
|
|
23
|
+
* or above it, anchored by its bottom edge, when opensUp says so. */
|
|
24
|
+
export function listPlacement(box, viewport, height) {
|
|
25
|
+
const at = { left: box.left, width: box.width };
|
|
26
|
+
return opensUp({ below: viewport - box.bottom - 4, above: box.top - 4, height })
|
|
27
|
+
? { ...at, bottom: viewport - box.top + 4 }
|
|
28
|
+
: { ...at, top: box.bottom + 4 };
|
|
29
|
+
}
|
|
@@ -22,6 +22,8 @@ export type ConfirmOptions = {
|
|
|
22
22
|
label: string;
|
|
23
23
|
checked: boolean;
|
|
24
24
|
};
|
|
25
|
+
/** One OK button, no Cancel — notify's dialog (spec 2026-09-27 editor safety §7). */
|
|
26
|
+
single?: boolean;
|
|
25
27
|
};
|
|
26
28
|
export type ConfirmAnswer = {
|
|
27
29
|
yes: boolean;
|
|
@@ -44,3 +46,9 @@ export declare const pendingConfirm: () => ConfirmRequest | null;
|
|
|
44
46
|
export declare function subscribeConfirm(fn: () => void): () => void;
|
|
45
47
|
/** Test seam: drop every pending question unanswered. */
|
|
46
48
|
export declare function resetConfirms(): void;
|
|
49
|
+
/** Tell, don't ask (spec 2026-09-27 editor safety §7): a one-button dialog in place of window.alert,
|
|
50
|
+
* resolved once it is dismissed; with no host mounted, the native alert. */
|
|
51
|
+
export declare function notify(options: {
|
|
52
|
+
title: string;
|
|
53
|
+
message: string;
|
|
54
|
+
}): Promise<void>;
|
package/dist/admin/ui/confirm.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
// One question at a time; a second request waits for the first answer.
|
|
9
9
|
// Without a mounted host (a test, a stray render outside the shell) the
|
|
10
10
|
// question falls back to window.confirm so no caller can hang.
|
|
11
|
+
//
|
|
12
|
+
// `notify` tells rather than asks (spec 2026-09-27 editor safety §7): the
|
|
13
|
+
// same host, one OK button, window.alert as its no-host fallback.
|
|
11
14
|
/** The Discard changes question, shared by the page editor and the entry and global forms (publish flow §4). */
|
|
12
15
|
export const DISCARD_CONFIRM = {
|
|
13
16
|
title: "Discard unsaved changes?",
|
|
@@ -18,7 +21,7 @@ export const DISCARD_CONFIRM = {
|
|
|
18
21
|
const listeners = new Set();
|
|
19
22
|
let queue = [];
|
|
20
23
|
let seq = 0;
|
|
21
|
-
const
|
|
24
|
+
const emit = () => { for (const fn of listeners)
|
|
22
25
|
fn(); };
|
|
23
26
|
/** Ask; resolves with the answer and the option's final state once the dialog closes. */
|
|
24
27
|
export function confirmWithOption(options) {
|
|
@@ -37,12 +40,12 @@ export function confirmWithOption(options) {
|
|
|
37
40
|
if (!queue.some((r) => r.id === id))
|
|
38
41
|
return; // answered already
|
|
39
42
|
queue = queue.filter((r) => r.id !== id);
|
|
40
|
-
|
|
43
|
+
emit();
|
|
41
44
|
resolve({ yes, checked });
|
|
42
45
|
},
|
|
43
46
|
};
|
|
44
47
|
queue = [...queue, request];
|
|
45
|
-
|
|
48
|
+
emit();
|
|
46
49
|
});
|
|
47
50
|
}
|
|
48
51
|
/** Ask; resolves with the answer once the dialog closes. */
|
|
@@ -57,5 +60,15 @@ export function subscribeConfirm(fn) {
|
|
|
57
60
|
/** Test seam: drop every pending question unanswered. */
|
|
58
61
|
export function resetConfirms() {
|
|
59
62
|
queue = [];
|
|
60
|
-
|
|
63
|
+
emit();
|
|
64
|
+
}
|
|
65
|
+
/** Tell, don't ask (spec 2026-09-27 editor safety §7): a one-button dialog in place of window.alert,
|
|
66
|
+
* resolved once it is dismissed; with no host mounted, the native alert. */
|
|
67
|
+
export function notify(options) {
|
|
68
|
+
if (listeners.size === 0) {
|
|
69
|
+
if (typeof window !== "undefined" && typeof window.alert === "function")
|
|
70
|
+
window.alert(`${options.title}\n\n${options.message}`);
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
}
|
|
73
|
+
return confirmWithOption({ title: options.title, body: options.message, confirmLabel: "OK", single: true }).then(() => undefined);
|
|
61
74
|
}
|
package/dist/admin/ui/theme.js
CHANGED
|
@@ -671,6 +671,13 @@ body:has(.sm-admin){margin:0}
|
|
|
671
671
|
.sm-cell--title{font-weight:500}
|
|
672
672
|
.sm-cell--grip{overflow:visible;display:flex;align-items:center;margin-left:-4px}
|
|
673
673
|
.sm-empty{padding:28px 8px;color:var(--text-muted)}
|
|
674
|
+
/* Settings › Content health (spec 2026-09-27 editor safety §4): headed sections of grouped rows. */
|
|
675
|
+
.sm-health section+section{margin-top:24px}
|
|
676
|
+
.sm-health__section{font-size:var(--text-sm);font-weight:600;letter-spacing:var(--caps-tracking);text-transform:var(--caps-transform);color:var(--text-muted);margin:0 0 8px;padding:0 8px}
|
|
677
|
+
.sm-health__group{display:flex;flex-direction:column;gap:2px;margin-bottom:16px}
|
|
678
|
+
.sm-health__group > .sm-btn{align-self:flex-start;margin-top:4px}
|
|
679
|
+
.sm-health__headline{display:flex;justify-content:space-between;gap:12px;font-weight:600;padding:6px 8px;margin:0}
|
|
680
|
+
.sm-health__row{grid-template-columns:minmax(160px,1fr) 140px 90px 120px 70px}
|
|
674
681
|
/* ── drag and drop (spec 2026-09-09 §7) ─────────────────────────────── */
|
|
675
682
|
/* The grip is faint until its row is hovered or it has focus. */
|
|
676
683
|
.sm-grip{
|
|
@@ -795,6 +802,12 @@ body:has(.sm-admin){margin:0}
|
|
|
795
802
|
font-family:var(--font-sans);font-size:var(--text-xs);font-weight:600;letter-spacing:var(--tracking-tag);text-transform:uppercase;color:var(--text-muted);
|
|
796
803
|
}
|
|
797
804
|
.sm-stale{padding:8px 20px 0}
|
|
805
|
+
/* Drift (spec 2026-09-27 editor safety §5): the document notice under the stale line, the orphan group at a form's foot. */
|
|
806
|
+
.sm-drift{padding:8px 20px 0}
|
|
807
|
+
.sm-orphans{margin-top:16px;border-top:1px solid var(--line);padding-top:12px}
|
|
808
|
+
.sm-orphans__head{cursor:pointer;font-size:var(--text-sm);font-weight:600;letter-spacing:var(--caps-tracking);text-transform:var(--caps-transform);color:var(--text-muted)}
|
|
809
|
+
.sm-orphans__row{display:grid;grid-template-columns:minmax(80px,auto) 1fr auto auto;gap:8px;align-items:center;margin-top:8px}
|
|
810
|
+
.sm-orphans__value{color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--font-mono);font-size:var(--text-sm)}
|
|
798
811
|
.sm-lock{display:inline-flex;align-items:center;margin-left:6px;color:var(--text-muted);vertical-align:middle}
|
|
799
812
|
.sm-presence{margin:0;padding:6px 20px 0;font-size:13px;color:var(--text-muted)}
|
|
800
813
|
.sm-banner{
|
|
@@ -922,6 +935,14 @@ body:has(.sm-admin){margin:0}
|
|
|
922
935
|
.sm-outline-row__label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
|
|
923
936
|
.sm-outline-row__note{font-size:var(--text-sm);font-weight:500;flex:none;letter-spacing:var(--caps-tracking);text-transform:var(--caps-transform)}
|
|
924
937
|
.sm-outline-row__add{color:var(--accent);font-weight:500;flex:none}
|
|
938
|
+
/* Drift marks (spec 2026-09-27 editor safety §5): a stripe inside the row's left edge, red for broken, muted for left over. */
|
|
939
|
+
.sm-outline-row--broken{box-shadow:inset 3px 0 0 var(--danger)}
|
|
940
|
+
.sm-outline-row--orphan{box-shadow:inset 3px 0 0 var(--line-dashed)}
|
|
941
|
+
.sm-outline-note{margin:0;font-size:var(--text-sm);color:var(--danger)}
|
|
942
|
+
/* A removed zone's one row wraps its sentence rather than cutting it: the inspector is narrow. */
|
|
943
|
+
.sm-outline-row--removed{height:auto;min-height:var(--outline-row-h);padding-block:6px}
|
|
944
|
+
.sm-outline-row--removed .sm-outline-row__label{white-space:normal}
|
|
945
|
+
.sm-rtcard--broken{box-shadow:inset 3px 0 0 var(--danger)}
|
|
925
946
|
/* A row with a grip: the grip takes the left padding's place and the label follows it. */
|
|
926
947
|
.sm-outline-row--grip{padding-left:2px}
|
|
927
948
|
.sm-outline-row--grip .sm-outline-row__label{flex:1}
|
|
@@ -943,12 +964,17 @@ body:has(.sm-admin){margin:0}
|
|
|
943
964
|
width:26px;height:26px;flex:none;border-radius:var(--radius-sm);background:var(--accent-soft);
|
|
944
965
|
color:var(--accent);display:grid;place-items:center;font-size:13px;
|
|
945
966
|
}
|
|
946
|
-
/* Combobox (spec 2026-09-17 §1): the input owns the width, the list hangs under it
|
|
967
|
+
/* Combobox (spec 2026-09-17 §1): the input owns the width, the list hangs under it —
|
|
968
|
+
portaled and fixed at the input's width (--combo-w), above modals and below
|
|
969
|
+
the confirm scrim (2026-09-27); inline under the input in static markup. */
|
|
947
970
|
.sm-combo{position:relative}
|
|
948
971
|
.sm-combo .sm-input{width:100%;padding-right:30px}
|
|
972
|
+
.sm-combo--suffix .sm-input{padding-right:96px}
|
|
973
|
+
.sm-combo__suffix{position:absolute;right:28px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:4px}
|
|
974
|
+
.sm-combo__suffix a.sm-iconbtn{text-decoration:none}
|
|
949
975
|
.sm-combo__chevron{position:absolute;right:10px;top:50%;transform:translateY(-50%);pointer-events:none;color:var(--text-muted)}
|
|
950
976
|
.sm-combo__list{position:absolute;top:calc(100% + 4px);left:0;right:0;width:auto;max-height:280px;overflow-y:auto;z-index:60}
|
|
951
|
-
.sm-combo__list--
|
|
977
|
+
.sm-combo__list--floating{position:fixed;top:auto;right:auto;width:var(--combo-w,300px);z-index:75}
|
|
952
978
|
.sm-combo__empty{padding:8px 10px;color:var(--text-muted);font-size:var(--text-sm)}
|
|
953
979
|
/* Reference picker (spec 2026-09-17 §2): the picked entries under the search input. */
|
|
954
980
|
.sm-refpicker{display:flex;flex-direction:column;gap:6px;min-width:0}
|
|
@@ -978,7 +1004,7 @@ body:has(.sm-admin){margin:0}
|
|
|
978
1004
|
.sm-combo--tag{width:180px}
|
|
979
1005
|
.sm-combo--tag .sm-input{height:var(--control-h-sm);padding-right:10px}
|
|
980
1006
|
.sm-combo--tag .sm-combo__chevron{display:none}
|
|
981
|
-
.sm-
|
|
1007
|
+
.sm-combo__list.sm-combo__list--tag{right:auto;width:240px}
|
|
982
1008
|
/* ── date and datetime pickers (spec 2026-09-25) ──────────────────────
|
|
983
1009
|
The text box with a calendar button as its suffix; for datetime an hour
|
|
984
1010
|
and a minute select beside it; the popover a month grid on the menu
|
package/dist/admin/validate.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// A field whose `.hidden()` condition holds is skipped entirely — a hidden
|
|
6
6
|
// required field is not required (spec 2026-09-14 §12); the same isHidden
|
|
7
7
|
// the form uses decides, over the same scope.
|
|
8
|
-
import { optionValues } from "../fields.js";
|
|
9
8
|
import { isHidden } from "../conditions.js";
|
|
10
9
|
import { cssProblem } from "../css.js";
|
|
11
10
|
import { normalizeTag, sameTag } from "../tags.js";
|
|
@@ -13,6 +12,7 @@ import { hasContent } from "./richtext-doc.js";
|
|
|
13
12
|
import { ELEMENT_NODE, richtextBlocks } from "../richtext-walk.js";
|
|
14
13
|
import { rowsProblems } from "../custom/rows.js";
|
|
15
14
|
import { isCalendarDay, isInstant } from "./forms/date-picker.js";
|
|
15
|
+
import { fits, misfitMessage } from "./drift.js";
|
|
16
16
|
const subfields = (d) => d.fields && typeof d.fields === "object" ? d.fields : {};
|
|
17
17
|
const itemOf = (d) => d.item && typeof d.item === "object" ? d.item : undefined;
|
|
18
18
|
const isEmpty = (v) => v === undefined || v === null || v === "" || (Array.isArray(v) && v.length === 0);
|
|
@@ -32,6 +32,11 @@ function validateValue(path, value, d, errors, elements, scope) {
|
|
|
32
32
|
errors.push({ field: path, message: "Required." });
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
+
// The one type check (spec 2026-09-27 editor safety §2): drift and publish share it.
|
|
36
|
+
if (!fits(d, value)) {
|
|
37
|
+
errors.push({ field: path, message: misfitMessage(d, value) });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
35
40
|
if (d.type === "richtext") {
|
|
36
41
|
if (!hasContent(value)) {
|
|
37
42
|
if (!mayBeAbsent(d))
|
|
@@ -58,26 +63,20 @@ function validateValue(path, value, d, errors, elements, scope) {
|
|
|
58
63
|
}
|
|
59
64
|
// Dates (spec 2026-09-25 date pickers §2): the stored formats exactly, and a day the calendar has.
|
|
60
65
|
if (d.type === "date") {
|
|
61
|
-
if (
|
|
66
|
+
if (!isCalendarDay(value))
|
|
62
67
|
errors.push({ field: path, message: "Not a date (2026-09-02)." });
|
|
63
68
|
return;
|
|
64
69
|
}
|
|
65
70
|
if (d.type === "datetime") {
|
|
66
|
-
if (
|
|
71
|
+
if (!isInstant(value))
|
|
67
72
|
errors.push({ field: path, message: "Not a date and time." });
|
|
68
73
|
return;
|
|
69
74
|
}
|
|
70
|
-
if (d.type === "image" || d.type === "video" || d.type === "file")
|
|
71
|
-
if (typeof value !== "object" || Array.isArray(value) || typeof value.asset !== "string") {
|
|
72
|
-
errors.push({ field: path, message: "Must be an uploaded file." });
|
|
73
|
-
}
|
|
75
|
+
if (d.type === "image" || d.type === "video" || d.type === "file")
|
|
74
76
|
return;
|
|
75
|
-
}
|
|
76
77
|
if (d.type === "list") {
|
|
77
|
-
if (!Array.isArray(value))
|
|
78
|
-
errors.push({ field: path, message: "Must be a list." });
|
|
78
|
+
if (!Array.isArray(value))
|
|
79
79
|
return;
|
|
80
|
-
}
|
|
81
80
|
if (typeof d.min === "number" && value.length < d.min)
|
|
82
81
|
errors.push({ field: path, message: `At least ${plural(d.min, "item")}.` });
|
|
83
82
|
if (typeof d.max === "number" && value.length > d.max)
|
|
@@ -89,37 +88,19 @@ function validateValue(path, value, d, errors, elements, scope) {
|
|
|
89
88
|
return;
|
|
90
89
|
}
|
|
91
90
|
if (d.type === "object") {
|
|
92
|
-
if (typeof value !== "object" || Array.isArray(value)) {
|
|
93
|
-
errors.push({ field: path, message: "Must be an object." });
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
91
|
validateMap(path, value, subfields(d), errors, elements, { ...scope, self: value });
|
|
97
92
|
return;
|
|
98
93
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
errors.push({ field: path, message: "Must be on or off." });
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
if (d.type === "select" || d.type === "radio") {
|
|
105
|
-
const allowed = optionValues(d.options);
|
|
106
|
-
if (allowed.length > 0 && !allowed.includes(value)) {
|
|
107
|
-
errors.push({ field: path, message: `Must be one of: ${allowed.join(", ")}.` });
|
|
108
|
-
}
|
|
94
|
+
// The shape of these is fits' (above); what is left is the count.
|
|
95
|
+
if (d.type === "boolean" || d.type === "select" || d.type === "radio" || d.type === "checkboxes")
|
|
109
96
|
return;
|
|
110
|
-
}
|
|
111
97
|
// Refs (spec 2026-09-17 §3): the shape and the count. Whether a target exists
|
|
112
98
|
// and is published is publish integrity's question (store.ts), not this one's.
|
|
113
|
-
if (d.type === "ref")
|
|
114
|
-
if (typeof value !== "string")
|
|
115
|
-
errors.push({ field: path, message: "Must be an entry." });
|
|
99
|
+
if (d.type === "ref")
|
|
116
100
|
return;
|
|
117
|
-
}
|
|
118
101
|
if (d.type === "refList") {
|
|
119
|
-
if (!Array.isArray(value)
|
|
120
|
-
errors.push({ field: path, message: "Must be a list of entries." });
|
|
102
|
+
if (!Array.isArray(value))
|
|
121
103
|
return;
|
|
122
|
-
}
|
|
123
104
|
if (typeof d.min === "number" && value.length < d.min)
|
|
124
105
|
errors.push({ field: path, message: `At least ${plural(d.min, "item")}.` });
|
|
125
106
|
if (typeof d.max === "number" && value.length > d.max)
|
|
@@ -130,10 +111,8 @@ function validateValue(path, value, d, errors, elements, scope) {
|
|
|
130
111
|
return;
|
|
131
112
|
}
|
|
132
113
|
if (d.type === "tags") {
|
|
133
|
-
if (!Array.isArray(value))
|
|
134
|
-
errors.push({ field: path, message: "Must be a list of tags." });
|
|
114
|
+
if (!Array.isArray(value))
|
|
135
115
|
return;
|
|
136
|
-
}
|
|
137
116
|
if (typeof d.min === "number" && value.length < d.min)
|
|
138
117
|
errors.push({ field: path, message: `At least ${plural(d.min, "item")}.` });
|
|
139
118
|
if (typeof d.max === "number" && value.length > d.max)
|
|
@@ -146,17 +125,6 @@ function validateValue(path, value, d, errors, elements, scope) {
|
|
|
146
125
|
});
|
|
147
126
|
return;
|
|
148
127
|
}
|
|
149
|
-
if (d.type === "checkboxes") {
|
|
150
|
-
if (!Array.isArray(value)) {
|
|
151
|
-
errors.push({ field: path, message: "Must be a list." });
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
const allowed = optionValues(d.options);
|
|
155
|
-
if (value.some((v) => !allowed.includes(v))) {
|
|
156
|
-
errors.push({ field: path, message: `Must be one of: ${allowed.join(", ")}.` });
|
|
157
|
-
}
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
128
|
if (typeof d.max === "number" && typeof value === "string" && value.length > d.max) {
|
|
161
129
|
errors.push({ field: path, message: `Must be at most ${d.max} characters.` });
|
|
162
130
|
}
|