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:
Sucukdeluxe
2026-08-10 14:15:57 +02:00
parent d844b33501
commit 069babfd54
77 changed files with 17442 additions and 3333 deletions
+70
View File
@@ -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>
);
}
+323
View File
@@ -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>
);
});
+104
View File
@@ -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>
);
}
+271
View File
@@ -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>
);
}
+129
View File
@@ -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>
);
}
+12
View File
@@ -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>;
}
+56
View File
@@ -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>
);
}
+22
View File
@@ -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();
});
}
+79
View File
@@ -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];
}