Skip to content

Commit

Permalink
Implement the breadcrumb with PatternFly components
Browse files Browse the repository at this point in the history
PatternFly has a breadcrumb component which we where not using but
instead used a button which meant we could not properly support middle
click and needed to apply CSS workarounds to style them properly.

Additionally this drops the hostname from the breadcrumb and replaces it
with an icon (with a tooltip).

Middle mouse click now properly works and opens the clicked path in a
new tab.

Closes: #640 #641
  • Loading branch information
jelly committed Jul 30, 2024
1 parent 520f6cb commit 3e1c25e
Show file tree
Hide file tree
Showing 4 changed files with 229 additions and 170 deletions.
100 changes: 72 additions & 28 deletions src/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,19 @@
block-size: 100vh;
}

.pf-v5-c-page__main-breadcrumb {
.files-overview-header {
grid-area: topbar;

/* Chrome specific alignment issue */
align-items: center;

/* Override different background color from the <PageBreadCrumb> */
.pf-v5-c-page__main-breadcrumb {
background-color: unset
}
}


.pf-v5-c-sidebar__content > .pf-v5-c-card {
grid-area: content;
}
Expand All @@ -74,37 +83,64 @@
@extend .ct-card;
}

// We have to override PatternFly assumptions, as PatternFly does not expect
// widgets other than breadcrumbs within a breadcrumbs bar. We should probably
// reparent these and separate the breadcrumb into its own widget on the top
// bar instead.
.pf-v5-c-page__main-breadcrumb {
.pf-v5-c-button:not(.breadcrumb-button):has(.pf-v5-svg:last-child) {
// PatternFly doesn't expect icons in buttons in breadcrumbs, so we need to adjust for that
>.pf-v5-c-button__icon {
margin-inline: 0;
.files-overview-header {
gap: var(--pf-v5-global--spacer--sm);
display: flex;
/* Align page breadcrumb centered */
align-items: baseline;

/* Drop PF padding */
.pf-v5-c-page__main-breadcrumb {
padding: 0;
display: inline-block;
}

.pf-v5-c-breadcrumb {
margin-block: 0;
margin-inline: var(--pf-v5-global--spacer--sm);
}
}

.pf-v5-c-breadcrumb {
margin-block: 0;
margin-inline: var(--pf-v5-global--spacer--md);
}

.pf-v5-c-breadcrumb__list {
// Make sure all breadcrumb text is aligned properly, even if different heights (including icon)
align-items: baseline;

// Style the breadcrumb component as a path
.pf-v5-c-breadcrumb__item-divider {
> svg {
display: none;
}

&::after {
content: "/";
}
}

.pf-v5-c-menu-toggle {
padding-inline: var(--pf-v5-global--spacer--md) calc(var(--pf-v5-global--spacer--md) * 0.75);
.pf-v5-c-breadcrumb__item {
// Use the default font size, not the smaller size
font-size: var(--pf-v5-global--FontSize--md);
}

// Size, align, and space icon correctly
.breadcrumb-hdd-icon {
// Set the size to a large icon
block-size: var(--pf-v5-global--icon--FontSize--lg);
// Width should resolve itself based on height and aspect ratio
inline-size: auto;
// Align to the middle (as one would expect)
vertical-align: middle;
// Fix the offset problem so it's properly aligned to middle
margin-block-start: calc(1ex - 1cap);
}
}

.breadcrumb {
&-button {
padding-inline: 0;
// HACK: override PF button behaviour, breadcrumbs should
// be links not button and then we should be able to remove these
// overrides.
// https://github.com/cockpit-project/cockpit-files/issues/641
overflow-wrap: break-word;
white-space: normal;

&.breadcrumb-0 {
margin-inline-start: var(--pf-v5-global--spacer--sm);
}

&-edit-apply,
&-edit-cancel {
padding-inline: var(--pf-v5-global--spacer--sm);
Expand All @@ -130,10 +166,6 @@
}
}

.path-divider {
color: var(--pf-v5-global--Color--200);
}

.view-toggle-group {
.pf-c-menu-toggle__button {
display: flex;
Expand Down Expand Up @@ -198,3 +230,15 @@
padding-block-start: 0;
}
}

// // FIXME: Promote the CSS below to overrides, open PF issues // //

// PatternFly always adds a margin after images inside of widgets with pf-m-end, which is incorrect when it's the last element
.pf-v5-c-button__icon.pf-m-start:last-child {
margin-inline-end: 0;
}

// PF menu toggles are no longer spaced consistently
.pf-v5-c-menu-toggle {
padding-inline: var(--pf-v5-global--spacer--md) calc(var(--pf-v5-global--spacer--md) * 0.75);
}
186 changes: 102 additions & 84 deletions src/files-breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@
import React from "react";

import { AlertVariant } from "@patternfly/react-core/dist/esm/components/Alert";
import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb";
import { Button } from "@patternfly/react-core/dist/esm/components/Button";
import { Divider } from "@patternfly/react-core/dist/esm/components/Divider";
import { Dropdown, DropdownItem, DropdownList } from "@patternfly/react-core/dist/esm/components/Dropdown";
import { MenuToggle, MenuToggleElement } from "@patternfly/react-core/dist/esm/components/MenuToggle";
import { PageBreadcrumb } from "@patternfly/react-core/dist/esm/components/Page";
import { PageBreadcrumb, PageSection, PageSectionVariants } from "@patternfly/react-core/dist/esm/components/Page";
import { TextInput } from "@patternfly/react-core/dist/esm/components/TextInput";
import { Tooltip, TooltipPosition } from "@patternfly/react-core/dist/esm/components/Tooltip";
import { Flex, FlexItem } from "@patternfly/react-core/dist/esm/layouts/Flex";
import { CheckIcon, HddIcon, PencilAltIcon, StarIcon, TimesIcon } from "@patternfly/react-icons";
import { CheckIcon, OutlinedHddIcon, PencilAltIcon, StarIcon, TimesIcon } from "@patternfly/react-icons";
import { useInit } from "hooks.js";

import cockpit from "cockpit";
Expand All @@ -37,29 +37,6 @@ import { basename } from "./common";

const _ = cockpit.gettext;

function useHostname() {
const [hostname, setHostname] = React.useState<string | null>(null);

React.useEffect(() => {
const client = cockpit.dbus('org.freedesktop.hostname1');
const hostname1 = client.proxy('org.freedesktop.hostname1', '/org/freedesktop/hostname1');

function changed() {
if (hostname1.valid && typeof hostname1.Hostname === 'string') {
setHostname(hostname1.Hostname);
}
}

hostname1.addEventListener("changed", changed);
return () => {
hostname1.removeEventListener("changed", changed);
client.close();
};
}, []);

return hostname;
}

function BookmarkButton({ path }: { path: string[] }) {
const [isOpen, setIsOpen] = React.useState(false);
const [user, setUser] = React.useState<cockpit.UserInfo | null>(null);
Expand Down Expand Up @@ -205,15 +182,71 @@ function BookmarkButton({ path }: { path: string[] }) {
);
}

const PathBreadcrumbs = ({ path }: { path: string[] }) => {
// HACK: strip extraneous slashes as PF's breadcrumb can't handle them
// Refactor the path to be the fullpath not an array of strings
if (path.length >= 2 && path[0] === '' && path[1] === '') {
path = path.slice(1);
}

if (path.length > 1 && path[path.length - 1] === '') {
path = path.slice(path.length - 1);
}

function navigate(event: React.MouseEvent<HTMLElement>) {
const { button, ctrlKey, metaKey } = event;
const target = event.target as HTMLButtonElement;
const isAnchor = target.matches("a");

if (!target.parentElement)
return;
const link = target.parentElement.getAttribute("data-location");

// Let the browser natively handle non-primary click events
// or if the control or meta (Mac) keys are pressed
// ...this lets opening in a new tab or window work by default.
if (isAnchor && link && (button === 0 && !ctrlKey && !metaKey)) {
event.preventDefault();
cockpit.location.go("/", { path: encodeURIComponent(link) });
}
}

return (
<Breadcrumb onClick={(event) => navigate(event)}>
{path.map((dir, i) => {
const url_path = path.slice(0, i + 1).join("/") || '/';
// We can't use a relative path as that will use the iframe's
// url while we want the outer shell url. And we can't obtain
// the full path of the shell easily, so a middle click will
// open a files page without the shell.
const link = `${window.location.pathname}#/?path=${url_path}`;

return (
<BreadcrumbItem
key={url_path}
data-location={url_path}
to={link}
isActive={i === path.length - 1}
>
{i === 0 &&
<Tooltip
content={_("Filesystem")}
position={TooltipPosition.bottom}
>
<OutlinedHddIcon className="breadcrumb-hdd-icon" />
</Tooltip>}
{i !== 0 && dir}
</BreadcrumbItem>
);
})}
</Breadcrumb>
);
};

// eslint-disable-next-line max-len
export function FilesBreadcrumbs({ path }: { path: string[] }) {
const [editMode, setEditMode] = React.useState(false);
const [newPath, setNewPath] = React.useState<string | null>(null);
const hostname = useHostname();

function navigate(n_parts: number) {
cockpit.location.go("/", { path: encodeURIComponent(path.slice(0, n_parts).join("/")) });
}

const handleInputKey = (event: React.KeyboardEvent<HTMLInputElement>) => {
// Don't propogate navigation specific events
Expand Down Expand Up @@ -248,66 +281,51 @@ export function FilesBreadcrumbs({ path }: { path: string[] }) {
setEditMode(false);
};

const fullPath = path.slice(1);
fullPath.unshift(hostname || "server");

return (
<PageBreadcrumb stickyOnBreakpoint={{ default: "top" }}>
<Flex spaceItems={{ default: "spaceItemsSm" }}>
<BookmarkButton path={path} />
{!editMode &&
<PageSection
variant={PageSectionVariants.light}
className="files-overview-header"
padding={{ default: "padding" }}
>
<BookmarkButton path={path} />
{!editMode &&
<>
<Tooltip content={_("Edit path")} position={TooltipPosition.bottom}>
<Button
variant="secondary"
icon={<PencilAltIcon />}
onClick={() => enableEditMode()}
className="breadcrumb-button-edit"
/>
</Tooltip>}
{!editMode && fullPath.map((dir, i) => {
return (
<React.Fragment key={fullPath.slice(0, i).join("/") || "/"}>
<Button
isDisabled={i === path.length - 1}
icon={i === 0 ? <HddIcon /> : null}
variant="link" onClick={() => { navigate(i + 1) }}
className={`breadcrumb-button breadcrumb-${i}`}
>
{dir || "/"}
</Button>
{dir !== "" && <p className="path-divider" key={i}>/</p>}
</React.Fragment>
);
})}
{editMode && newPath !== null &&
<FlexItem flex={{ default: "flex_1" }}>
<TextInput
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
id="new-path-input"
value={newPath}
onFocus={(event) => event.target.select()}
onKeyDown={handleInputKey}
onChange={(_event, value) => setNewPath(value)}
/>
</FlexItem>}
<FlexItem align={{ default: 'alignRight' }}>
{editMode &&
<>
<Button
variant="plain"
icon={<CheckIcon className="breadcrumb-edit-apply-icon" />}
onClick={changePath}
className="breadcrumb-button-edit-apply"
/>
<Button
variant="plain"
icon={<TimesIcon />}
onClick={() => cancelPathEdit()}
className="breadcrumb-button-edit-cancel"
/>
</>}
</FlexItem>
</Flex>
</PageBreadcrumb>
</Tooltip>
<PageBreadcrumb>
<PathBreadcrumbs path={path} />
</PageBreadcrumb>
</>}
{editMode && newPath !== null &&
<TextInput
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
id="new-path-input"
value={newPath}
onFocus={(event) => event.target.select()}
onKeyDown={handleInputKey}
onChange={(_event, value) => setNewPath(value)}
/>}
{editMode &&
<>
<Button
variant="plain"
icon={<CheckIcon className="breadcrumb-edit-apply-icon" />}
onClick={changePath}
className="breadcrumb-button-edit-apply"
/>
<Button
variant="plain"
icon={<TimesIcon />}
onClick={() => cancelPathEdit()}
className="breadcrumb-button-edit-cancel"
/>
</>}
</PageSection>
);
}
Loading

0 comments on commit 3e1c25e

Please sign in to comment.