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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user