Files
2026-09-22 21:24:43 +01:00

352 lines
11 KiB
TypeScript

"use client";
import {
cloneElement,
isValidElement,
useCallback,
useEffect,
useId,
useRef,
useState,
type ComponentProps,
type ReactElement,
type ReactNode,
} from "react";
import { SettingsCard } from "./settings-card";
import { SettingsSaveDirtyScope } from "./settings-save-provider";
import { SettingsFieldLabel, SETTINGS_TYPOGRAPHY } from "./settings-typography";
import { useSettingsTargetRegistration } from "./settings-target-provider";
import { UnsavedChangesBadge } from "./unsaved-indicator";
import { SETTINGS_TARGET_DISCLOSURE_OPEN_EVENT } from "@/lib/settings-discovery/target";
import { cn } from "@/lib/utils";
export type SettingsPresentation = "card" | "row";
type SettingsGroupProps = Omit<ComponentProps<"section">, "title"> & {
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
titleAccessory?: ReactNode;
children: ReactNode;
isDirty?: boolean;
discoveryTargetId?: string;
collapsible?: boolean;
defaultOpen?: boolean;
summary?: ReactNode;
revealOn?: boolean | string;
frame?: "card" | "none";
contentClassName?: string;
titleTestId?: string;
};
function SettingsGroupHeading({
title,
description,
summary,
titleTestId,
titleAccessory,
dirty,
}: Pick<
SettingsGroupProps,
"title" | "description" | "summary" | "titleTestId" | "titleAccessory"
> & {
dirty: boolean;
}) {
return (
<span className="min-w-0">
<span className="flex items-center gap-2">
<svg
aria-hidden="true"
className="size-4 shrink-0 transition-transform group-open:rotate-90"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m9 5 7 7-7 7" />
</svg>
<h3 className={SETTINGS_TYPOGRAPHY.sectionTitle} data-testid={titleTestId}>
{title}
</h3>
{titleAccessory}
</span>
{(description || summary) && (
<span className="mt-1 flex flex-wrap gap-x-3 gap-y-1 pl-6">
{description && (
<span className={SETTINGS_TYPOGRAPHY.sectionDescription}>{description}</span>
)}
{summary && <span className={SETTINGS_TYPOGRAPHY.meta}>{summary}</span>}
</span>
)}
{dirty && <UnsavedChangesBadge />}
</span>
);
}
function SettingsGroupHeader({
title,
description,
action,
titleTestId,
titleAccessory,
dirty,
}: Pick<
SettingsGroupProps,
"title" | "description" | "action" | "titleTestId" | "titleAccessory"
> & { dirty: boolean }) {
return (
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className={SETTINGS_TYPOGRAPHY.sectionTitle} data-testid={titleTestId}>
{title}
</h3>
{titleAccessory}
{dirty && <UnsavedChangesBadge />}
</div>
{description && <p className={SETTINGS_TYPOGRAPHY.sectionDescription}>{description}</p>}
</div>
{action && <div className="w-full shrink-0 md:w-auto">{action}</div>}
</div>
);
}
function SettingsGroupContent({
frame,
isDirty,
contentClassName,
children,
}: {
frame: "card" | "none";
isDirty: boolean;
contentClassName?: string;
children: ReactNode;
}) {
if (frame === "none") {
return (
<div className={cn("min-w-0", contentClassName)} data-settings-group-content="true">
{children}
</div>
);
}
return (
<SettingsCard isDirty={isDirty} className="min-w-0 w-full" data-settings-group-card="true">
<div className={cn("divide-y divide-border/70 px-4 py-2", contentClassName)}>{children}</div>
</SettingsCard>
);
}
/** A settings group owns one heading and, by default, one bordered surface. */
export function SettingsGroup({
title,
description,
action,
titleAccessory,
children,
isDirty = false,
discoveryTargetId,
collapsible = false,
defaultOpen = true,
summary,
revealOn = false,
frame = "card",
className,
contentClassName,
titleTestId,
...props
}: SettingsGroupProps) {
const registerTarget = useSettingsTargetRegistration(discoveryTargetId);
const [open, setOpen] = useState(defaultOpen);
const detailsRef = useRef<HTMLDetailsElement | null>(null);
const lastRevealToken = useRef("");
const revealToken = revealOn === true ? "true" : revealOn || "";
useEffect(() => {
if (revealToken && revealToken !== lastRevealToken.current) setOpen(true);
lastRevealToken.current = revealToken;
}, [revealToken]);
useEffect(() => {
const details = detailsRef.current;
if (!details) return;
const openFromDiscovery = () => setOpen(true);
details.addEventListener(SETTINGS_TARGET_DISCLOSURE_OPEN_EVENT, openFromDiscovery);
return () =>
details.removeEventListener(SETTINGS_TARGET_DISCLOSURE_OPEN_EVENT, openFromDiscovery);
}, []);
return (
<SettingsSaveDirtyScope>
{(nestedIsDirty) => {
const groupIsDirty = isDirty || nestedIsDirty;
const groupContent = (
<SettingsGroupContent
frame={frame}
isDirty={groupIsDirty}
contentClassName={contentClassName}
>
{children}
</SettingsGroupContent>
);
return (
<section
{...props}
ref={registerTarget}
className={cn("min-w-0 space-y-3", className)}
data-settings-group="true"
data-settings-dirty={groupIsDirty}
>
{collapsible ? (
<details
ref={detailsRef}
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
data-settings-group-disclosure="true"
>
<summary className="group flex min-h-11 cursor-pointer list-none items-center justify-between gap-3 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
<SettingsGroupHeading
title={title}
description={description}
summary={summary}
titleTestId={titleTestId}
titleAccessory={titleAccessory}
dirty={groupIsDirty}
/>
</summary>
<div className="mt-3">{groupContent}</div>
</details>
) : (
<>
<SettingsGroupHeader
title={title}
description={description}
action={action}
titleTestId={titleTestId}
titleAccessory={titleAccessory}
dirty={groupIsDirty}
/>
{groupContent}
</>
)}
</section>
);
}}
</SettingsSaveDirtyScope>
);
}
type SettingsRowProps = Omit<ComponentProps<"div">, "children"> & {
label: ReactNode;
description?: ReactNode;
control: ReactNode;
controlId?: string;
descriptionId?: string;
touchTarget?: "switch";
controlWrapperTestId?: string;
controlWrapperClassName?: string;
info?: ReactNode;
details?: { label: ReactNode; children: ReactNode };
discoveryTargetId?: string;
isDirty?: boolean;
};
/** A row keeps a setting's label, explanation, control, and discovery target together. */
export function SettingsRow({
label,
description,
control,
controlId,
descriptionId,
touchTarget,
controlWrapperTestId,
controlWrapperClassName,
details,
info,
discoveryTargetId,
isDirty = false,
className,
...props
}: SettingsRowProps) {
const registerTarget = useSettingsTargetRegistration(discoveryTargetId);
const generatedDescriptionId = useId();
const resolvedDescriptionId = description
? (descriptionId ?? `settings-row-description-${generatedDescriptionId.replace(/:/g, "")}`)
: undefined;
const setTargetRef = useCallback(
(element: HTMLDivElement | null) => registerTarget(element),
[registerTarget],
);
const describedControl =
resolvedDescriptionId && isValidElement(control)
? cloneElement(control as ReactElement<{ "aria-describedby"?: string }>, {
"aria-describedby": [
(control as ReactElement<{ "aria-describedby"?: string }>).props["aria-describedby"],
resolvedDescriptionId,
]
.filter(Boolean)
.join(" "),
})
: control;
return (
<div
{...props}
ref={setTargetRef}
className={cn(
"flex min-w-0 flex-col gap-3 py-3 first:pt-2 last:pb-2 md:flex-row md:items-center md:justify-between",
className,
)}
data-settings-row="true"
data-settings-dirty={isDirty}
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-1">
<SettingsFieldLabel htmlFor={controlId}>{label}</SettingsFieldLabel>
{info}
</div>
{description && (
<div id={resolvedDescriptionId} className={SETTINGS_TYPOGRAPHY.fieldDescription}>
{description}
</div>
)}
{details && <SettingsDetails label={details.label}>{details.children}</SettingsDetails>}
</div>
{touchTarget === "switch" ? (
<label
htmlFor={controlId}
data-testid={controlWrapperTestId}
data-settings-touch-target="true"
className={cn(
"flex min-w-0 shrink-0 items-center md:max-w-[min(50%,28rem)] md:justify-end max-md:min-h-11 max-md:min-w-11 max-md:w-full max-md:justify-end",
"[@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11",
controlWrapperClassName,
)}
>
{describedControl}
</label>
) : (
<div
data-testid={controlWrapperTestId}
className={cn(
"flex min-w-0 shrink-0 items-center md:max-w-[min(50%,28rem)] md:justify-end max-md:min-h-11 max-md:w-full",
"[@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11",
controlWrapperClassName,
)}
>
{describedControl}
</div>
)}
</div>
);
}
export function SettingsDetails({ label, children }: { label: ReactNode; children: ReactNode }) {
return (
<details className="pt-1 text-xs text-muted-foreground">
<summary className="inline-flex w-fit cursor-pointer items-center px-2 -mx-2 font-medium text-foreground max-md:min-h-11 max-md:min-w-11 [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11">
{label}
</summary>
<div className="mt-2 space-y-2">{children}</div>
</details>
);
}