Files
minabot/src/components/design-system/Modal.tsx
syntaxbullet 00729096bf feat: add theme provider and control components for light/dark mode
- 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.
2026-09-05 10:07:28 +02:00

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>;
}