smoodly 0.0.28 → 0.0.30
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 +123 -17
- 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/CanvasWarning.d.ts +5 -0
- package/dist/admin/editor/CanvasWarning.js +10 -0
- package/dist/admin/editor/EditorView.js +113 -26
- 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/editor/canvas-diagnosis.d.ts +49 -0
- package/dist/admin/editor/canvas-diagnosis.js +106 -0
- package/dist/admin/editor/crash-copy.d.ts +7 -0
- package/dist/admin/editor/crash-copy.js +13 -0
- package/dist/admin/editor/protocol.d.ts +2 -0
- package/dist/admin/editor/useCanvasBridge.d.ts +3 -0
- package/dist/admin/editor/useCanvasBridge.js +14 -7
- package/dist/admin/editor/useCanvasDiagnosis.d.ts +7 -0
- package/dist/admin/editor/useCanvasDiagnosis.js +24 -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/index.d.ts +1 -0
- package/dist/admin/next/bridge.js +4 -2
- package/dist/admin/next/index.d.ts +0 -1
- package/dist/admin/next/index.js +6 -1
- 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/ActionMenu.d.ts +20 -0
- package/dist/admin/ui/ActionMenu.js +33 -7
- 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/custom/CustomSection.d.ts +1 -0
- package/dist/custom/CustomSection.js +4 -1
- package/dist/entry-store.d.ts +13 -0
- package/dist/entry-store.js +26 -3
- package/dist/index.d.ts +2 -2
- package/dist/marks.d.ts +12 -0
- package/dist/marks.js +14 -0
- package/dist/next/canvas-boundary.d.ts +16 -0
- package/dist/next/canvas-boundary.js +25 -0
- package/dist/next/page-renderer.d.ts +3 -1
- package/dist/next/page-renderer.js +33 -4
- package/dist/pairing.js +10 -3
- package/dist/render.d.ts +12 -1
- package/dist/render.js +15 -4
- package/dist/section-crash.d.ts +11 -0
- package/dist/section-crash.js +16 -0
- package/dist/section-guard.d.ts +27 -0
- package/dist/section-guard.js +41 -0
- 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
|
@@ -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
|
+
}
|
|
@@ -4,6 +4,25 @@ export type ActionMenuItem = {
|
|
|
4
4
|
disabled?: boolean;
|
|
5
5
|
danger?: boolean;
|
|
6
6
|
};
|
|
7
|
+
/** The button's box in viewport pixels, `right` measured from the viewport's right edge. */
|
|
8
|
+
type Anchor = {
|
|
9
|
+
top: number;
|
|
10
|
+
bottom: number;
|
|
11
|
+
right: number;
|
|
12
|
+
};
|
|
13
|
+
export declare const anchorOf: (button: {
|
|
14
|
+
top: number;
|
|
15
|
+
bottom: number;
|
|
16
|
+
right: number;
|
|
17
|
+
}, viewportWidth: number) => Anchor;
|
|
18
|
+
/** Whether a scroll moved the button since the menu opened. */
|
|
19
|
+
export declare const anchorMoved: (open: Anchor, now: Anchor) => boolean;
|
|
20
|
+
/** 6px under the button from its right edge, or above it when it does not fit below and there is more room above. */
|
|
21
|
+
export declare function menuPlacement(anchor: Anchor, viewportHeight: number, height: number): {
|
|
22
|
+
right: number;
|
|
23
|
+
top?: number;
|
|
24
|
+
bottom?: number;
|
|
25
|
+
};
|
|
7
26
|
export declare function ActionMenu({ label, items, disabled, small }: {
|
|
8
27
|
label: string;
|
|
9
28
|
items: ActionMenuItem[];
|
|
@@ -11,3 +30,4 @@ export declare function ActionMenu({ label, items, disabled, small }: {
|
|
|
11
30
|
/** The smaller round button, next to a small Publish. */
|
|
12
31
|
small?: boolean;
|
|
13
32
|
}): import("react").JSX.Element;
|
|
33
|
+
export {};
|
|
@@ -8,14 +8,35 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// right edge (every control sits at its bar's or card's right). It is
|
|
9
9
|
// position:fixed from the button's rect (2026-09-26): inside a table cell
|
|
10
10
|
// an absolute child was clipped by the cell's overflow, so the Members
|
|
11
|
-
// row menu opened invisibly.
|
|
12
|
-
|
|
11
|
+
// row menu opened invisibly. Near the bottom of the screen it opens upward,
|
|
12
|
+
// the Combobox's rule (2026-09-27: a fixed menu cut off by the viewport
|
|
13
|
+
// cannot be scrolled into view). A scroll that moves the button closes it
|
|
14
|
+
// rather than leaving it behind — only one that moves it: scroll events
|
|
15
|
+
// arrive a frame late, so the one from bringing the row into view could
|
|
16
|
+
// land after the click and close the menu it had just opened.
|
|
17
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
18
|
+
import { opensUp } from "./combo-box.js";
|
|
13
19
|
import { cx } from "./primitives.js";
|
|
20
|
+
export const anchorOf = (button, viewportWidth) => ({ top: button.top, bottom: button.bottom, right: viewportWidth - button.right });
|
|
21
|
+
/** Whether a scroll moved the button since the menu opened. */
|
|
22
|
+
export const anchorMoved = (open, now) => open.top !== now.top || open.bottom !== now.bottom || open.right !== now.right;
|
|
23
|
+
/** 6px under the button from its right edge, or above it when it does not fit below and there is more room above. */
|
|
24
|
+
export function menuPlacement(anchor, viewportHeight, height) {
|
|
25
|
+
return opensUp({ below: viewportHeight - anchor.bottom - 6, above: anchor.top - 6, height })
|
|
26
|
+
? { right: anchor.right, bottom: viewportHeight - anchor.top + 6 }
|
|
27
|
+
: { right: anchor.right, top: anchor.bottom + 6 };
|
|
28
|
+
}
|
|
14
29
|
export function ActionMenu({ label, items, disabled, small = false }) {
|
|
15
|
-
/** Open: the
|
|
30
|
+
/** Open: the button's box when it opened; the popover is placed from it. */
|
|
16
31
|
const [open, setOpen] = useState(null);
|
|
32
|
+
const [place, setPlace] = useState(null);
|
|
17
33
|
const ref = useRef(null);
|
|
18
34
|
const button = useRef(null);
|
|
35
|
+
const menu = useRef(null);
|
|
36
|
+
// Measured before paint, so an upward menu never flashes below first.
|
|
37
|
+
useLayoutEffect(() => {
|
|
38
|
+
setPlace(open && menu.current ? menuPlacement(open, window.innerHeight, menu.current.offsetHeight) : null);
|
|
39
|
+
}, [open]);
|
|
19
40
|
const toggle = () => {
|
|
20
41
|
if (open) {
|
|
21
42
|
setOpen(null);
|
|
@@ -23,7 +44,7 @@ export function ActionMenu({ label, items, disabled, small = false }) {
|
|
|
23
44
|
}
|
|
24
45
|
const r = button.current?.getBoundingClientRect();
|
|
25
46
|
if (r)
|
|
26
|
-
setOpen(
|
|
47
|
+
setOpen(anchorOf(r, window.innerWidth));
|
|
27
48
|
};
|
|
28
49
|
useEffect(() => {
|
|
29
50
|
if (!open)
|
|
@@ -33,14 +54,19 @@ export function ActionMenu({ label, items, disabled, small = false }) {
|
|
|
33
54
|
close(); };
|
|
34
55
|
const onKey = (e) => { if (e.key === "Escape")
|
|
35
56
|
close(); };
|
|
57
|
+
const onScroll = () => {
|
|
58
|
+
const r = button.current?.getBoundingClientRect();
|
|
59
|
+
if (!r || anchorMoved(open, anchorOf(r, window.innerWidth)))
|
|
60
|
+
close();
|
|
61
|
+
};
|
|
36
62
|
document.addEventListener("pointerdown", onDown);
|
|
37
63
|
document.addEventListener("keydown", onKey);
|
|
38
|
-
document.addEventListener("scroll",
|
|
64
|
+
document.addEventListener("scroll", onScroll, true);
|
|
39
65
|
return () => {
|
|
40
66
|
document.removeEventListener("pointerdown", onDown);
|
|
41
67
|
document.removeEventListener("keydown", onKey);
|
|
42
|
-
document.removeEventListener("scroll",
|
|
68
|
+
document.removeEventListener("scroll", onScroll, true);
|
|
43
69
|
};
|
|
44
70
|
}, [open]);
|
|
45
|
-
return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { ref: button, type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open !== null, disabled: disabled, onClick: toggle, children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { role: "menu", className: "sm-picker sm-picker--menu", style: { top: open.
|
|
71
|
+
return (_jsxs("div", { ref: ref, style: { position: "relative", flex: "none" }, children: [_jsx("button", { ref: button, type: "button", className: cx("sm-dotmenu", small && "sm-dotmenu--sm"), "aria-label": label, title: label, "aria-haspopup": "menu", "aria-expanded": open !== null, disabled: disabled, onClick: toggle, children: "\u00B7\u00B7\u00B7" }), open && (_jsx("div", { ref: menu, role: "menu", className: "sm-picker sm-picker--menu", style: place ?? { top: open.bottom + 6, right: open.right, visibility: "hidden" }, children: items.map((item) => (_jsx("button", { type: "button", role: "menuitem", className: cx("sm-picker__item", item.danger && "sm-picker__item--danger"), disabled: item.disabled, onClick: () => { setOpen(null); item.run(); }, children: item.label }, item.label))) }))] }));
|
|
46
72
|
}
|
|
@@ -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
|