diff --git a/.changeset/content-settings-sortable-sections.md b/.changeset/content-settings-sortable-sections.md new file mode 100644 index 0000000000..c8180dd34a --- /dev/null +++ b/.changeset/content-settings-sortable-sections.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": minor +--- + +Adds accessible drag handles for reordering the built-in content settings sections and stores each editor's preferred order per collection in the browser. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index c6741857eb..f2025f4b6e 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -952,6 +952,7 @@ function MobileSidebarPortalGuard() { const nestedOverlaySelector = '[role="dialog"], [role="listbox"], [role="menu"], .kumo-tooltip-popup'; const keepSheetOpen = () => queueMicrotask(() => setOpenMobile(true)); + const reopenSheetAfterDismiss = () => setTimeout(setOpenMobile, 0, true); const promotePortal = (element: Element) => { const overlay = element.closest(nestedOverlaySelector) ?? element.querySelector(nestedOverlaySelector); @@ -964,7 +965,19 @@ function MobileSidebarPortalGuard() { const handleFocusOut = (event: FocusEvent) => { const source = event.target; const destination = event.relatedTarget; - if (!(source instanceof Element) || !(destination instanceof Element)) return; + if (!(source instanceof Element)) return; + + // dnd-kit briefly blurs and then restores the activator after a + // pointer drop. Kumo interprets the null relatedTarget as leaving the + // sheet and closes it before focus is restored. Keep this transient + // sortable-handle blur inside the mobile settings interaction. + if (source.closest("[data-sortable-handle]") && destination === null) { + event.stopPropagation(); + keepSheetOpen(); + return; + } + + if (!(destination instanceof Element)) return; const sheet = source.closest('nav[data-sidebar="sidebar"][data-mobile="true"]'); if (!sheet || sheet.contains(destination)) return; @@ -977,7 +990,17 @@ function MobileSidebarPortalGuard() { const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; const target = event.target; - if (!(target instanceof Element) || !target.closest(nestedOverlaySelector)) return; + if (!(target instanceof Element)) return; + + // Escape cancels a keyboard drag, but Kumo also treats it as a request + // to dismiss the mobile sheet. Let dnd-kit receive the key while + // restoring the sheet after its dismissal handler runs. + if (target.closest('[data-sortable-handle][data-sorting="true"]')) { + reopenSheetAfterDismiss(); + return; + } + + if (!target.closest(nestedOverlaySelector)) return; keepSheetOpen(); }; diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index 08a8cd8903..f108f9fffe 100644 --- a/packages/admin/src/components/ContentSettingsPanel.tsx +++ b/packages/admin/src/components/ContentSettingsPanel.tsx @@ -26,7 +26,7 @@ import type { } from "../lib/api"; import { fetchBylines } from "../lib/api"; import { useDebouncedValue } from "../lib/hooks.js"; -import { slugify } from "../lib/utils"; +import { cn, slugify } from "../lib/utils"; import type { CurrentUserInfo } from "./ContentEditor.js"; import { DocumentOutline } from "./editor/DocumentOutline"; import { GalleryDetailPanel } from "./editor/GalleryDetailPanel"; @@ -38,7 +38,11 @@ import { RevisionHistory } from "./RevisionHistory"; import { RouterLinkButton } from "./RouterLinkButton.js"; import { SaveButton } from "./SaveButton"; import { SeoPanel } from "./SeoPanel"; -import { TaxonomySidebar } from "./TaxonomySidebar"; +import { + SortableContentSettingsSection, + SortableContentSettingsSections, +} from "./SortableContentSettingsSections.js"; +import { TaxonomySidebar, useHasApplicableTaxonomies } from "./TaxonomySidebar"; import { TranslationsPanel } from "./TranslationsPanel.js"; // Editor role level (40) from @emdash-cms/auth @@ -378,7 +382,9 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ const [scheduleDate, setScheduleDate] = React.useState(""); const [showScheduler, setShowScheduler] = React.useState(false); + const [isReorderingSections, setIsReorderingSections] = React.useState(false); const showDiscard = !isNew && supportsDrafts && hasPendingChanges && !!onDiscardDraft; + const hasApplicableTaxonomies = useHasApplicableTaxonomies(collection); const handleScheduleSubmit = () => { if (scheduleDate && onSchedule) { @@ -421,209 +427,234 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ // The Kumo Sidebar wrapper sets `whitespace-nowrap` for its collapse // animation, which would stop long field descriptions from wrapping.
-
- - {t`Publish`} - -
- onSlugChange(e.target.value)} - placeholder="my-post-slug" - /> -
-
- - {supportsDrafts ? ( - <> - {isLive && {t`Published`}} - {hasPendingChanges && {t`Pending changes`}} - {!isLive && !hasSchedule && {t`Draft`}} - {hasSchedule && {t`Scheduled`}} - - ) : ( - - {status.charAt(0).toUpperCase() + status.slice(1)} - - )} -
- {showDiscard && ( -
- + + +
+ + {t`Publish`} + +
+ onSlugChange(e.target.value)} + placeholder="my-post-slug" + /> +
+
+ + {supportsDrafts ? ( + <> + {isLive && {t`Published`}} + {hasPendingChanges && {t`Pending changes`}} + {!isLive && !hasSchedule && {t`Draft`}} + {hasSchedule && {t`Scheduled`}} + + ) : ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + )} +
+ {showDiscard && ( +
+ +
+ )}
- )} -
- {item?.scheduledAt && ( -
-

{t`Scheduled for: ${formatScheduledDate(item.scheduledAt)}`}

- -
- )} + {item?.scheduledAt && ( +
+

{t`Scheduled for: ${formatScheduledDate(item.scheduledAt)}`}

+ +
+ )} - {canSchedule && ( -
- {showScheduler ? ( -
- setScheduleDate(e.target.value)} - min={new Date().toISOString().slice(0, 16)} - /> -
- + {canSchedule && ( +
+ {showScheduler ? ( +
+ setScheduleDate(e.target.value)} + min={new Date().toISOString().slice(0, 16)} + /> +
+ + +
+
+ ) : ( -
+ )}
- ) : ( - )}
- )} -
- {item && ( -
-
-
{t`Created`}
-
{new Date(item.createdAt).toLocaleString()}
-
-
-
{t`Updated`}
-
{new Date(item.updatedAt).toLocaleString()}
+ {item && ( +
+
+
{t`Created`}
+
{new Date(item.createdAt).toLocaleString()}
+
+
+
{t`Updated`}
+
{new Date(item.updatedAt).toLocaleString()}
+
+
+ )} +
+ + + {currentUser && currentUser.role >= ROLE_EDITOR && users && users.length > 0 && ( + +
+ + {t`Ownership`} + +
-
+ )} -
- {currentUser && currentUser.role >= ROLE_EDITOR && users && users.length > 0 && ( -
- - {t`Ownership`} - - -
- )} - - {currentUser && currentUser.role >= ROLE_EDITOR && ( -
- - {t`Bylines`} - - entry.byline)} - bylinesLoaded={availableBylinesLoaded} - onChange={onBylinesChange} - onQuickCreate={onQuickCreateByline} - onQuickEdit={onQuickEditByline} - // Existing entry: use its own locale. New entry: use the - // URL `?locale=` (passed in via `entryLocale`). - entryLocale={item?.locale ?? entryLocale} - i18n={i18n} - /> -
- )} + {currentUser && currentUser.role >= ROLE_EDITOR && ( + +
+ + {t`Bylines`} + + entry.byline)} + bylinesLoaded={availableBylinesLoaded} + onChange={onBylinesChange} + onQuickCreate={onQuickCreateByline} + onQuickEdit={onQuickEditByline} + // Existing entry: use its own locale. New entry: use the + // URL `?locale=` (passed in via `entryLocale`). + entryLocale={item?.locale ?? entryLocale} + i18n={i18n} + /> +
+
+ )} - {i18n && item && !isNew && ( -
- - navigate({ - to: "/content/$collection/$id", - params: { collection, id: tr.id }, - search: { locale: tr.locale }, - }) - } - onCreate={onTranslate} - /> -
- )} + {i18n && item && !isNew && ( + +
+ + navigate({ + to: "/content/$collection/$id", + params: { collection, id: tr.id }, + search: { locale: tr.locale }, + }) + } + onCreate={onTranslate} + /> +
+
+ )} - {/* Taxonomy selector — renders nothing (no chrome) when no taxonomies - apply to this collection, so it owns its own section border. */} - {item && ( - - )} + {/* Do not register an empty sortable row when this collection has no taxonomies. */} + {item && hasApplicableTaxonomies && ( + + + + )} - {hasSeo && !isNew && onSeoChange && ( -
- - {t`SEO`} - - -
- )} + {hasSeo && !isNew && onSeoChange && ( + +
+ + {t`SEO`} + + +
+
+ )} - {portableTextEditor && ( -
- -
- )} + {portableTextEditor && ( + +
+ +
+
+ )} - {!isNew && item && supportsRevisions && ( -
- -
- )} + {!isNew && item && supportsRevisions && ( + +
+ +
+
+ )} +
{!isNew && onDelete && ( -
+
( diff --git a/packages/admin/src/components/RevisionHistory.tsx b/packages/admin/src/components/RevisionHistory.tsx index 56eda5798a..9826f81c20 100644 --- a/packages/admin/src/components/RevisionHistory.tsx +++ b/packages/admin/src/components/RevisionHistory.tsx @@ -71,6 +71,8 @@ interface RevisionHistoryProps { entryId: string; /** Called when a revision is successfully restored */ onRestored?: () => void; + /** Reserve the inline end of the disclosure header for an external control. */ + reserveHeaderEnd?: boolean; } /** @@ -91,7 +93,12 @@ function formatFullDate(dateString: string): string { * RevisionHistory component - displays revision history for a content item * with ability to restore previous versions. */ -export function RevisionHistory({ collection, entryId, onRestored }: RevisionHistoryProps) { +export function RevisionHistory({ + collection, + entryId, + onRestored, + reserveHeaderEnd = false, +}: RevisionHistoryProps) { const { t } = useLingui(); const [isExpanded, setIsExpanded] = React.useState(false); const [selectedRevision, setSelectedRevision] = React.useState(null); @@ -149,7 +156,10 @@ export function RevisionHistory({ collection, entryId, onRestored }: RevisionHis type="button" variant="ghost" className="relative justify-between" - style={{ width: "calc(100% + 1.5rem)", insetInlineStart: "-0.75rem" }} + style={{ + width: reserveHeaderEnd ? "calc(100% - 1.5rem)" : "calc(100% + 1.5rem)", + insetInlineStart: "-0.75rem", + }} /> } > diff --git a/packages/admin/src/components/SortableContentSettingsSections.tsx b/packages/admin/src/components/SortableContentSettingsSections.tsx new file mode 100644 index 0000000000..dabd3dc030 --- /dev/null +++ b/packages/admin/src/components/SortableContentSettingsSections.tsx @@ -0,0 +1,222 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + type DragStartEvent, + KeyboardSensor, + MeasuringStrategy, + type Modifier, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + sortableKeyboardCoordinates, + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useLingui } from "@lingui/react/macro"; +import { DotsSixVertical } from "@phosphor-icons/react"; +import * as React from "react"; + +import { + parseContentSettingsLayout, + reorderContentSettingsLayout, + resolveContentSettingsLayout, + type ContentSettingsLayout, + type ContentSettingsSectionId, +} from "../lib/content-settings-layout.js"; +import { cn } from "../lib/utils.js"; + +const STORAGE_PREFIX = "emdash:content-settings-layout:v1"; + +const restrictToVerticalAxis: Modifier = ({ transform }) => ({ + ...transform, + x: 0, +}); + +export interface SortableContentSettingsSectionProps { + id: ContentSettingsSectionId; + label: string; + /** Leaves room for an existing disclosure chevron at the inline end. */ + disclosure?: boolean; + children: React.ReactNode; + /** Internal state supplied by the sortable group while any section is moving. */ + isSorting?: boolean; +} + +interface SortableContentSettingsSectionsProps { + collection: string; + userId?: string; + onSortingChange?: (isSorting: boolean) => void; + children: React.ReactNode; +} + +function readStoredLayout(storageKey: string | null): ContentSettingsLayout | null { + if (!storageKey || typeof window === "undefined") return null; + try { + return parseContentSettingsLayout(window.localStorage.getItem(storageKey)); + } catch { + return null; + } +} + +function writeStoredLayout(storageKey: string | null, layout: ContentSettingsLayout): void { + if (!storageKey || typeof window === "undefined") return; + try { + window.localStorage.setItem(storageKey, JSON.stringify(layout)); + } catch { + // Browser storage is optional; the reordered in-memory layout still works. + } +} + +export function SortableContentSettingsSections({ + collection, + userId, + onSortingChange, + children, +}: SortableContentSettingsSectionsProps) { + const storageKey = userId + ? `${STORAGE_PREFIX}:${encodeURIComponent(userId)}:${encodeURIComponent(collection)}` + : null; + // Keep the server and first client render identical. Browser preferences + // are restored after hydration so a saved order cannot cause a mismatch. + const [storedLayout, setStoredLayout] = React.useState(null); + const [activeId, setActiveId] = React.useState(null); + + React.useEffect(() => { + setStoredLayout(readStoredLayout(storageKey)); + }, [storageKey]); + + const layout = React.useMemo(() => resolveContentSettingsLayout(storedLayout), [storedLayout]); + const sectionsById = React.useMemo(() => { + const sections = React.Children.toArray(children).filter( + (child): child is React.ReactElement => + React.isValidElement(child), + ); + return new Map(sections.map((section) => [section.props.id, section])); + }, [children]); + const visibleIds = React.useMemo( + () => layout.order.filter((id) => sectionsById.has(id)), + [layout.order, sectionsById], + ); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + const handleDragStart = React.useCallback( + (event: DragStartEvent) => { + setActiveId(String(event.active.id) as ContentSettingsSectionId); + onSortingChange?.(true); + }, + [onSortingChange], + ); + const handleDragCancel = React.useCallback(() => { + setActiveId(null); + onSortingChange?.(false); + }, [onSortingChange]); + + const handleDragEnd = React.useCallback( + (event: DragEndEvent) => { + setActiveId(null); + onSortingChange?.(false); + if (event.over && event.active.id !== event.over.id) { + const movedId = String(event.active.id) as ContentSettingsSectionId; + const overId = String(event.over.id) as ContentSettingsSectionId; + setStoredLayout((current) => { + const next = reorderContentSettingsLayout( + resolveContentSettingsLayout(current), + movedId, + overId, + ); + writeStoredLayout(storageKey, next); + return next; + }); + } + }, + [onSortingChange, storageKey], + ); + + return ( + + + {visibleIds.map((id) => { + const section = sectionsById.get(id); + return section + ? React.cloneElement(section, { key: id, isSorting: activeId !== null }) + : null; + })} + + + ); +} + +export function SortableContentSettingsSection({ + id, + label, + disclosure = false, + children, + isSorting = false, +}: SortableContentSettingsSectionProps) { + const { t } = useLingui(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }); + const style: React.CSSProperties = { + transform: transform ? CSS.Transform.toString({ ...transform, x: 0 }) : undefined, + transition, + zIndex: isDragging ? 10 : undefined, + inlineSize: "100%", + }; + + return ( +
*:not([data-sortable-heading]):not([data-sortable-handle])]:hidden", + isDragging && "bg-kumo-tint opacity-60", + )} + > + {isSorting && ( +
+ {label} +
+ )} + {children} + +
+ ); +} diff --git a/packages/admin/src/components/TaxonomySidebar.tsx b/packages/admin/src/components/TaxonomySidebar.tsx index 6977bb1992..03a2773d9e 100644 --- a/packages/admin/src/components/TaxonomySidebar.tsx +++ b/packages/admin/src/components/TaxonomySidebar.tsx @@ -63,6 +63,19 @@ async function fetchTaxonomyDefs(): Promise { return data.taxonomies; } +function useApplicableTaxonomies(collection: string): TaxonomyDef[] { + const { data: taxonomies = [] } = useQuery({ + queryKey: ["taxonomy-defs"], + queryFn: fetchTaxonomyDefs, + }); + return taxonomies.filter((taxonomy) => taxonomy.collections.includes(collection)); +} + +/** Whether the editor should include a taxonomy settings section. */ +export function useHasApplicableTaxonomies(collection: string): boolean { + return useApplicableTaxonomies(collection).length > 0; +} + /** * Fetch terms for a taxonomy, scoped to the entry's locale so only the matching * translation variants are offered. @@ -524,13 +537,7 @@ export function TaxonomySidebar({ className, }: TaxonomySidebarProps) { const { t } = useLingui(); - const { data: taxonomies = [] } = useQuery({ - queryKey: ["taxonomy-defs"], - queryFn: fetchTaxonomyDefs, - }); - - // Filter to taxonomies that apply to this collection - const applicableTaxonomies = taxonomies.filter((tax) => tax.collections.includes(collection)); + const applicableTaxonomies = useApplicableTaxonomies(collection); if (applicableTaxonomies.length === 0) { return null; diff --git a/packages/admin/src/components/editor/DocumentOutline.tsx b/packages/admin/src/components/editor/DocumentOutline.tsx index fff5980373..080b90dffa 100644 --- a/packages/admin/src/components/editor/DocumentOutline.tsx +++ b/packages/admin/src/components/editor/DocumentOutline.tsx @@ -108,12 +108,18 @@ export interface DocumentOutlineProps { editor: Editor | null; /** Additional CSS classes */ className?: string; + /** Reserve the inline end of the disclosure header for an external control. */ + reserveHeaderEnd?: boolean; } /** * Document outline component showing heading tree structure */ -export function DocumentOutline({ editor, className }: DocumentOutlineProps) { +export function DocumentOutline({ + editor, + className, + reserveHeaderEnd = false, +}: DocumentOutlineProps) { const { t } = useLingui(); const [isExpanded, setIsExpanded] = React.useState(true); const [headings, setHeadings] = React.useState([]); @@ -174,7 +180,10 @@ export function DocumentOutline({ editor, className }: DocumentOutlineProps) {