feat(ui): refine download controls and package motion
Move the global package disclosure action into a responsive right-aligned toolbar group and disable it for empty queues. Replace the decorative package title with a compact neutral filter group, coordinate disclosure opacity with height transitions, and make contextual help follow combined pointer and keyboard focus state.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Children, Fragment, isValidElement, useId, type ReactElement, type ReactNode } from "react";
|
||||
import { Children, Fragment, isValidElement, useId, useRef, type ReactElement, type ReactNode } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
export interface ContextInfoButtonProps {
|
||||
@@ -37,19 +37,45 @@ export function ContextInfoButton({
|
||||
onOpenChange
|
||||
}: ContextInfoButtonProps): ReactElement | null {
|
||||
const regionId = useId();
|
||||
const pointerInside = useRef(false);
|
||||
const focusInside = useRef(false);
|
||||
|
||||
if (!hasRenderableContent(content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-context-info">
|
||||
<div
|
||||
className="ui-context-info"
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
focusInside.current = false;
|
||||
if (!pointerInside.current) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
focusInside.current = true;
|
||||
onOpenChange(true);
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
pointerInside.current = true;
|
||||
onOpenChange(true);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
pointerInside.current = false;
|
||||
if (!focusInside.current) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
aria-controls={regionId}
|
||||
aria-expanded={open}
|
||||
aria-label="Informationen"
|
||||
className="ui-context-info-trigger"
|
||||
onClick={() => onOpenChange(!open)}
|
||||
onClick={() => onOpenChange(true)}
|
||||
title="Informationen"
|
||||
type="button"
|
||||
>
|
||||
|
||||
@@ -93,15 +93,13 @@ const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
|
||||
|
||||
export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
|
||||
return (
|
||||
<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">
|
||||
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
|
||||
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical" className="downloads-filter-group">
|
||||
{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>
|
||||
<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>
|
||||
<div className="downloads-sidebar-actions">
|
||||
<button onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||
<button disabled={model.empty} onClick={actions.onClearAll} type="button">Liste leeren</button>
|
||||
</div>
|
||||
<label className="downloads-clipboard-toggle"><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
|
||||
@@ -148,8 +146,11 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
|
||||
<span className="downloads-toolbar-divider" />
|
||||
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
|
||||
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
|
||||
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
|
||||
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
|
||||
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
|
||||
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
|
||||
<span aria-label="Paketdarstellung" className="downloads-toolbar-tail" role="group">
|
||||
<button className="downloads-toolbar-toggle-all" disabled={model.empty} onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function itemGroup(
|
||||
packageId,
|
||||
items: items.map((row) => ({ ...row, height: DOWNLOAD_FILE_ROW_HEIGHT })),
|
||||
height,
|
||||
disclosureOpacity: 1,
|
||||
disclosureOpacity: phase === "entering" ? 0 : 1,
|
||||
disclosurePhase: phase
|
||||
};
|
||||
}
|
||||
@@ -197,8 +197,8 @@ export function activateDownloadDisclosureTransition(rows: readonly DownloadDisc
|
||||
return rows.map((row) => {
|
||||
if (row.type !== "item-group") return row;
|
||||
return row.disclosurePhase === "entering"
|
||||
? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT }
|
||||
: { ...row, height: 0 };
|
||||
? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT, disclosureOpacity: 1 }
|
||||
: { ...row, height: 0, disclosureOpacity: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -39,18 +39,11 @@
|
||||
padding: 14px 12px 6px;
|
||||
}
|
||||
|
||||
.downloads-mode-title {
|
||||
display: flex;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 10px;
|
||||
.downloads-filter-group {
|
||||
padding: 4px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
color: #0a0f1a;
|
||||
background: #90cdf4;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
background: var(--ui-input);
|
||||
}
|
||||
|
||||
.downloads-sidebar nav button,
|
||||
@@ -204,8 +197,10 @@
|
||||
.downloads-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-height: 52px;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
background: var(--ui-surface);
|
||||
@@ -237,6 +232,13 @@
|
||||
background: var(--ui-border);
|
||||
}
|
||||
|
||||
.downloads-toolbar-tail {
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.downloads-schedule-slot {
|
||||
display: grid;
|
||||
grid-template-columns: 0fr;
|
||||
@@ -941,7 +943,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
.downloads-toolbar,
|
||||
.downloads-footer {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user