release: publish v2.0.21 responsive accessibility audit
Refine all primary views across supported desktop widths, preserve compact download actions, strengthen keyboard and assistive semantics, confirm irreversible actions, complete new bilingual UI strings, and unify live speed visualization colors.
This commit is contained in:
@@ -77,8 +77,9 @@ export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactE
|
||||
);
|
||||
}
|
||||
|
||||
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
|
||||
return (
|
||||
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
|
||||
const activeTab = model.tabs.find((tab) => tab.id === model.activeTabId) ?? model.tabs[0];
|
||||
return (
|
||||
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
|
||||
<ToolbarGroup label="Links erfassen">
|
||||
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
|
||||
@@ -87,7 +88,7 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE
|
||||
</ToolbarGroup>
|
||||
<ToolbarGroup label="Sammlung verarbeiten">
|
||||
<button className="collector-action" disabled={model.busy} onClick={actions.onExportQueue} type="button">Queue exportieren</button>
|
||||
<button className="collector-action" disabled={model.busy || model.tabs.length === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button>
|
||||
<button className="collector-action" disabled={model.busy || !activeTab || activeTab.linkCount === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button>
|
||||
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
|
||||
</ToolbarGroup>
|
||||
<ToolbarSearch
|
||||
@@ -129,8 +130,8 @@ export function CollectorContent({ model, actions }: CollectorViewProps): ReactE
|
||||
model.rows.map((row) => (
|
||||
<div className={`collector-row${selected.has(row.id) ? " is-selected" : ""}`} key={row.id} role="row">
|
||||
<span className="collector-column-select" role="cell">
|
||||
<input
|
||||
aria-label="Link auswählen"
|
||||
<input
|
||||
aria-label={`${row.value} aus ${row.tabName}, Zeile ${row.lineNumber} auswählen`}
|
||||
checked={selected.has(row.id)}
|
||||
onChange={() => actions.onSelectionChange(row.id)}
|
||||
type="checkbox"
|
||||
|
||||
@@ -130,18 +130,18 @@
|
||||
.collector-action-primary {
|
||||
background: var(--ui-primary);
|
||||
border-color: var(--ui-primary);
|
||||
color: #181A1F;
|
||||
color: var(--ui-primary-text);
|
||||
}
|
||||
|
||||
.collector-action-primary:hover:not(:disabled) {
|
||||
background: var(--ui-primary-hover);
|
||||
color: #181A1F;
|
||||
color: var(--ui-primary-text);
|
||||
}
|
||||
|
||||
.collector-action-danger:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||
color: var(--ui-danger);
|
||||
}
|
||||
.collector-action-danger:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.collector-action:disabled {
|
||||
cursor: default;
|
||||
@@ -172,8 +172,8 @@
|
||||
height: 41px;
|
||||
}
|
||||
|
||||
.collector-table-header-row {
|
||||
color: var(--ui-text-muted);
|
||||
.collector-table-header-row {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
height: 41px;
|
||||
@@ -272,7 +272,7 @@
|
||||
.collector-dialog-primary {
|
||||
background: var(--ui-primary);
|
||||
border-color: var(--ui-primary);
|
||||
color: #181A1F;
|
||||
color: var(--ui-primary-text);
|
||||
}
|
||||
|
||||
.collector-dialog-secondary {
|
||||
@@ -300,8 +300,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.collector-table-header-row,
|
||||
@media (max-width: 1120px) {
|
||||
.collector-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.collector-toolbar .ui-toolbar-search {
|
||||
flex: 1 0 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.collector-table-header-row,
|
||||
.collector-row {
|
||||
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
|
||||
min-width: 610px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement } from "react";
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react";
|
||||
import type { DownloadItem } from "../../../shared/types";
|
||||
import {
|
||||
compactProviderLabels,
|
||||
@@ -47,16 +47,16 @@ function isPackageRowDisclosureExcluded(target: EventTarget | null): boolean {
|
||||
}
|
||||
|
||||
export const downloadColumnDefinitions: Record<string, { label: string; width: string; sortable?: DownloadSortColumn }> = {
|
||||
name: { label: "Name", width: "minmax(290px, 2.3fr)", sortable: "name" },
|
||||
size: { label: "Geladen / Größe", width: "minmax(140px, 1.1fr)", sortable: "size" },
|
||||
progress: { label: "Fortschritt", width: "minmax(105px, 0.85fr)", sortable: "progress" },
|
||||
hoster: { label: "Hoster", width: "minmax(90px, 0.85fr)", sortable: "hoster" },
|
||||
account: { label: "Service", width: "minmax(90px, 0.85fr)" },
|
||||
prio: { label: "Priorität", width: "minmax(85px, 0.8fr)" },
|
||||
name: { label: "Name", width: "minmax(var(--downloads-name-min, 290px), 2.3fr)", sortable: "name" },
|
||||
size: { label: "Geladen / Größe", width: "minmax(var(--downloads-size-min, 140px), 1.1fr)", sortable: "size" },
|
||||
progress: { label: "Fortschritt", width: "minmax(var(--downloads-progress-min, 105px), 0.85fr)", sortable: "progress" },
|
||||
hoster: { label: "Hoster", width: "minmax(var(--downloads-hoster-min, 90px), 0.85fr)", sortable: "hoster" },
|
||||
account: { label: "Service", width: "minmax(var(--downloads-service-min, 90px), 0.85fr)" },
|
||||
prio: { label: "Priorität", width: "minmax(var(--downloads-priority-min, 85px), 0.8fr)" },
|
||||
status: { label: "Status", width: "minmax(var(--downloads-status-min, 210px), 1.2fr)" },
|
||||
speed: { label: "Geschwindigkeit", width: "minmax(120px, 1fr)" },
|
||||
availability: { label: "Verfügbarkeit", width: "minmax(110px, 1fr)" },
|
||||
added: { label: "Hinzugefügt am", width: "minmax(135px, 1fr)" }
|
||||
speed: { label: "Geschwindigkeit", width: "minmax(var(--downloads-speed-min, 120px), 1fr)" },
|
||||
availability: { label: "Verfügbarkeit", width: "minmax(var(--downloads-availability-min, 110px), 1fr)" },
|
||||
added: { label: "Hinzugefügt am", width: "minmax(var(--downloads-added-min, 135px), 1fr)" }
|
||||
};
|
||||
|
||||
export type AvailabilityState = "online" | "partial" | "offline" | "checking";
|
||||
@@ -103,14 +103,21 @@ export interface DownloadsTableActions {
|
||||
onMovePackageDown: (packageId: string) => void;
|
||||
onRemoveItem: (itemId: string) => void;
|
||||
onOpenContextMenu: (id: string, x: number, y: number, packageId?: string) => void;
|
||||
onColumnPointerDown: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onColumnPointerMove: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onColumnPointerUp: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onColumnPointerCancel: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
|
||||
onColumnPointerDown: (column: string, event: DownloadColumnPointerInput) => void;
|
||||
onColumnPointerMove: (column: string, event: DownloadColumnPointerInput) => void;
|
||||
onColumnPointerUp: (column: string, event: DownloadColumnPointerInput) => void;
|
||||
onColumnPointerCancel: (column: string, event: DownloadColumnPointerInput) => void;
|
||||
onColumnContextMenu: (column: string, x: number, y: number) => void;
|
||||
onSortColumn: (column: DownloadSortColumn) => void;
|
||||
}
|
||||
|
||||
export interface DownloadColumnPointerInput {
|
||||
clientX: number;
|
||||
currentTarget: HTMLDivElement;
|
||||
pointerId: number;
|
||||
preventDefault: () => void;
|
||||
}
|
||||
|
||||
function displayedStatus(item: DownloadItem, sessionRunning: boolean): string {
|
||||
const value = item.fullStatus.trim();
|
||||
if (value === "Wartet") return "";
|
||||
@@ -548,15 +555,33 @@ export interface DownloadsTableHeaderProps {
|
||||
visibleIds: string[];
|
||||
}
|
||||
|
||||
function moveColumnWithPointerActions(column: string, direction: -1 | 1, element: HTMLDivElement, actions: DownloadsTableActions): void {
|
||||
const sibling = direction < 0 ? element.previousElementSibling : element.nextElementSibling;
|
||||
if (!sibling?.matches(".downloads-column-header")) return;
|
||||
const currentRect = element.getBoundingClientRect();
|
||||
const siblingRect = sibling.getBoundingClientRect();
|
||||
const startX = currentRect.left + currentRect.width / 2;
|
||||
const clientX = siblingRect.left + siblingRect.width / 2 + direction;
|
||||
const pointerId = -1;
|
||||
const pointerEvent = (x: number): DownloadColumnPointerInput => ({ clientX: x, currentTarget: element, pointerId, preventDefault: () => {} });
|
||||
actions.onColumnPointerDown(column, pointerEvent(startX));
|
||||
actions.onColumnPointerMove(column, pointerEvent(clientX));
|
||||
actions.onColumnPointerUp(column, pointerEvent(clientX));
|
||||
}
|
||||
|
||||
export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement {
|
||||
const allSelected = visibleIds.length > 0 && selectedCount === visibleIds.length;
|
||||
const mixedSelection = selectedCount > 0 && selectedCount < visibleIds.length;
|
||||
return (
|
||||
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}>
|
||||
<span className="downloads-selection-cell" role="columnheader"><input aria-label="Alle sichtbaren Downloads auswählen" checked={visibleIds.length > 0 && selectedCount === visibleIds.length} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} type="checkbox" /></span>
|
||||
{columnOrder.map((column) => {
|
||||
<span className="downloads-selection-cell" role="columnheader"><input aria-checked={mixedSelection ? "mixed" : allSelected} aria-label="Alle sichtbaren Downloads auswählen" checked={allSelected} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} ref={(input) => { if (input) input.indeterminate = mixedSelection; }} type="checkbox" /></span>
|
||||
{columnOrder.map((column, index) => {
|
||||
const definition = downloadColumnDefinitions[column];
|
||||
if (!definition) return null;
|
||||
const ariaSort = definition.sortable ? sortColumn === definition.sortable ? sortDirection === "asc" ? "ascending" : "descending" : "none" : undefined;
|
||||
return (
|
||||
<div
|
||||
aria-sort={ariaSort}
|
||||
className="downloads-column-header"
|
||||
data-download-column={column}
|
||||
key={column}
|
||||
@@ -575,8 +600,12 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC
|
||||
role="columnheader"
|
||||
>
|
||||
{definition.sortable
|
||||
? <button onClick={() => actions.onSortColumn(definition.sortable!)} type="button">{definition.label}{sortColumn === definition.sortable ? sortDirection === "asc" ? " ↑" : " ↓" : ""}</button>
|
||||
: definition.label}
|
||||
? <button className="downloads-column-sort" onClick={() => actions.onSortColumn(definition.sortable!)} type="button">{definition.label}{sortColumn === definition.sortable ? sortDirection === "asc" ? " ↑" : " ↓" : ""}</button>
|
||||
: <span className="downloads-column-label">{definition.label}</span>}
|
||||
<span aria-label={`${definition.label} verschieben`} className="downloads-column-move-controls" onPointerDown={(event) => event.stopPropagation()} role="group">
|
||||
{index > 0 ? <button aria-label={`${definition.label} nach links verschieben`} onClick={(event) => { event.stopPropagation(); const element = event.currentTarget.closest<HTMLDivElement>(".downloads-column-header"); if (element) moveColumnWithPointerActions(column, -1, element, actions); }} type="button">←</button> : null}
|
||||
{index < columnOrder.length - 1 ? <button aria-label={`${definition.label} nach rechts verschieben`} onClick={(event) => { event.stopPropagation(); const element = event.currentTarget.closest<HTMLDivElement>(".downloads-column-header"); if (element) moveColumnWithPointerActions(column, 1, element, actions); }} type="button">→</button> : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -86,7 +86,7 @@ export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewAct
|
||||
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
|
||||
<div className="downloads-mode-title">Pakete</div>
|
||||
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical">
|
||||
{filters.map((filter) => <button className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
|
||||
{filters.map((filter) => <button aria-current={model.filter === filter.id ? "page" : undefined} className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
|
||||
</SlidingSelection>
|
||||
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" disabled={model.providerOptions.length <= 1} onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
|
||||
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
|
||||
|
||||
@@ -200,7 +200,7 @@
|
||||
.downloads-footer button,
|
||||
.downloads-action-cell button,
|
||||
.downloads-collapse-button,
|
||||
.downloads-column-header button {
|
||||
.downloads-column-sort {
|
||||
min-height: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--ui-border);
|
||||
@@ -261,7 +261,7 @@
|
||||
.downloads-table-body {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 1191px;
|
||||
min-width: var(--downloads-table-min-width, 1191px);
|
||||
}
|
||||
|
||||
.downloads-table-header {
|
||||
@@ -419,6 +419,11 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.downloads-cell-slot > :is(.downloads-status-cell, .downloads-service-cell) {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.downloads-cell,
|
||||
.downloads-name-cell {
|
||||
display: flex;
|
||||
@@ -508,6 +513,7 @@
|
||||
}
|
||||
|
||||
.downloads-column-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -565,7 +571,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.downloads-column-header button {
|
||||
.downloads-column-sort {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
@@ -579,10 +585,50 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.downloads-column-header[data-download-column="name"] button {
|
||||
.downloads-column-header[data-download-column="name"] .downloads-column-sort {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.downloads-column-move-controls {
|
||||
position: absolute;
|
||||
inset: 50% 2px auto auto;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
background: var(--ui-table-header);
|
||||
box-shadow: 0 1px 4px color-mix(in srgb, var(--ui-text) 18%, transparent);
|
||||
pointer-events: none;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.downloads-column-header:hover .downloads-column-move-controls,
|
||||
.downloads-column-header:focus-within .downloads-column-move-controls {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.downloads-column-move-controls button {
|
||||
display: inline-flex;
|
||||
width: 22px;
|
||||
height: 24px;
|
||||
min-height: 24px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-modal-secondary);
|
||||
}
|
||||
|
||||
.downloads-column-move-controls button:hover,
|
||||
.downloads-column-move-controls button:focus-visible {
|
||||
background: var(--ui-hover);
|
||||
}
|
||||
|
||||
.downloads-link-state {
|
||||
flex: 0 0 8px;
|
||||
width: 8px;
|
||||
@@ -642,15 +688,15 @@
|
||||
}
|
||||
|
||||
.downloads-availability.is-online {
|
||||
color: var(--ui-success);
|
||||
color: var(--ui-success-text);
|
||||
}
|
||||
|
||||
.downloads-availability.is-partial {
|
||||
color: var(--ui-warning);
|
||||
color: var(--ui-warning-text);
|
||||
}
|
||||
|
||||
.downloads-availability.is-offline {
|
||||
color: var(--ui-danger);
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.downloads-availability.is-checking {
|
||||
@@ -712,14 +758,33 @@
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
:is(.downloads-status-full, .downloads-status-compact, .downloads-service-full, .downloads-service-compact) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.downloads-status-compact,
|
||||
.downloads-service-compact {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.md-shell.is-compact .downloads-view,
|
||||
.md-shell.is-minimum .downloads-view {
|
||||
.md-shell.is-compact .downloads-content,
|
||||
.md-shell.is-minimum .downloads-content {
|
||||
--downloads-table-min-width: 1016px;
|
||||
--downloads-name-min: 180px;
|
||||
--downloads-size-min: 96px;
|
||||
--downloads-progress-min: 78px;
|
||||
--downloads-hoster-min: 62px;
|
||||
--downloads-service-min: 78px;
|
||||
--downloads-priority-min: 65px;
|
||||
--downloads-status-min: 90px;
|
||||
--downloads-speed-min: 84px;
|
||||
--downloads-availability-min: 82px;
|
||||
--downloads-added-min: 105px;
|
||||
}
|
||||
|
||||
@container (max-width: 150px) {
|
||||
@@ -728,7 +793,7 @@
|
||||
}
|
||||
|
||||
.downloads-status-compact {
|
||||
display: inline;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.downloads-service-full {
|
||||
@@ -736,7 +801,7 @@
|
||||
}
|
||||
|
||||
.downloads-service-compact {
|
||||
display: inline;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { ChangeEvent, MouseEvent, ReactElement } from "react";
|
||||
import { useEffect, useState, type ChangeEvent, type MouseEvent, type ReactElement } from "react";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableBody,
|
||||
DataTableEmpty,
|
||||
DataTableFooter,
|
||||
DataTableHeader
|
||||
} from "../../ui/DataTable";
|
||||
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
|
||||
import { SlidingSelection } from "../../ui/SlidingSelection";
|
||||
import type { HistoryFilter, HistoryRow, HistoryViewModel } from "./history-model";
|
||||
import { paginateHistoryRows, type HistoryFilter, type HistoryPage, type HistoryRow, type HistoryViewModel } from "./history-model";
|
||||
import "./history.css";
|
||||
|
||||
export interface HistoryViewActions {
|
||||
@@ -109,18 +108,65 @@ export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactEleme
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryContent({ model, actions }: HistoryViewProps): ReactElement {
|
||||
export function historyPageStatusLabel(page: HistoryPage): string {
|
||||
return `Seite ${page.page} von ${page.totalPages}`;
|
||||
}
|
||||
|
||||
export function HistoryPagination({
|
||||
page,
|
||||
onPageChange
|
||||
}: {
|
||||
page: HistoryPage;
|
||||
onPageChange: (page: number) => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<nav aria-label="Verlaufsseiten" className="history-pagination" data-visual-region="history-pagination">
|
||||
<span className="history-pagination-size">{page.pageSize} pro Seite</span>
|
||||
<div className="history-pagination-controls">
|
||||
<button
|
||||
aria-label="Vorherige Verlaufsseite"
|
||||
disabled={page.page <= 1}
|
||||
onClick={() => onPageChange(page.page - 1)}
|
||||
type="button"
|
||||
>Zurück</button>
|
||||
<span aria-atomic="true" aria-current="page" aria-live="polite" className="history-pagination-status">
|
||||
<span>{page.rangeLabel}</span>
|
||||
<span>{historyPageStatusLabel(page)}</span>
|
||||
</span>
|
||||
<button
|
||||
aria-label="Nächste Verlaufsseite"
|
||||
disabled={page.page >= page.totalPages}
|
||||
onClick={() => onPageChange(page.page + 1)}
|
||||
type="button"
|
||||
>Vor</button>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
interface HistoryContentPageProps extends HistoryViewProps {
|
||||
page: HistoryPage;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
export function HistoryContentPage({ model, actions, page, onPageChange }: HistoryContentPageProps): ReactElement {
|
||||
const selected = new Set(model.selectedIds);
|
||||
const expanded = new Set(model.expandedIds);
|
||||
const visibleIds = model.rows.map((row) => row.id);
|
||||
const visibleIds = page.rows.map((row) => row.id);
|
||||
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
|
||||
const showEmpty = !model.loading && !model.error && model.rows.length === 0;
|
||||
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
|
||||
? "Noch kein Verlauf"
|
||||
: "Keine passenden Einträge";
|
||||
const announcement = model.loading
|
||||
? { role: "status" as const, live: "polite" as const, message: "Verlauf wird geladen. Die gespeicherten Einträge werden geladen." }
|
||||
: model.error
|
||||
? { role: "alert" as const, live: "assertive" as const, message: `${model.error}. Öffne die Ansicht erneut, um es noch einmal zu versuchen.` }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section aria-label="Verlaufstabelle" className="history-content">
|
||||
<h1 className="history-main-title">Verlauf</h1>
|
||||
<DataTable className="history-table" label="Verlauf">
|
||||
<DataTableHeader className="history-table-header">
|
||||
<div className="history-table-header-row" role="row">
|
||||
@@ -150,7 +196,7 @@ export function HistoryContent({ model, actions }: HistoryViewProps): ReactEleme
|
||||
) : showEmpty ? (
|
||||
<DataTableEmpty description={emptyTitle === "Noch kein Verlauf" ? "Abgeschlossene und gelöschte Pakete erscheinen hier." : "Passe Filter oder Suche an."} title={emptyTitle} />
|
||||
) : (
|
||||
model.rows.map((row) => {
|
||||
page.rows.map((row) => {
|
||||
const isSelected = selected.has(row.id);
|
||||
const isExpanded = expanded.has(row.id);
|
||||
const onContextMenu = (event: MouseEvent<HTMLElement>): void => {
|
||||
@@ -212,23 +258,42 @@ export function HistoryContent({ model, actions }: HistoryViewProps): ReactEleme
|
||||
)}
|
||||
</DataTableBody>
|
||||
</DataTable>
|
||||
{announcement ? (
|
||||
<div role={announcement.role} aria-live={announcement.live} aria-atomic="true" className="history-announcement">
|
||||
{announcement.message}
|
||||
</div>
|
||||
) : null}
|
||||
<HistoryPagination onPageChange={onPageChange} page={page} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryFooter({ model }: Pick<HistoryViewProps, "model">): ReactElement {
|
||||
const count = model.rows.length;
|
||||
function PaginatedHistoryContent({ model, actions }: HistoryViewProps): ReactElement {
|
||||
const [requestedPage, setRequestedPage] = useState(1);
|
||||
const page = paginateHistoryRows(model.rows, requestedPage);
|
||||
|
||||
useEffect(() => {
|
||||
setRequestedPage((current) => current === page.page ? current : page.page);
|
||||
}, [page.page]);
|
||||
|
||||
return (
|
||||
<DataTableFooter
|
||||
className="history-pagination"
|
||||
data-visual-region="history-pagination"
|
||||
pageSize={count}
|
||||
paginationVisible
|
||||
rangeLabel={count === 0 ? "0 von 0" : `1–${count} von ${count}`}
|
||||
<HistoryContentPage
|
||||
actions={actions}
|
||||
model={model}
|
||||
onPageChange={setRequestedPage}
|
||||
page={page}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HistoryContent({ model, actions }: HistoryViewProps): ReactElement {
|
||||
return <PaginatedHistoryContent actions={actions} key={`${model.filter}\u0000${model.query}`} model={model} />;
|
||||
}
|
||||
|
||||
export function HistoryFooter(_props: Pick<HistoryViewProps, "model">): null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function HistoryView({ model, actions }: HistoryViewProps): ReactElement {
|
||||
return (
|
||||
<div className="history-workspace-view">
|
||||
@@ -236,7 +301,6 @@ export function HistoryView({ model, actions }: HistoryViewProps): ReactElement
|
||||
<div className="history-view-main">
|
||||
<HistoryToolbar actions={actions} model={model} />
|
||||
<HistoryContent actions={actions} model={model} />
|
||||
<HistoryFooter model={model} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,17 @@ export interface HistoryViewModel {
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface HistoryPage {
|
||||
rows: HistoryRow[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
rangeLabel: string;
|
||||
}
|
||||
|
||||
export const HISTORY_PAGE_SIZE = 100;
|
||||
|
||||
const providerLabels: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid",
|
||||
megadebrid: "Mega-Debrid",
|
||||
@@ -58,6 +69,7 @@ const statusLabels: Record<HistoryViewStatus, string> = {
|
||||
};
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
|
||||
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
|
||||
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
@@ -92,6 +104,27 @@ function formatDuration(durationSeconds: number): string {
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function paginateHistoryRows(rows: HistoryRow[], requestedPage: number): HistoryPage {
|
||||
const totalItems = rows.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / HISTORY_PAGE_SIZE));
|
||||
const normalizedPage = Number.isFinite(requestedPage) ? Math.trunc(requestedPage) : 1;
|
||||
const page = Math.min(totalPages, Math.max(1, normalizedPage));
|
||||
const startIndex = (page - 1) * HISTORY_PAGE_SIZE;
|
||||
const endIndex = Math.min(totalItems, startIndex + HISTORY_PAGE_SIZE);
|
||||
const rangeLabel = totalItems === 0
|
||||
? "0 von 0"
|
||||
: `${integerFormatter.format(startIndex + 1)}–${integerFormatter.format(endIndex)} von ${integerFormatter.format(totalItems)}`;
|
||||
|
||||
return {
|
||||
rows: rows.slice(startIndex, endIndex),
|
||||
page,
|
||||
pageSize: HISTORY_PAGE_SIZE,
|
||||
totalItems,
|
||||
totalPages,
|
||||
rangeLabel
|
||||
};
|
||||
}
|
||||
|
||||
function localDayStart(timestamp: number): number {
|
||||
const date = new Date(timestamp);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
.history-view-main {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) 60px;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -108,20 +108,46 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-action-danger:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||
color: var(--ui-danger);
|
||||
.history-action-danger:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.history-content {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) 60px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-main-title {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
color: var(--ui-text);
|
||||
display: flex;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
margin: 0;
|
||||
min-height: 44px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.history-announcement {
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.history-content .history-table {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -255,19 +281,19 @@
|
||||
.history-status-completed {
|
||||
background: color-mix(in srgb, var(--ui-success) 16%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ui-success) 60%, var(--ui-border));
|
||||
color: var(--ui-success);
|
||||
color: var(--ui-success-text);
|
||||
}
|
||||
|
||||
.history-status-deleted {
|
||||
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
|
||||
color: var(--ui-danger);
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.history-status-failed {
|
||||
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
|
||||
color: var(--ui-danger);
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.history-row-size,
|
||||
@@ -335,11 +361,56 @@
|
||||
}
|
||||
|
||||
.history-table-error .ui-data-table-empty-title {
|
||||
color: var(--ui-danger);
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.history-pagination {
|
||||
.history-pagination {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--ui-border);
|
||||
box-sizing: border-box;
|
||||
color: var(--ui-text-secondary);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
height: 60px;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px 10px 60px;
|
||||
}
|
||||
|
||||
.history-pagination-size,
|
||||
.history-pagination-status {
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.history-pagination-controls,
|
||||
.history-pagination-status {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.history-pagination button {
|
||||
background: var(--ui-input);
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 5px;
|
||||
color: var(--ui-text);
|
||||
font: inherit;
|
||||
height: 32px;
|
||||
min-width: 72px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.history-pagination button:hover:not(:disabled) {
|
||||
background: var(--ui-hover);
|
||||
}
|
||||
|
||||
.history-pagination button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.md-shell-main:has(.history-content) > .md-shell-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1366px) {
|
||||
|
||||
@@ -14,14 +14,33 @@ import {
|
||||
DataTableHeader
|
||||
} from "../../ui/DataTable";
|
||||
import { Dialog } from "../../ui/Dialog";
|
||||
import {
|
||||
ACCOUNT_COLUMNS,
|
||||
type AccountAddFilter,
|
||||
import {
|
||||
ACCOUNT_COLUMNS,
|
||||
getSettingsSelectNavigationIndex,
|
||||
type AccountAddFilter,
|
||||
type AccountAddOption,
|
||||
type AccountRowViewModel
|
||||
} from "./settings-model";
|
||||
|
||||
export type AccountWorkspacePanel = "overview" | "rules";
|
||||
export type AccountWorkspacePanel = "overview" | "rules";
|
||||
|
||||
const ACCOUNT_WORKSPACE_PANELS: readonly { id: AccountWorkspacePanel; label: string }[] = [
|
||||
{ id: "overview", label: "Übersicht" },
|
||||
{ id: "rules", label: "Verwendungsregeln" }
|
||||
];
|
||||
|
||||
function getAccountPanelNavigationIndex(currentIndex: number, key: string): number | null {
|
||||
if (key === "ArrowRight") {
|
||||
return getSettingsSelectNavigationIndex(currentIndex, ACCOUNT_WORKSPACE_PANELS.length, "ArrowDown");
|
||||
}
|
||||
if (key === "ArrowLeft") {
|
||||
return getSettingsSelectNavigationIndex(currentIndex, ACCOUNT_WORKSPACE_PANELS.length, "ArrowUp");
|
||||
}
|
||||
if (key === "Home" || key === "End") {
|
||||
return getSettingsSelectNavigationIndex(currentIndex, ACCOUNT_WORKSPACE_PANELS.length, key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface AccountRulesViewModel {
|
||||
providerOrder: readonly string[];
|
||||
@@ -253,7 +272,7 @@ function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElemen
|
||||
const selectedIds = new Set(model.selectedIds);
|
||||
return (
|
||||
<>
|
||||
<DataTable className="settings-account-table" label="Accounts">
|
||||
<DataTable aria-busy={model.busy} className="settings-account-table" label="Accounts">
|
||||
<DataTableHeader className="settings-account-table-header">
|
||||
<div className="settings-account-table-grid" role="row">
|
||||
<span aria-label="Aktiviert" className="settings-account-column-enable" role="columnheader" />
|
||||
@@ -412,9 +431,11 @@ function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
|
||||
}
|
||||
|
||||
export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): ReactElement {
|
||||
return (
|
||||
<div className="settings-account-workspace">
|
||||
<header className="settings-account-heading">
|
||||
return (
|
||||
<div className="settings-account-workspace">
|
||||
{model.busy ? <span aria-live="polite" className="settings-visually-hidden" role="status">Accountdaten werden aktualisiert.</span> : null}
|
||||
{model.error ? <span className="settings-visually-hidden" role="alert">{model.error}</span> : null}
|
||||
<header className="settings-account-heading">
|
||||
<div>
|
||||
<h2>Accountverwaltung</h2>
|
||||
<p>Accounts hinzufügen, prüfen und verwalten.</p>
|
||||
@@ -431,27 +452,32 @@ export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): Rea
|
||||
</label>
|
||||
) : null}
|
||||
</header>
|
||||
<SlidingSelection activeKey={model.activePanel} aria-label="Accountverwaltung" axis="horizontal" className="settings-account-tabs" role="tablist">
|
||||
<button
|
||||
aria-controls="settings-account-overview"
|
||||
aria-selected={model.activePanel === "overview"}
|
||||
data-sliding-selection-active={model.activePanel === "overview"}
|
||||
data-sliding-selection-item="true"
|
||||
id="settings-account-overview-tab"
|
||||
onClick={() => actions.onPanelChange("overview")}
|
||||
role="tab"
|
||||
type="button"
|
||||
>Übersicht</button>
|
||||
<button
|
||||
aria-controls="settings-account-rules"
|
||||
aria-selected={model.activePanel === "rules"}
|
||||
data-sliding-selection-active={model.activePanel === "rules"}
|
||||
data-sliding-selection-item="true"
|
||||
id="settings-account-rules-tab"
|
||||
onClick={() => actions.onPanelChange("rules")}
|
||||
role="tab"
|
||||
type="button"
|
||||
>Verwendungsregeln</button>
|
||||
<SlidingSelection activeKey={model.activePanel} aria-label="Accountverwaltung" aria-orientation="horizontal" axis="horizontal" className="settings-account-tabs" role="tablist">
|
||||
{ACCOUNT_WORKSPACE_PANELS.map((panel, index) => (
|
||||
<button
|
||||
aria-controls={`settings-account-${panel.id}`}
|
||||
aria-selected={model.activePanel === panel.id}
|
||||
data-sliding-selection-active={model.activePanel === panel.id}
|
||||
data-sliding-selection-item="true"
|
||||
id={`settings-account-${panel.id}-tab`}
|
||||
key={panel.id}
|
||||
onClick={() => actions.onPanelChange(panel.id)}
|
||||
onKeyDown={(event) => {
|
||||
const nextIndex = getAccountPanelNavigationIndex(index, event.key);
|
||||
if (nextIndex === null) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const tablist = event.currentTarget.closest('[role="tablist"]');
|
||||
const tabs = tablist?.querySelectorAll<HTMLElement>('[role="tab"]');
|
||||
tabs?.[nextIndex]?.focus();
|
||||
actions.onPanelChange(ACCOUNT_WORKSPACE_PANELS[nextIndex].id);
|
||||
}}
|
||||
role="tab"
|
||||
tabIndex={model.activePanel === panel.id ? 0 : -1}
|
||||
type="button"
|
||||
>{panel.label}</button>
|
||||
))}
|
||||
</SlidingSelection>
|
||||
<div
|
||||
aria-labelledby="settings-account-overview-tab"
|
||||
@@ -502,6 +528,8 @@ export function AccountAddDialog({
|
||||
<div className="settings-account-picker-selector">
|
||||
<span>Dienst / Zugangstyp</span>
|
||||
<input
|
||||
aria-controls={model.options.length === 0 ? "settings-account-picker-empty" : "settings-account-picker-results"}
|
||||
aria-describedby={model.options.length === 0 ? "settings-account-picker-empty" : undefined}
|
||||
aria-label="Dienst oder Zugangstyp suchen"
|
||||
className="settings-control"
|
||||
onChange={(event) => actions.onQueryChange(event.target.value)}
|
||||
@@ -515,8 +543,13 @@ export function AccountAddDialog({
|
||||
<span>Dienst</span>
|
||||
<span>Typ/Funktion</span>
|
||||
</div>
|
||||
<div aria-label="Dienst / Zugangstyp" className="settings-account-picker-list" role="listbox">
|
||||
{model.options.map((option) => (
|
||||
{model.options.length === 0 ? (
|
||||
<div aria-live="polite" className="settings-account-picker-empty" id="settings-account-picker-empty" role="status">
|
||||
Keine passenden Dienste oder Zugangstypen gefunden.
|
||||
</div>
|
||||
) : (
|
||||
<div aria-label="Dienst / Zugangstyp" className="settings-account-picker-list" id="settings-account-picker-results" role="listbox">
|
||||
{model.options.map((option) => (
|
||||
<button
|
||||
aria-selected={option.id === model.selectedOptionId}
|
||||
className={`settings-account-picker-row${option.id === model.selectedOptionId ? " is-selected" : ""}`}
|
||||
@@ -532,8 +565,9 @@ export function AccountAddDialog({
|
||||
</span>
|
||||
<span>{option.functionLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedOption ? (
|
||||
<>
|
||||
|
||||
@@ -13,10 +13,50 @@ export interface SettingsFormActions {
|
||||
onCommit?: (fieldId: string, value: string) => void;
|
||||
}
|
||||
|
||||
export interface SettingsFormProps {
|
||||
model: SettingsFormViewModel;
|
||||
actions: SettingsFormActions;
|
||||
}
|
||||
export interface SettingsFormProps {
|
||||
model: SettingsFormViewModel;
|
||||
actions: SettingsFormActions;
|
||||
}
|
||||
|
||||
export type SettingsSelectKeyboardAction =
|
||||
| { type: "close" }
|
||||
| { type: "focus"; index: number }
|
||||
| null;
|
||||
|
||||
export function getSettingsSelectKeyboardAction(
|
||||
key: string,
|
||||
currentIndex: number,
|
||||
optionCount: number
|
||||
): SettingsSelectKeyboardAction {
|
||||
if (key === "Escape") {
|
||||
return { type: "close" };
|
||||
}
|
||||
if (key === "ArrowDown" || key === "ArrowUp" || key === "Home" || key === "End") {
|
||||
return { type: "focus", index: getSettingsSelectNavigationIndex(currentIndex, optionCount, key) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function closeSettingsSelectAndRestoreFocus(
|
||||
close: () => void,
|
||||
trigger: Pick<HTMLButtonElement, "focus"> | null
|
||||
): void {
|
||||
close();
|
||||
trigger?.focus();
|
||||
}
|
||||
|
||||
function getThemeNavigationIndex(currentIndex: number, optionCount: number, key: string): number | null {
|
||||
if (key === "ArrowRight" || key === "ArrowDown") {
|
||||
return getSettingsSelectNavigationIndex(currentIndex, optionCount, "ArrowDown");
|
||||
}
|
||||
if (key === "ArrowLeft" || key === "ArrowUp") {
|
||||
return getSettingsSelectNavigationIndex(currentIndex, optionCount, "ArrowUp");
|
||||
}
|
||||
if (key === "Home" || key === "End") {
|
||||
return getSettingsSelectNavigationIndex(currentIndex, optionCount, key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function FieldHelp({ field }: { field: SettingsFieldViewModel }): ReactElement | null {
|
||||
return field.help ? <span className="settings-field-help" id={`${field.id}-help`}>{field.help}</span> : null;
|
||||
@@ -87,28 +127,27 @@ function SelectControl({ field, actions }: { field: SettingsSelectFieldViewModel
|
||||
requestAnimationFrame(() => optionRefs.current[nextIndex]?.focus());
|
||||
};
|
||||
|
||||
const closeAndRestoreFocus = (): void => {
|
||||
closeSettingsSelectAndRestoreFocus(() => setOpen(false), triggerRef.current);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (event: MouseEvent): void => {
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
|
||||
};
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("mousedown", close);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", close);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
|
||||
const action = getSettingsSelectKeyboardAction(event.key, selectedIndex, field.options.length);
|
||||
if (action?.type === "focus") {
|
||||
event.preventDefault();
|
||||
const nextIndex = getSettingsSelectNavigationIndex(selectedIndex, field.options.length, event.key);
|
||||
setOpen(true);
|
||||
focusOption(nextIndex);
|
||||
focusOption(action.index);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
@@ -119,17 +158,20 @@ function SelectControl({ field, actions }: { field: SettingsSelectFieldViewModel
|
||||
};
|
||||
|
||||
const onOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number): void => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
|
||||
const action = getSettingsSelectKeyboardAction(event.key, index, field.options.length);
|
||||
if (action?.type === "focus") {
|
||||
event.preventDefault();
|
||||
const nextIndex = getSettingsSelectNavigationIndex(index, field.options.length, event.key);
|
||||
focusOption(nextIndex);
|
||||
focusOption(action.index);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (!open || getSettingsSelectKeyboardAction(event.key, selectedIndex, field.options.length)?.type !== "close") {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeAndRestoreFocus();
|
||||
};
|
||||
|
||||
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
@@ -139,7 +181,7 @@ function SelectControl({ field, actions }: { field: SettingsSelectFieldViewModel
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label id={`${field.id}-label`}>{field.label}</label>
|
||||
<div className={`settings-select${open ? " is-open" : ""}${field.disabled ? " is-disabled" : ""}`} onBlur={onBlur} ref={rootRef}>
|
||||
<div className={`settings-select${open ? " is-open" : ""}${field.disabled ? " is-disabled" : ""}`} onBlur={onBlur} onKeyDown={onSelectKeyDown} ref={rootRef}>
|
||||
<button
|
||||
aria-controls={`${field.id}-options`}
|
||||
aria-expanded={open}
|
||||
@@ -165,11 +207,12 @@ function SelectControl({ field, actions }: { field: SettingsSelectFieldViewModel
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
actions.onChange(field.id, option.value);
|
||||
setOpen(false);
|
||||
closeAndRestoreFocus();
|
||||
}}
|
||||
onKeyDown={(event) => onOptionKeyDown(event, index)}
|
||||
ref={(element) => { optionRefs.current[index] = element; }}
|
||||
role="option"
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
>{option.label}</button>
|
||||
))}
|
||||
@@ -187,19 +230,32 @@ function SettingsField({ field, actions }: { field: SettingsFieldViewModel; acti
|
||||
if (field.kind === "select") {
|
||||
return <SelectControl actions={actions} field={field} />;
|
||||
}
|
||||
if (field.kind === "theme") {
|
||||
return (
|
||||
<fieldset className="settings-field settings-theme-field" disabled={field.disabled}>
|
||||
<legend>{field.label}</legend>
|
||||
<div aria-describedby={field.help ? `${field.id}-help` : undefined} className="settings-theme-options" role="radiogroup">
|
||||
{field.options.map((option) => (
|
||||
<button
|
||||
aria-checked={field.value === option.value}
|
||||
className={`settings-theme-option${field.value === option.value ? " is-active" : ""}`}
|
||||
key={option.value}
|
||||
onClick={() => actions.onChange(field.id, option.value)}
|
||||
role="radio"
|
||||
type="button"
|
||||
if (field.kind === "theme") {
|
||||
const selectedIndex = Math.max(0, field.options.findIndex((option) => option.value === field.value));
|
||||
return (
|
||||
<fieldset className="settings-field settings-theme-field" disabled={field.disabled}>
|
||||
<legend id={`${field.id}-label`}>{field.label}</legend>
|
||||
<div aria-describedby={field.help ? `${field.id}-help` : undefined} aria-labelledby={`${field.id}-label`} className="settings-theme-options" role="radiogroup">
|
||||
{field.options.map((option, index) => (
|
||||
<button
|
||||
aria-checked={field.value === option.value}
|
||||
className={`settings-theme-option${field.value === option.value ? " is-active" : ""}`}
|
||||
key={option.value}
|
||||
onClick={() => actions.onChange(field.id, option.value)}
|
||||
onKeyDown={(event) => {
|
||||
const nextIndex = getThemeNavigationIndex(index, field.options.length, event.key);
|
||||
if (nextIndex === null) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const group = event.currentTarget.closest('[role="radiogroup"]');
|
||||
const radios = group?.querySelectorAll<HTMLElement>('[role="radio"]');
|
||||
radios?.[nextIndex]?.focus();
|
||||
actions.onChange(field.id, field.options[nextIndex].value);
|
||||
}}
|
||||
role="radio"
|
||||
tabIndex={index === selectedIndex ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true" className={`settings-theme-preview is-${option.value}`} />
|
||||
<span>{option.label}</span>
|
||||
|
||||
@@ -117,13 +117,18 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-save-state.is-clean,
|
||||
.settings-save-state.is-saved {
|
||||
color: var(--ui-success-text);
|
||||
}
|
||||
|
||||
.settings-save-state.is-dirty,
|
||||
.settings-save-state.is-saving {
|
||||
color: var(--ui-warning);
|
||||
color: var(--ui-warning-text);
|
||||
}
|
||||
|
||||
.settings-save-state.is-error {
|
||||
color: var(--ui-danger);
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.settings-content-body {
|
||||
@@ -209,7 +214,7 @@
|
||||
height: 44px;
|
||||
min-width: 0;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border: 1px solid var(--ui-control-border);
|
||||
border-radius: 6px;
|
||||
outline: 0;
|
||||
background: var(--ui-input);
|
||||
@@ -231,7 +236,7 @@
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border: 1px solid var(--ui-control-border);
|
||||
border-radius: 6px;
|
||||
background: var(--ui-input);
|
||||
color: var(--ui-text);
|
||||
@@ -312,7 +317,7 @@
|
||||
.settings-account-row:focus-visible,
|
||||
.settings-account-action-button:focus-visible,
|
||||
.settings-account-picker-row:focus-visible {
|
||||
outline: 2px solid var(--ui-accent);
|
||||
outline: 2px solid var(--ui-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -470,13 +475,13 @@
|
||||
.settings-button-primary {
|
||||
border-color: var(--ui-primary);
|
||||
background: var(--ui-primary);
|
||||
color: #181A1F;
|
||||
color: var(--ui-primary-text);
|
||||
}
|
||||
|
||||
.settings-button-danger {
|
||||
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
|
||||
background: transparent;
|
||||
color: var(--ui-danger);
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.settings-button:hover:not(:disabled),
|
||||
@@ -488,7 +493,7 @@
|
||||
|
||||
.settings-button-primary:hover:not(:disabled) {
|
||||
background: var(--ui-primary-hover);
|
||||
color: #181A1F;
|
||||
color: var(--ui-primary-text);
|
||||
}
|
||||
|
||||
.settings-account-workspace {
|
||||
@@ -677,15 +682,28 @@
|
||||
background: var(--ui-success);
|
||||
}
|
||||
|
||||
.settings-account-status-badge.is-ok {
|
||||
color: var(--ui-success-text);
|
||||
}
|
||||
|
||||
.settings-account-status-badge.is-free::before,
|
||||
.settings-account-status-badge.is-unknown::before {
|
||||
background: var(--ui-warning);
|
||||
}
|
||||
|
||||
.settings-account-status-badge.is-free,
|
||||
.settings-account-status-badge.is-unknown {
|
||||
color: var(--ui-warning-text);
|
||||
}
|
||||
|
||||
.settings-account-status-badge.is-invalid::before {
|
||||
background: var(--ui-danger);
|
||||
}
|
||||
|
||||
.settings-account-status-badge.is-invalid {
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.settings-account-status-badge.is-disabled::before {
|
||||
background: var(--ui-text-muted);
|
||||
}
|
||||
@@ -746,7 +764,7 @@
|
||||
|
||||
.settings-account-table-error .ui-data-table-empty-title,
|
||||
.settings-account-dialog-error {
|
||||
color: var(--ui-danger);
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.settings-account-rules {
|
||||
@@ -879,6 +897,16 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-account-picker-empty {
|
||||
display: grid;
|
||||
min-height: 72px;
|
||||
padding: 16px;
|
||||
place-items: center;
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-account-picker-row {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
@@ -905,6 +933,17 @@
|
||||
box-shadow: inset 3px 0 0 var(--ui-accent);
|
||||
}
|
||||
|
||||
.settings-visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
border: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-account-picker-service {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
.statistics-range:focus-visible,
|
||||
.statistics-reset:focus-visible {
|
||||
outline: 2px solid var(--ui-accent);
|
||||
outline: 2px solid var(--ui-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
}
|
||||
|
||||
.statistics-reset-danger {
|
||||
color: var(--ui-danger);
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.statistics-reset:disabled {
|
||||
@@ -178,7 +178,7 @@
|
||||
}
|
||||
|
||||
.statistics-kpi-danger .statistics-kpi-value {
|
||||
color: var(--ui-danger);
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.statistics-kpi-source {
|
||||
@@ -309,7 +309,7 @@
|
||||
}
|
||||
|
||||
.statistics-provider-errors {
|
||||
color: var(--ui-danger);
|
||||
color: var(--ui-danger-text);
|
||||
}
|
||||
|
||||
.statistics-provider-empty {
|
||||
|
||||
Reference in New Issue
Block a user