feat: deliver the redesigned desktop workspace
Rebuild downloads, link collection, settings, history, and statistics around a responsive desktop shell with compact account and queue tables, contextual navigation, persistent update affordances, unified overlays, and accessible keyboard interactions. Add safe history-folder reveal IPC, responsive 2560/1920/1366/1120 coverage, deterministic visual fixtures, focused component regressions, and release-tree exclusions for internal working files. Bump the public application version to 2.0.13.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { Children, Fragment, isValidElement, useId, type ReactElement, type ReactNode } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export interface ContextInfoButtonProps {
|
||||
contextName: string;
|
||||
content: ReactNode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function hasRenderableContent(content: ReactNode): boolean {
|
||||
if (content === null || content === undefined || typeof content === "boolean") {
|
||||
return false;
|
||||
}
|
||||
if (typeof content === "string") {
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
if (typeof content === "number") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content.some(hasRenderableContent);
|
||||
}
|
||||
if (isValidElement<{ children?: ReactNode }>(content) && content.type === Fragment) {
|
||||
return hasRenderableContent(content.props.children);
|
||||
}
|
||||
if (isValidElement(content)) {
|
||||
return true;
|
||||
}
|
||||
return Children.toArray(content).some(hasRenderableContent);
|
||||
}
|
||||
|
||||
export function ContextInfoButton({
|
||||
contextName,
|
||||
content,
|
||||
open,
|
||||
onOpenChange
|
||||
}: ContextInfoButtonProps): ReactElement | null {
|
||||
const regionId = useId();
|
||||
|
||||
if (!hasRenderableContent(content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-context-info">
|
||||
<button
|
||||
aria-controls={regionId}
|
||||
aria-expanded={open}
|
||||
aria-label="Informationen"
|
||||
className="ui-context-info-trigger"
|
||||
onClick={() => onOpenChange(!open)}
|
||||
title="Informationen"
|
||||
type="button"
|
||||
>
|
||||
<Icon name="info" size={18} />
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
aria-label={`Informationen zu ${contextName}`}
|
||||
className="ui-context-info-region"
|
||||
id={regionId}
|
||||
role="region"
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type RefObject
|
||||
} from "react";
|
||||
import { restoreFocus } from "./focus";
|
||||
|
||||
const useImmediateEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
export interface ContextMenuProps {
|
||||
open: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
ignoreOutsideRefs?: Array<RefObject<HTMLElement>>;
|
||||
}
|
||||
|
||||
export type ContextMenuKeyboardAction =
|
||||
| { type: "focus"; index: number }
|
||||
| { type: "activate"; index: number }
|
||||
| { type: "close" };
|
||||
|
||||
export type ContextMenuSubmenuKeyboardAction = "open" | "close";
|
||||
|
||||
export function clampContextMenuPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: Math.max(0, Math.min(x, Math.max(0, viewportWidth - width))),
|
||||
y: Math.max(0, Math.min(y, Math.max(0, viewportHeight - height)))
|
||||
};
|
||||
}
|
||||
|
||||
export function getContextSubmenuPosition(
|
||||
trigger: { left: number; right: number; top: number },
|
||||
submenu: { width: number; height: number },
|
||||
viewport: { width: number; height: number }
|
||||
): { x: number; y: number } {
|
||||
const opensRight = trigger.right + submenu.width <= viewport.width || trigger.left - submenu.width < 0;
|
||||
return clampContextMenuPosition(
|
||||
opensRight ? trigger.right : trigger.left - submenu.width,
|
||||
trigger.top,
|
||||
submenu.width,
|
||||
submenu.height,
|
||||
viewport.width,
|
||||
viewport.height
|
||||
);
|
||||
}
|
||||
|
||||
export function getContextMenuKeyboardAction(
|
||||
key: string,
|
||||
currentIndex: number,
|
||||
enabled: boolean[]
|
||||
): ContextMenuKeyboardAction | null {
|
||||
const indexes = enabled.flatMap((value, index) => value ? [index] : []);
|
||||
if (key === "Escape") {
|
||||
return { type: "close" };
|
||||
}
|
||||
if (indexes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (key === "Enter" || key === " ") {
|
||||
return { type: "activate", index: enabled[currentIndex] ? currentIndex : indexes[0] };
|
||||
}
|
||||
if (key === "Home") {
|
||||
return { type: "focus", index: indexes[0] };
|
||||
}
|
||||
if (key === "End") {
|
||||
return { type: "focus", index: indexes[indexes.length - 1] };
|
||||
}
|
||||
if (key !== "ArrowDown" && key !== "ArrowUp") {
|
||||
return null;
|
||||
}
|
||||
const enabledPosition = indexes.indexOf(currentIndex);
|
||||
if (enabledPosition < 0) {
|
||||
return { type: "focus", index: key === "ArrowDown" ? indexes[0] : indexes[indexes.length - 1] };
|
||||
}
|
||||
const direction = key === "ArrowDown" ? 1 : -1;
|
||||
const nextPosition = (enabledPosition + direction + indexes.length) % indexes.length;
|
||||
return { type: "focus", index: indexes[nextPosition] };
|
||||
}
|
||||
|
||||
export function getContextMenuSubmenuKeyboardAction(
|
||||
key: string,
|
||||
hasSubmenu: boolean,
|
||||
insideSubmenu: boolean
|
||||
): ContextMenuSubmenuKeyboardAction | null {
|
||||
if (hasSubmenu && (key === "Enter" || key === "ArrowRight")) {
|
||||
return "open";
|
||||
}
|
||||
if (insideSubmenu && (key === "ArrowLeft" || key === "Escape")) {
|
||||
return "close";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyMenuItemSemantics(node: ReactNode): ReactNode {
|
||||
return Children.map(node, (child) => {
|
||||
if (!isValidElement(child)) {
|
||||
return child;
|
||||
}
|
||||
const element = child as ReactElement<{
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
role?: string;
|
||||
tabIndex?: number;
|
||||
}>;
|
||||
if (typeof element.type === "string" && element.type === "button") {
|
||||
return cloneElement(element, { role: "menuitem", tabIndex: -1 });
|
||||
}
|
||||
if (element.props.children === undefined) {
|
||||
return element;
|
||||
}
|
||||
return cloneElement(element, { children: applyMenuItemSemantics(element.props.children) });
|
||||
});
|
||||
}
|
||||
|
||||
function getMenuItems(menu: HTMLElement | null): HTMLElement[] {
|
||||
return Array.from(menu?.querySelectorAll<HTMLElement>("[role='menuitem']") ?? []).filter((item) => {
|
||||
if (item.matches(":disabled") || item.getAttribute("aria-disabled") === "true") {
|
||||
return false;
|
||||
}
|
||||
return item.getClientRects().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function getTopLevelMenuItems(menu: HTMLElement | null): HTMLElement[] {
|
||||
return getMenuItems(menu).filter((item) => !item.closest(".ctx-menu-sub-items"));
|
||||
}
|
||||
|
||||
function getSubmenuParts(item: HTMLElement | null): {
|
||||
container: HTMLElement;
|
||||
trigger: HTMLElement;
|
||||
items: HTMLElement;
|
||||
} | null {
|
||||
const container = item?.closest<HTMLElement>(".ctx-menu-sub") ?? null;
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
const trigger = Array.from(container.children).find((child) => child.matches("[role='menuitem']"));
|
||||
const items = Array.from(container.children).find((child) => child.matches(".ctx-menu-sub-items"));
|
||||
if (!(trigger instanceof HTMLElement) || !(items instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
return { container, trigger, items };
|
||||
}
|
||||
|
||||
function openSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
|
||||
if (!parts) {
|
||||
return;
|
||||
}
|
||||
parts.container.classList.add("is-keyboard-open");
|
||||
parts.trigger.setAttribute("aria-expanded", "true");
|
||||
positionSubmenu(parts);
|
||||
getMenuItems(parts.items)[0]?.focus();
|
||||
}
|
||||
|
||||
function positionSubmenu(parts: NonNullable<ReturnType<typeof getSubmenuParts>>): void {
|
||||
const triggerRect = parts.trigger.getBoundingClientRect();
|
||||
const submenuRect = parts.items.getBoundingClientRect();
|
||||
const position = getContextSubmenuPosition(
|
||||
triggerRect,
|
||||
submenuRect,
|
||||
{ width: window.innerWidth, height: window.innerHeight }
|
||||
);
|
||||
parts.items.style.position = "fixed";
|
||||
parts.items.style.left = `${position.x}px`;
|
||||
parts.items.style.top = `${position.y}px`;
|
||||
}
|
||||
|
||||
function closeSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
|
||||
if (!parts) {
|
||||
return;
|
||||
}
|
||||
parts.container.classList.remove("is-keyboard-open");
|
||||
parts.trigger.setAttribute("aria-expanded", "false");
|
||||
parts.trigger.focus();
|
||||
}
|
||||
|
||||
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(function ContextMenu({
|
||||
open,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
children,
|
||||
ariaLabel = "Kontextmenü",
|
||||
className = "",
|
||||
ignoreOutsideRefs = []
|
||||
}, forwardedRef): ReactElement | null {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const ignoreOutsideRefsRef = useRef(ignoreOutsideRefs);
|
||||
const [position, setPosition] = useState({ x, y });
|
||||
onCloseRef.current = onClose;
|
||||
ignoreOutsideRefsRef.current = ignoreOutsideRefs;
|
||||
useImperativeHandle(forwardedRef, () => menuRef.current as HTMLDivElement);
|
||||
|
||||
useImmediateEffect(() => {
|
||||
if (!open || !menuRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!previousFocusRef.current) {
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
}
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
const next = clampContextMenuPosition(x, y, rect.width, rect.height, window.innerWidth, window.innerHeight);
|
||||
setPosition((current) => current.x === next.x && current.y === next.y ? current : next);
|
||||
getTopLevelMenuItems(menuRef.current)[0]?.focus();
|
||||
}, [open, x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const onOutside = (event: MouseEvent): void => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node) || menuRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if (ignoreOutsideRefsRef.current.some((ref) => ref.current?.contains(target))) {
|
||||
return;
|
||||
}
|
||||
onCloseRef.current();
|
||||
};
|
||||
window.addEventListener("mousedown", onOutside);
|
||||
window.addEventListener("contextmenu", onOutside);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", onOutside);
|
||||
window.removeEventListener("contextmenu", onOutside);
|
||||
const previousFocus = previousFocusRef.current;
|
||||
previousFocusRef.current = null;
|
||||
restoreFocus(previousFocus);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const activeItem = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const submenu = getSubmenuParts(activeItem);
|
||||
const insideSubmenu = Boolean(activeItem?.closest(".ctx-menu-sub-items"));
|
||||
const hasSubmenu = submenu?.trigger === activeItem;
|
||||
const submenuAction = getContextMenuSubmenuKeyboardAction(event.key, hasSubmenu, insideSubmenu);
|
||||
if (submenuAction) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (submenuAction === "open") {
|
||||
openSubmenu(submenu);
|
||||
} else {
|
||||
closeSubmenu(submenu);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const submenuItems = insideSubmenu ? activeItem?.closest<HTMLElement>(".ctx-menu-sub-items") ?? null : null;
|
||||
const items = submenuItems ? getMenuItems(submenuItems) : getTopLevelMenuItems(menuRef.current);
|
||||
const currentIndex = items.findIndex((item) => item === document.activeElement);
|
||||
const action = getContextMenuKeyboardAction(event.key, currentIndex, items.map(() => true));
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (action.type === "close") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (action.type === "activate") {
|
||||
items[action.index]?.click();
|
||||
return;
|
||||
}
|
||||
items[action.index]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={ariaLabel}
|
||||
className={["ctx-menu", "md-context-menu", className].filter(Boolean).join(" ")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const item = event.target instanceof Element ? event.target.closest<HTMLElement>("[role='menuitem']") : null;
|
||||
const submenu = getSubmenuParts(item);
|
||||
if (submenu?.trigger === item) {
|
||||
event.preventDefault();
|
||||
openSubmenu(submenu);
|
||||
}
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseOver={(event) => {
|
||||
const item = event.target instanceof Element ? event.target.closest<HTMLElement>("[role='menuitem']") : null;
|
||||
const submenu = getSubmenuParts(item);
|
||||
if (submenu?.trigger === item) {
|
||||
positionSubmenu(submenu);
|
||||
}
|
||||
}}
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
{applyMenuItemSemantics(children)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
|
||||
export interface DataTableProps extends HTMLAttributes<HTMLDivElement> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export type DataTableSectionProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export interface DataTableEmptyProps extends HTMLAttributes<HTMLDivElement> {
|
||||
title: string;
|
||||
description?: string;
|
||||
illustration?: ReactNode;
|
||||
}
|
||||
|
||||
export interface DataTableFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||
pageSize: number;
|
||||
rangeLabel: string;
|
||||
paginationVisible: boolean;
|
||||
}
|
||||
|
||||
export function DataTable({ label = "Datentabelle", className, children, ...props }: DataTableProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
aria-label={label}
|
||||
className={["ui-data-table", className].filter(Boolean).join(" ")}
|
||||
role="table"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableHeader({ className, children, ...props }: DataTableSectionProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={["ui-data-table-header", className].filter(Boolean).join(" ")}
|
||||
data-ui-region="table-header"
|
||||
role="rowgroup"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableBody({ className, children, ...props }: DataTableSectionProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={["ui-data-table-body", className].filter(Boolean).join(" ")}
|
||||
data-ui-region="table-body"
|
||||
role="rowgroup"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableEmpty({
|
||||
title,
|
||||
description,
|
||||
illustration,
|
||||
className,
|
||||
...props
|
||||
}: DataTableEmptyProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={["ui-data-table-empty", className].filter(Boolean).join(" ")}
|
||||
role="row"
|
||||
>
|
||||
<div className="ui-data-table-empty-cell" role="cell">
|
||||
{illustration ? <div className="ui-data-table-empty-illustration">{illustration}</div> : null}
|
||||
<strong className="ui-data-table-empty-title">{title}</strong>
|
||||
{description ? <span className="ui-data-table-empty-description">{description}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableFooter({
|
||||
pageSize,
|
||||
rangeLabel,
|
||||
paginationVisible,
|
||||
className,
|
||||
...props
|
||||
}: DataTableFooterProps): ReactElement | null {
|
||||
if (!paginationVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
aria-label="Seitennavigation"
|
||||
className={["ui-data-table-footer", className].filter(Boolean).join(" ")}
|
||||
role="navigation"
|
||||
>
|
||||
<span className="ui-data-table-page-size">{pageSize} pro Seite</span>
|
||||
<span className="ui-data-table-range" aria-live="polite">{rangeLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type KeyboardEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type RefObject
|
||||
} from "react";
|
||||
import { getConnectedFocusTarget, restoreFocus } from "./focus";
|
||||
|
||||
const useImmediateEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
|
||||
const focusableSelector = "button:not([disabled]), summary, [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
|
||||
|
||||
let activeDialogCount = 0;
|
||||
let blockedShells: Map<HTMLElement, { inert: boolean; ariaHidden: string | null }> | null = null;
|
||||
|
||||
export type DialogSize = "default" | "account" | "update" | "wide";
|
||||
|
||||
export interface DialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
size?: DialogSize;
|
||||
danger?: boolean;
|
||||
initialFocusRef?: RefObject<HTMLElement>;
|
||||
restoreFocusTarget?: HTMLElement | null;
|
||||
restoreFocusFallback?: () => HTMLElement | null;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
actions: ReactNode;
|
||||
closable?: boolean;
|
||||
showCloseButton?: boolean;
|
||||
className?: string;
|
||||
backdropClassName?: string;
|
||||
headerClassName?: string;
|
||||
bodyClassName?: string;
|
||||
actionsClassName?: string;
|
||||
}
|
||||
|
||||
export type DialogKeyboardAction = { type: "focus"; index: number } | { type: "close" };
|
||||
|
||||
export function getDialogFocusTarget(shiftKey: boolean, currentIndex: number, itemCount: number): number | null {
|
||||
if (itemCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (currentIndex < 0) {
|
||||
return shiftKey ? itemCount - 1 : 0;
|
||||
}
|
||||
if (shiftKey && currentIndex === 0) {
|
||||
return itemCount - 1;
|
||||
}
|
||||
if (!shiftKey && currentIndex === itemCount - 1) {
|
||||
return 0;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getDialogInitialFocusTarget(
|
||||
dialog: HTMLElement | null,
|
||||
explicitTarget: HTMLElement | null,
|
||||
activeTarget: HTMLElement | null = null
|
||||
): HTMLElement | null {
|
||||
if (explicitTarget) {
|
||||
return explicitTarget;
|
||||
}
|
||||
if (activeTarget && dialog?.contains(activeTarget)) {
|
||||
return activeTarget;
|
||||
}
|
||||
return dialog?.querySelector<HTMLElement>("[autofocus]") ?? dialog;
|
||||
}
|
||||
|
||||
export function getDialogRestoreFocusTarget(
|
||||
dialog: HTMLElement | null,
|
||||
activeTarget: HTMLElement | null
|
||||
): HTMLElement | null {
|
||||
if (!activeTarget || dialog?.contains(activeTarget)) {
|
||||
return null;
|
||||
}
|
||||
return activeTarget;
|
||||
}
|
||||
|
||||
export function getConnectedDialogRestoreTarget(
|
||||
previousTarget: HTMLElement | null,
|
||||
fallbackTarget: HTMLElement | null
|
||||
): HTMLElement | null {
|
||||
return getConnectedFocusTarget(previousTarget, fallbackTarget);
|
||||
}
|
||||
|
||||
export function getDialogKeyboardAction(
|
||||
key: string,
|
||||
shiftKey: boolean,
|
||||
currentIndex: number,
|
||||
itemCount: number,
|
||||
closable: boolean
|
||||
): DialogKeyboardAction | null {
|
||||
if (key === "Escape") {
|
||||
return closable ? { type: "close" } : null;
|
||||
}
|
||||
if (key !== "Tab" || itemCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
const target = getDialogFocusTarget(shiftKey, currentIndex, itemCount);
|
||||
return target === null ? null : { type: "focus", index: target };
|
||||
}
|
||||
|
||||
function blockShell(): () => void {
|
||||
if (activeDialogCount === 0) {
|
||||
blockedShells = new Map();
|
||||
document.querySelectorAll<HTMLElement>(".md-shell").forEach((shell) => {
|
||||
blockedShells?.set(shell, {
|
||||
inert: shell.hasAttribute("inert"),
|
||||
ariaHidden: shell.getAttribute("aria-hidden")
|
||||
});
|
||||
shell.setAttribute("inert", "");
|
||||
shell.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
}
|
||||
activeDialogCount += 1;
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
activeDialogCount = Math.max(0, activeDialogCount - 1);
|
||||
if (activeDialogCount !== 0 || !blockedShells) {
|
||||
return;
|
||||
}
|
||||
for (const [shell, state] of blockedShells) {
|
||||
if (!state.inert) {
|
||||
shell.removeAttribute("inert");
|
||||
}
|
||||
if (state.ariaHidden === null) {
|
||||
shell.removeAttribute("aria-hidden");
|
||||
} else {
|
||||
shell.setAttribute("aria-hidden", state.ariaHidden);
|
||||
}
|
||||
}
|
||||
blockedShells = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
size = "default",
|
||||
danger = false,
|
||||
initialFocusRef,
|
||||
restoreFocusTarget,
|
||||
restoreFocusFallback,
|
||||
onClose,
|
||||
children,
|
||||
actions,
|
||||
closable = true,
|
||||
showCloseButton = false,
|
||||
className = "",
|
||||
backdropClassName = "",
|
||||
headerClassName = "",
|
||||
bodyClassName = "",
|
||||
actionsClassName = ""
|
||||
}: DialogProps): ReactElement | null {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const backdropAttachedRef = useRef(false);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
const restoreFocusFallbackRef = useRef<() => HTMLElement | null>(() => null);
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
restoreFocusFallbackRef.current = restoreFocusFallback ?? (() => null);
|
||||
|
||||
if (open && !previousFocusRef.current && restoreFocusTarget) {
|
||||
previousFocusRef.current = getDialogRestoreFocusTarget(dialogRef.current, restoreFocusTarget);
|
||||
}
|
||||
|
||||
const captureBackdropRef = useCallback((node: HTMLDivElement | null): void => {
|
||||
if (node && !backdropAttachedRef.current && !previousFocusRef.current) {
|
||||
const activeTarget = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
previousFocusRef.current = getDialogRestoreFocusTarget(dialogRef.current, activeTarget);
|
||||
}
|
||||
backdropAttachedRef.current = Boolean(node);
|
||||
}, []);
|
||||
|
||||
useImmediateEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const releaseShell = blockShell();
|
||||
const activeTarget = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const focusTarget = getDialogInitialFocusTarget(dialogRef.current, initialFocusRef?.current ?? null, activeTarget);
|
||||
focusTarget?.focus();
|
||||
return () => {
|
||||
releaseShell();
|
||||
const previousFocus = previousFocusRef.current;
|
||||
const fallbackFocus = restoreFocusFallbackRef.current();
|
||||
previousFocusRef.current = null;
|
||||
restoreFocus(previousFocus, fallbackFocus);
|
||||
};
|
||||
}, [initialFocusRef, open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const focusable = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
|
||||
const currentIndex = focusable.findIndex((element) => element === document.activeElement);
|
||||
const action = getDialogKeyboardAction(event.key, event.shiftKey, currentIndex, focusable.length, closable);
|
||||
if (!action) {
|
||||
if (event.key === "Tab" && focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (action.type === "close") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
focusable[action.index]?.focus();
|
||||
};
|
||||
|
||||
const dialogClasses = [
|
||||
"modal-card",
|
||||
"md-dialog",
|
||||
`md-dialog-size-${size}`,
|
||||
danger ? "is-danger" : "",
|
||||
className
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={["modal-backdrop", "md-dialog-backdrop", backdropClassName].filter(Boolean).join(" ")}
|
||||
onClick={() => {
|
||||
if (closable) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
ref={captureBackdropRef}
|
||||
>
|
||||
<div
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className={dialogClasses}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={onKeyDown}
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className={["md-dialog-header", headerClassName].filter(Boolean).join(" ")}>
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
{showCloseButton && closable ? (
|
||||
<button aria-label="Schließen" className="md-dialog-close" onClick={onClose} type="button">×</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={["md-dialog-body", bodyClassName].filter(Boolean).join(" ")}>
|
||||
{description ? <p className="md-dialog-description" id={descriptionId}>{description}</p> : null}
|
||||
{children}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className={["modal-actions", "md-dialog-actions", actionsClassName].filter(Boolean).join(" ")}>{actions}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ReactElement, SVGProps } from "react";
|
||||
|
||||
export const ICON_NAMES = [
|
||||
"download",
|
||||
"collector",
|
||||
"settings",
|
||||
"history",
|
||||
"statistics",
|
||||
"add",
|
||||
"search",
|
||||
"play",
|
||||
"pause",
|
||||
"stop",
|
||||
"arrow-up",
|
||||
"arrow-down",
|
||||
"refresh",
|
||||
"check",
|
||||
"edit",
|
||||
"trash",
|
||||
"filter",
|
||||
"folder",
|
||||
"info",
|
||||
"more",
|
||||
"chevron-left",
|
||||
"chevron-right",
|
||||
"chevron-down",
|
||||
"close",
|
||||
"menu",
|
||||
"help",
|
||||
"backup",
|
||||
"update",
|
||||
"account"
|
||||
] as const;
|
||||
|
||||
export type IconName = (typeof ICON_NAMES)[number];
|
||||
|
||||
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
|
||||
name: IconName;
|
||||
label?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
function IconDrawing({ name }: { name: IconName }): ReactElement {
|
||||
switch (name) {
|
||||
case "download":
|
||||
return <><path d="M12 3v12" /><path d="m7 10 5 5 5-5" /><path d="M5 21h14" /></>;
|
||||
case "collector":
|
||||
return <><path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1" /><path d="M14 11a5 5 0 0 0-7.1-.1l-2 2a5 5 0 0 0 7.1 7.1l1.1-1.1" /></>;
|
||||
case "settings":
|
||||
return <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" /></>;
|
||||
case "history":
|
||||
return <><path d="M3 12a9 9 0 1 0 3-6.7L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l3 2" /></>;
|
||||
case "statistics":
|
||||
return <><path d="M4 20V10" /><path d="M10 20V4" /><path d="M16 20v-7" /><path d="M22 20V7" /></>;
|
||||
case "add":
|
||||
return <><path d="M12 5v14" /><path d="M5 12h14" /></>;
|
||||
case "search":
|
||||
return <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>;
|
||||
case "play":
|
||||
return <path d="m8 5 11 7-11 7Z" />;
|
||||
case "pause":
|
||||
return <><path d="M9 5v14" /><path d="M15 5v14" /></>;
|
||||
case "stop":
|
||||
return <rect x="6" y="6" width="12" height="12" rx="1" />;
|
||||
case "arrow-up":
|
||||
return <><path d="m6 10 6-6 6 6" /><path d="M12 4v16" /></>;
|
||||
case "arrow-down":
|
||||
return <><path d="m6 14 6 6 6-6" /><path d="M12 20V4" /></>;
|
||||
case "refresh":
|
||||
return <><path d="M20 7v5h-5" /><path d="M4 17v-5h5" /><path d="M6.1 9a7 7 0 0 1 11.5-2L20 9" /><path d="m4 15 2.4 2a7 7 0 0 0 11.5-2" /></>;
|
||||
case "check":
|
||||
return <path d="m5 12 4 4L19 6" />;
|
||||
case "edit":
|
||||
return <><path d="M4 20h4L19 9l-4-4L4 16v4Z" /><path d="m13 7 4 4" /></>;
|
||||
case "trash":
|
||||
return <><path d="M4 7h16" /><path d="M9 7V4h6v3" /><path d="m6 7 1 14h10l1-14" /><path d="M10 11v6" /><path d="M14 11v6" /></>;
|
||||
case "filter":
|
||||
return <path d="M4 5h16l-6 7v6l-4 2v-8Z" />;
|
||||
case "folder":
|
||||
return <path d="M3 6h7l2 2h9v11H3Z" />;
|
||||
case "info":
|
||||
return <><circle cx="12" cy="12" r="9" /><path d="M12 11v6" /><path d="M12 7h.01" /></>;
|
||||
case "more":
|
||||
return <><circle cx="5" cy="12" r="1" fill="currentColor" stroke="none" /><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" /><circle cx="19" cy="12" r="1" fill="currentColor" stroke="none" /></>;
|
||||
case "chevron-left":
|
||||
return <path d="m15 18-6-6 6-6" />;
|
||||
case "chevron-right":
|
||||
return <path d="m9 18 6-6-6-6" />;
|
||||
case "chevron-down":
|
||||
return <path d="m6 9 6 6 6-6" />;
|
||||
case "close":
|
||||
return <><path d="m6 6 12 12" /><path d="M18 6 6 18" /></>;
|
||||
case "menu":
|
||||
return <><path d="M4 6h16" /><path d="M4 12h16" /><path d="M4 18h16" /></>;
|
||||
case "help":
|
||||
return <><circle cx="12" cy="12" r="9" /><path d="M9.8 9a2.4 2.4 0 1 1 3.7 2c-1 .6-1.5 1.1-1.5 2" /><path d="M12 17h.01" /></>;
|
||||
case "backup":
|
||||
return <><path d="M6 4h10l3 3v13H6Z" /><path d="M9 4v6h6V4" /><path d="M9 20v-6h6v6" /></>;
|
||||
case "update":
|
||||
return <><path d="M12 21a9 9 0 1 0-8.5-6" /><path d="M3 15v6h6" /><path d="M12 7v5l3 2" /></>;
|
||||
case "account":
|
||||
return <><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></>;
|
||||
}
|
||||
}
|
||||
|
||||
export function Icon({ name, label, size = 18, className, ...props }: IconProps): ReactElement {
|
||||
const labelled = typeof label === "string" && label.trim().length > 0;
|
||||
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
aria-hidden={labelled ? undefined : true}
|
||||
aria-label={labelled ? label : undefined}
|
||||
className={["ui-icon", className].filter(Boolean).join(" ")}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={size}
|
||||
role={labelled ? "img" : undefined}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.75"
|
||||
viewBox="0 0 24 24"
|
||||
width={size}
|
||||
>
|
||||
<IconDrawing name={name} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
export interface ToastProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function Toast({ message }: ToastProps): ReactElement | null {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
return <div aria-live="polite" className="toast md-toast" role="status">{message}</div>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ChangeEventHandler, HTMLAttributes, InputHTMLAttributes, ReactElement } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export interface ToolbarProps extends HTMLAttributes<HTMLDivElement> {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ToolbarGroupProps extends HTMLAttributes<HTMLDivElement> {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ToolbarSearchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "aria-label"> {
|
||||
label: string;
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export function Toolbar({ label, className, children, ...props }: ToolbarProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
aria-label={label}
|
||||
className={["ui-toolbar", className].filter(Boolean).join(" ")}
|
||||
role="toolbar"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarGroup({ label, className, children, ...props }: ToolbarGroupProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
aria-label={label}
|
||||
className={["ui-toolbar-group", className].filter(Boolean).join(" ")}
|
||||
role="group"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolbarSearch({ label, className, placeholder, ...props }: ToolbarSearchProps): ReactElement {
|
||||
return (
|
||||
<label className="ui-toolbar-search">
|
||||
<Icon className="ui-toolbar-search-icon" name="search" size={16} />
|
||||
<input
|
||||
{...props}
|
||||
aria-label={label}
|
||||
className={["ui-input", "ui-toolbar-search-input", className].filter(Boolean).join(" ")}
|
||||
placeholder={placeholder ?? label}
|
||||
type="search"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type FocusRestoreScheduler = (callback: () => void) => void;
|
||||
|
||||
export function getConnectedFocusTarget(
|
||||
preferredTarget: HTMLElement | null,
|
||||
fallbackTarget: HTMLElement | null = null
|
||||
): HTMLElement | null {
|
||||
if (preferredTarget?.isConnected) {
|
||||
return preferredTarget;
|
||||
}
|
||||
|
||||
return fallbackTarget?.isConnected ? fallbackTarget : null;
|
||||
}
|
||||
|
||||
export function restoreFocus(
|
||||
preferredTarget: HTMLElement | null,
|
||||
fallbackTarget: HTMLElement | null = null,
|
||||
schedule: FocusRestoreScheduler = queueMicrotask
|
||||
): void {
|
||||
schedule(() => {
|
||||
getConnectedFocusTarget(preferredTarget, fallbackTarget)?.focus();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
export type UiTheme = "dark" | "light";
|
||||
|
||||
export const UI_THEME_VARIABLES = [
|
||||
"--ui-canvas",
|
||||
"--ui-surface",
|
||||
"--ui-input",
|
||||
"--ui-table-header",
|
||||
"--ui-active",
|
||||
"--ui-hover",
|
||||
"--ui-tooltip",
|
||||
"--ui-border",
|
||||
"--ui-text",
|
||||
"--ui-text-secondary",
|
||||
"--ui-text-muted",
|
||||
"--ui-primary",
|
||||
"--ui-primary-hover",
|
||||
"--ui-accent",
|
||||
"--ui-warning",
|
||||
"--ui-danger",
|
||||
"--ui-modal-secondary",
|
||||
"--ui-overlay"
|
||||
] as const;
|
||||
|
||||
export type UiThemeVariable = (typeof UI_THEME_VARIABLES)[number];
|
||||
|
||||
export const UI_FOCUS_RING_VARIABLE: UiThemeVariable = "--ui-accent";
|
||||
|
||||
type UiThemeVariables = Readonly<Record<UiThemeVariable, string>>;
|
||||
|
||||
const darkThemeVariables: UiThemeVariables = Object.freeze({
|
||||
"--ui-canvas": "#0F0F0F",
|
||||
"--ui-surface": "#232323",
|
||||
"--ui-input": "#2B2B2B",
|
||||
"--ui-table-header": "#313131",
|
||||
"--ui-active": "#333436",
|
||||
"--ui-hover": "#373535",
|
||||
"--ui-tooltip": "#4F4D4D",
|
||||
"--ui-border": "#3D3D3D",
|
||||
"--ui-text": "#FFFFFF",
|
||||
"--ui-text-secondary": "#EAEDF3",
|
||||
"--ui-text-muted": "#919191",
|
||||
"--ui-primary": "#BAD0FC",
|
||||
"--ui-primary-hover": "#8AA5DC",
|
||||
"--ui-accent": "#3886FF",
|
||||
"--ui-warning": "#F1C786",
|
||||
"--ui-danger": "#F06464",
|
||||
"--ui-modal-secondary": "#35383D",
|
||||
"--ui-overlay": "rgba(0, 0, 0, 0.60)"
|
||||
});
|
||||
|
||||
const lightThemeVariables: UiThemeVariables = Object.freeze({
|
||||
"--ui-canvas": "#F3F4F6",
|
||||
"--ui-surface": "#FFFFFF",
|
||||
"--ui-input": "#F7F8FA",
|
||||
"--ui-table-header": "#E7E9ED",
|
||||
"--ui-active": "#DEE6F5",
|
||||
"--ui-hover": "#E8ECF3",
|
||||
"--ui-tooltip": "#35383D",
|
||||
"--ui-border": "#D0D4DB",
|
||||
"--ui-text": "#181A1F",
|
||||
"--ui-text-secondary": "#343842",
|
||||
"--ui-text-muted": "#667085",
|
||||
"--ui-primary": "#A9C2F3",
|
||||
"--ui-primary-hover": "#8AA5DC",
|
||||
"--ui-accent": "#256FDB",
|
||||
"--ui-warning": "#E8B85D",
|
||||
"--ui-danger": "#D94747",
|
||||
"--ui-modal-secondary": "#E7E9ED",
|
||||
"--ui-overlay": "rgba(0, 0, 0, 0.45)"
|
||||
});
|
||||
|
||||
const themes: Readonly<Record<UiTheme, UiThemeVariables>> = Object.freeze({
|
||||
dark: darkThemeVariables,
|
||||
light: lightThemeVariables
|
||||
});
|
||||
|
||||
export function getThemeVariables(theme: UiTheme): Readonly<Record<string, string>> {
|
||||
return themes[theme];
|
||||
}
|
||||
Reference in New Issue
Block a user