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:
Sucukdeluxe
2026-08-11 06:03:07 +02:00
parent eda3a3f744
commit 6c5946c88d
28 changed files with 1361 additions and 288 deletions
+47 -18
View File
@@ -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>
+76 -11
View File
@@ -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;
}
}