- Implemented ThemeProvider to manage theme state and preferences. - Added ThemeControl component for users to switch between system, light, and dark themes. - Integrated localStorage for theme preference persistence. - Updated document styles based on theme changes. test: add tests for permanent deletion of habits and related records - Created tests to ensure permanent deletion of habits removes all dependent records. - Verified that deletion requires ownership, authentication, and checks for archived habits. - Added tests to confirm rollback behavior on deletion failures. test: add tests for habit palette and progress card functionality - Implemented tests for habit color progression and theme-based color shading. - Added tests for progress card calculations and calendar display logic. feat: define theme types and preferences - Introduced Theme and ThemePreference types for better type safety. - Created utility functions for resolving theme based on user preference and system settings. - Defined theme palettes for light and dark modes to ensure adequate contrast.
81 lines
4.4 KiB
TypeScript
81 lines
4.4 KiB
TypeScript
import { useLayoutEffect, useRef, type ReactNode } from "react";
|
|
import { X } from "lucide-react";
|
|
import { Button } from "./primitives";
|
|
|
|
let openModals = 0;
|
|
let previousOverflow = "";
|
|
|
|
/** Native top-layer dialog supplies focus containment and an inert background. */
|
|
export function Modal({ id, eyebrow, title, description, children, onClose, closeLabel = "Close dialog", busy = false, size = "wide", initialFocus, returnFocus }: {
|
|
id: string; eyebrow?: string; title: ReactNode; description?: ReactNode; children: ReactNode;
|
|
onClose?: () => void; closeLabel?: string; busy?: boolean; size?: "wide" | "compact"; initialFocus?: string; returnFocus?: () => HTMLElement | null;
|
|
}) {
|
|
const dialog = useRef<HTMLDialogElement>(null);
|
|
const heading = useRef<HTMLHeadingElement>(null);
|
|
const pointerOnBackdrop = useRef(false);
|
|
const mounted = useRef(false);
|
|
useLayoutEffect(() => {
|
|
mounted.current = true;
|
|
const element = dialog.current!;
|
|
const opener = returnFocus?.() ?? document.activeElement as HTMLElement | null;
|
|
if (!openModals++) {
|
|
previousOverflow = document.documentElement.style.overflow;
|
|
document.documentElement.style.overflow = "hidden";
|
|
}
|
|
element.showModal();
|
|
const target = initialFocus ? element.querySelector<HTMLElement>(initialFocus) : heading.current;
|
|
target?.focus({ preventScroll: true });
|
|
return () => {
|
|
mounted.current = false;
|
|
element.close();
|
|
if (!--openModals) document.documentElement.style.overflow = previousOverflow;
|
|
// A successful save may keep its opener disabled until the dashboard
|
|
// refresh finishes. Restore focus when it becomes available again.
|
|
window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
|
|
// Ignore React Strict Mode's setup/cleanup rehearsal.
|
|
if (mounted.current || !opener?.isConnected) return;
|
|
if (!opener.matches(":disabled")) { opener.focus({ preventScroll: true }); return; }
|
|
const observer = new window.MutationObserver(() => {
|
|
if (opener.matches(":disabled")) return;
|
|
observer.disconnect();
|
|
window.clearTimeout(timeout);
|
|
if (opener.isConnected && document.activeElement === document.body) opener.focus({ preventScroll: true });
|
|
});
|
|
observer.observe(opener, { attributes: true, attributeFilter: ["disabled"] });
|
|
const timeout = window.setTimeout(() => observer.disconnect(), 5000);
|
|
}));
|
|
};
|
|
}, []);
|
|
|
|
const dismiss = () => { if (!busy) onClose?.(); };
|
|
return <dialog ref={dialog} id={id} className={`ds-modal ds-modal--${size}`} aria-labelledby={`${id}-title`}
|
|
onCancel={event => { event.preventDefault(); event.stopPropagation(); dismiss(); }}
|
|
onKeyDown={event => {
|
|
if (event.key !== "Tab") return;
|
|
event.stopPropagation();
|
|
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, summary, [tabindex]')]
|
|
.filter(element => element.tabIndex >= 0 && !element.matches(':disabled') && element.getClientRects().length > 0);
|
|
const first = controls[0];
|
|
const last = controls.at(-1);
|
|
if (!first || !last) { event.preventDefault(); heading.current?.focus(); return; }
|
|
const active = document.activeElement;
|
|
if (event.shiftKey && (active === first || !controls.includes(active as HTMLElement))) {
|
|
event.preventDefault(); last.focus();
|
|
} else if (!event.shiftKey && active === last) {
|
|
event.preventDefault(); first.focus();
|
|
}
|
|
}}
|
|
onPointerDown={event => { pointerOnBackdrop.current = event.target === event.currentTarget; }}
|
|
onClick={event => {
|
|
if (!pointerOnBackdrop.current || event.target !== event.currentTarget) return;
|
|
const bounds = event.currentTarget.getBoundingClientRect();
|
|
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dismiss();
|
|
}}>
|
|
<header className="ds-modal-heading">
|
|
<div>{eyebrow && <p className="ds-eyebrow">{eyebrow}</p>}<h2 id={`${id}-title`} ref={heading} tabIndex={-1}>{title}</h2>{description && <p className="ds-panel-description">{description}</p>}</div>
|
|
<Button variant="text" className="ds-panel-close" disabled={busy || !onClose} onClick={dismiss} aria-label={closeLabel} title={closeLabel}><X size={20} aria-hidden="true" /></Button>
|
|
</header>
|
|
<div className="ds-modal-body">{children}</div>
|
|
</dialog>;
|
|
}
|