fix: show the folder name in the Files header on a phone and keep Delete in sight

This commit is contained in:
Linus Rath
2026-11-03 05:19:31 +01:00
parent 5ac2d15afb
commit 6f6370c7f6
+36 -18
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState, type ReactNode } from "react";
import { useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import {
Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList,
@@ -12,6 +12,7 @@ import { IconButton } from "@/components/ui/icon-button";
import { ToolbarButton, ToolbarSearch, TOOLBAR_ACTIVE } from "@/components/ui/toolbar";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useMediaQuery } from "@/hooks/use-media-query";
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
import { cn } from "@/lib/utils";
import { anchorEvent, isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
@@ -123,6 +124,9 @@ export function FilesHeader({
const overflowMenu = useContextMenu<Crumb[]>();
const [searchOpen, setSearchOpen] = useState(false);
const [dropCrumb, setDropCrumb] = useState<string | null>(null);
// A phone's row has no room for the path next to every tool: sort, view and details go in the ⋮.
const narrow = useMediaQuery("(max-width: 30rem)") && compact;
const toolsRef = useRef<HTMLDivElement>(null);
const headerClass = "flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0";
// The row is px-3; the margin puts the button where the reader has it (px-4, lg:px-6).
@@ -165,23 +169,30 @@ export function FilesHeader({
)}
<ToolButton icon={Scissors} label={t("cut")} onClick={selection.onCut} compact />
<ToolButton icon={Copy} label={t("copy")} onClick={selection.onCopy} compact />
<ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />
{!compact && <ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />}
{!compact && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} />}
</>
)}
</div>
{/* Outside the scrolling row, so the "more" button never scrolls out of sight. */}
{/* Outside the scrolling row, so Delete and the "more" button never scroll out of sight. */}
{compact && !selection.trash && <ToolButton icon={Trash2} label={t("delete")} onClick={selection.onDelete} compact destructive />}
{compact && !selection.trash && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} overflow />}
<div className="flex-1" />
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
{!narrow && <DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />}
{toggleSlot}
</div>
);
}
// Deep paths keep the root and the last folders; the rest go in a menu.
const hidden = crumbs.length > MAX_VISIBLE_CRUMBS + 1 ? crumbs.slice(1, crumbs.length - MAX_VISIBLE_CRUMBS + 1) : [];
const visible = hidden.length > 0 ? [crumbs[0], ...crumbs.slice(crumbs.length - MAX_VISIBLE_CRUMBS + 1)] : crumbs;
// A phone only fits the folder you are in, with the way up in the menu.
const onlyLast = narrow ? crumbs.length > 1 : compact && crumbs.length > 2;
const hidden = onlyLast
? crumbs.slice(0, -1)
: crumbs.length > MAX_VISIBLE_CRUMBS + 1 ? crumbs.slice(1, crumbs.length - MAX_VISIBLE_CRUMBS + 1) : [];
const visible = onlyLast
? crumbs.slice(-1)
: hidden.length > 0 ? [crumbs[0], ...crumbs.slice(crumbs.length - MAX_VISIBLE_CRUMBS + 1)] : crumbs;
const crumbDropProps = (crumb: Crumb, isLast: boolean) => isLast ? {} : {
onDragOver: (e: React.DragEvent) => {
@@ -269,8 +280,8 @@ export function FilesHeader({
<nav aria-label={t("breadcrumbs")} className="flex-1 min-w-0">
<ol className="flex items-center text-sm min-w-0">
{visible.slice(0, 1).map((c) => renderCrumb(c, 0))}
{accountLabel && (
{!onlyLast && visible.slice(0, 1).map((c) => renderCrumb(c, 0))}
{accountLabel && !onlyLast && (
<li className="flex items-center min-w-0 shrink-0">
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />
<button
@@ -284,7 +295,7 @@ export function FilesHeader({
)}
{hidden.length > 0 && (
<li className="flex items-center shrink-0">
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />
{!onlyLast && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" aria-hidden />}
<button
type="button"
className="px-1.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted"
@@ -296,12 +307,12 @@ export function FilesHeader({
</button>
</li>
)}
{visible.slice(1).map((c, i) => renderCrumb(c, i + 1))}
{(onlyLast ? visible : visible.slice(1)).map((c, i) => renderCrumb(c, i + 1))}
</ol>
</nav>
{!toolsHidden && (
<div className="flex items-center gap-1 shrink-0">
<div ref={toolsRef} className="flex items-center gap-1 shrink-0">
{emptyTrash && (
<Button
variant="outline"
@@ -326,7 +337,7 @@ export function FilesHeader({
<Search />
</ToolbarButton>
) : searchField}
<ToolbarButton
{!narrow && <ToolbarButton
label={t("sort_by")}
active={sortMenu.contextMenu.isOpen}
aria-haspopup="menu"
@@ -338,8 +349,8 @@ export function FilesHeader({
}}
>
<ArrowUpDown />
</ToolbarButton>
<div className="flex items-center h-8 rounded-md border border-input p-0.5 shrink-0" role="radiogroup" aria-label={t("view_mode")}>
</ToolbarButton>}
{!narrow && <div className="flex items-center h-8 rounded-md border border-input p-0.5 shrink-0" role="radiogroup" aria-label={t("view_mode")}>
{([["list", LayoutList, t("list_view")], ["grid", LayoutGrid, t("grid_view")]] as const).map(([mode, Icon, label]) => (
<button
key={mode}
@@ -359,19 +370,26 @@ export function FilesHeader({
<Icon className="w-4 h-4" />
</button>
))}
</div>
</div>}
{!compact && (
<ToolbarButton label={t("refresh")} onClick={onRefresh} disabled={isLoading}>
<RefreshCw className={cn(isLoading && "animate-spin [animation-direction:reverse]")} />
</ToolbarButton>
)}
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
{!narrow && <DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />}
{toggleSlot}
<PhoneHeaderActions
below="md"
menuItems={(close) => (
// The phone header has no room for Refresh as a button.
<ContextMenuItem icon={RefreshCw} label={t("refresh")} onClick={() => { close(); onRefresh(); }} disabled={isLoading} />
<>
{narrow && <ContextMenuItem icon={ArrowUpDown} label={t("sort_by")} onClick={() => { close(); if (toolsRef.current) sortMenu.openContextMenu(anchorEvent(toolsRef.current, "end"), null); }} />}
{narrow && (viewMode === "list"
? <ContextMenuItem icon={LayoutGrid} label={t("grid_view")} onClick={() => { close(); onViewModeChange("grid"); }} />
: <ContextMenuItem icon={LayoutList} label={t("list_view")} onClick={() => { close(); onViewModeChange("list"); }} />)}
{narrow && <ContextMenuItem icon={PanelRight} label={t("details")} onClick={() => { close(); onToggleDetails(); }} />}
{/* The phone header has no room for Refresh as a button. */}
<ContextMenuItem icon={RefreshCw} label={t("refresh")} onClick={() => { close(); onRefresh(); }} disabled={isLoading} />
</>
)}
/>
</div>