Skip to content

Commit fc77285

Browse files
authored
New breadcrumb for Branch list and details page (#7550)
1 parent 55b93c0 commit fc77285

File tree

14 files changed

+323
-123
lines changed

14 files changed

+323
-123
lines changed

changelog/+breadcrumb.added.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
New breadcrumb navigation:
2+
3+
- Select and switch branches directly from the breadcrumb

frontend/app/src/app/router.tsx

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,6 @@ export const router = createBrowserRouter([
9191
},
9292
{
9393
path: "/branches",
94-
handle: {
95-
breadcrumb: () => {
96-
return {
97-
type: "link",
98-
label: "Branches",
99-
to: constructPath("/branches"),
100-
};
101-
},
102-
},
10394
children: [
10495
{
10596
index: true,
@@ -108,14 +99,6 @@ export const router = createBrowserRouter([
10899
{
109100
path: "*",
110101
lazy: () => import("@/pages/branches/details"),
111-
handle: {
112-
breadcrumb: (match: UIMatch) => {
113-
return {
114-
type: "branch",
115-
value: match.params["*"],
116-
};
117-
},
118-
},
119102
},
120103
],
121104
},
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { SearchIcon, XIcon } from "lucide-react";
2+
import {
3+
Autocomplete as AriaAutocomplete,
4+
type AutocompleteProps as AriaAutocompleteProps,
5+
Button as AriaButton,
6+
Input as AriaInput,
7+
type InputProps as AriaInputProps,
8+
SearchField as AriaSearchField,
9+
type SearchFieldProps as AriaSearchFieldProps,
10+
} from "react-aria-components";
11+
12+
import { classNames } from "@/shared/utils/common";
13+
14+
export function Autocomplete({ children, ...props }: AriaAutocompleteProps) {
15+
return (
16+
<AriaAutocomplete {...props}>
17+
<div className="max-h-[inherit] overflow-hidden">
18+
<AutocompleteSearchField placeholder="Search..." />
19+
{children}
20+
</div>
21+
</AriaAutocomplete>
22+
);
23+
}
24+
25+
export interface SearchInputProps extends AriaSearchFieldProps {
26+
placeholder?: AriaInputProps["placeholder"];
27+
}
28+
29+
export function AutocompleteSearchField({ className, placeholder, ...props }: SearchInputProps) {
30+
return (
31+
<AriaSearchField
32+
className="group sticky flex items-center border-neutral-200 border-b px-2 text-sm"
33+
aria-label="Search"
34+
autoFocus
35+
{...props}
36+
>
37+
<SearchIcon aria-hidden className="size-3.5 text-neutral-400" />
38+
<AriaInput
39+
className={classNames(
40+
"min-w-0 flex-1 border-none px-2 py-1.5 outline-hidden placeholder:text-neutral-400 [&::-webkit-search-cancel-button]:hidden",
41+
className
42+
)}
43+
placeholder={placeholder}
44+
/>
45+
<AriaButton
46+
className={classNames(
47+
"inline-flex rounded-full p-1 opacity-70 transition-all",
48+
"hover:bg-neutral-200 hover:opacity-100",
49+
"data-disabled:pointer-events-none",
50+
"group-data-empty:invisible"
51+
)}
52+
>
53+
<XIcon aria-hidden className="size-3.5" />
54+
</AriaButton>
55+
</AriaSearchField>
56+
);
57+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { CheckIcon } from "lucide-react";
2+
import {
3+
ListBox as AriaListBox,
4+
ListBoxItem as AriaListBoxItem,
5+
type ListBoxItemProps as AriaListBoxItemProps,
6+
type ListBoxProps as AriaListBoxProps,
7+
} from "react-aria-components";
8+
9+
import { disabledStyle } from "@/shared/components/style-rac";
10+
import { PushableItem, pushableItemContainerStyle } from "@/shared/components/ui/pushable-item";
11+
import { classNames } from "@/shared/utils/common";
12+
13+
export interface ListBoxProps<T> extends AriaListBoxProps<T> {
14+
emptyMessage?: string;
15+
}
16+
17+
export function ListBox<T extends object>({ className, emptyMessage, ...props }: ListBoxProps<T>) {
18+
return (
19+
<AriaListBox
20+
shouldFocusOnHover
21+
className={classNames("no-scrollbar max-h-[inherit] overflow-auto", className)}
22+
renderEmptyState={
23+
emptyMessage
24+
? () => <div className="px-2 py-1.5 text-neutral-600 text-sm">{emptyMessage}</div>
25+
: undefined
26+
}
27+
{...props}
28+
/>
29+
);
30+
}
31+
32+
export function ListBoxItem<T extends object>({
33+
children,
34+
className,
35+
textValue,
36+
...props
37+
}: AriaListBoxItemProps<T>) {
38+
return (
39+
<AriaListBoxItem
40+
textValue={textValue || (typeof children === "string" ? children : undefined)}
41+
className={classNames(disabledStyle, pushableItemContainerStyle)}
42+
{...props}
43+
>
44+
{(renderProps) => (
45+
<PushableItem
46+
variant="ghost"
47+
isElevated={renderProps.isFocused}
48+
isPressed={renderProps.isPressed}
49+
className={classNames(
50+
renderProps.selectionMode !== "none" && "pl-8",
51+
typeof className === "function"
52+
? className({ ...renderProps, defaultClassName: undefined })
53+
: className
54+
)}
55+
>
56+
{renderProps.isSelected && <CheckIcon className="absolute left-2 size-4" />}
57+
{typeof children === "function" ? children(renderProps) : children}
58+
</PushableItem>
59+
)}
60+
</AriaListBoxItem>
61+
);
62+
}

frontend/app/src/shared/components/aria/menu.tsx

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515

1616
import { Popover } from "@/shared/components/aria/popover";
1717
import { disabledStyle } from "@/shared/components/style-rac";
18+
import { PushableItem, pushableItemContainerStyle } from "@/shared/components/ui/pushable-item";
1819
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
1920
import { classNames } from "@/shared/utils/common";
2021

@@ -37,7 +38,7 @@ export const Menu = <T extends object>({ className, ...props }: MenuProps<T>) =>
3738
return (
3839
<AriaMenu
3940
className={classNames(
40-
"no-scrollbar max-h-[inherit] overflow-auto p-1 pb-1.5 outline-hidden",
41+
"no-scrollbar max-h-[inherit] overflow-auto p-1 outline-hidden",
4142
"space-y-0.5 *:[[role='group']:not(:last-child)]:mb-2",
4243
className
4344
)}
@@ -52,35 +53,22 @@ export const MenuItem = ({ children, className, textValue, ...props }: MenuItemP
5253
return (
5354
<AriaMenuItem
5455
textValue={textValue ?? (typeof children === "string" ? children : undefined)}
55-
className={classNames(
56-
disabledStyle,
57-
"relative flex cursor-pointer select-none outline-hidden"
58-
)}
56+
className={classNames(disabledStyle, pushableItemContainerStyle)}
5957
{...props}
6058
>
61-
{composeRenderProps(children, (children, { isFocused, isPressed }) => (
62-
<>
63-
{isFocused && (
64-
<span
65-
className={classNames(
66-
"absolute inset-0 translate-y-0.75 rounded-lg border-stone-400 border-b bg-button-edge-gradient shadow-xs",
67-
isPressed && "shadow-none"
68-
)}
69-
/>
70-
)}
71-
72-
<div
73-
className={classNames(
74-
"flex w-full min-w-40 items-center gap-2 rounded-lg border border-white bg-white px-2 py-1 text-sm text-stone-600 shadow-xs transition-transform duration-100 will-change-transform",
75-
isFocused && "border-stone-300 shadow-none",
76-
isPressed && "translate-y-0.75",
77-
className
78-
)}
79-
>
80-
{children}
81-
</div>
82-
</>
83-
))}
59+
{(renderProps) => (
60+
<PushableItem
61+
isElevated={renderProps.isFocused}
62+
isPressed={renderProps.isPressed}
63+
className={
64+
typeof className === "function"
65+
? className({ ...renderProps, defaultClassName: undefined })
66+
: className
67+
}
68+
>
69+
{typeof children === "function" ? children(renderProps) : children}
70+
</PushableItem>
71+
)}
8472
</AriaMenuItem>
8573
);
8674
};

frontend/app/src/shared/components/aria/popover.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function Popover({ className, offset = 4, ...props }: PopoverProps) {
1919
offset={offset}
2020
className={composeRenderProps(className, (className) =>
2121
classNames(
22-
"z-50 rounded-xl border border-neutral-200 bg-white shadow-md outline-hidden",
22+
"z-50 rounded-xl border border-neutral-200 bg-white shadow-md outline-hidden duration-50",
2323
"data-entering:fade-in-0 data-entering:zoom-in-95 data-entering:animate-in",
2424
"data-exiting:fade-out-0 data-exiting:zoom-out-95 data-exiting:animate-out",
2525
"data-[placement=bottom]:slide-in-from-top-2 data-[placement=left]:slide-in-from-right-2 data-[placement=right]:slide-in-from-left-2 data-[placement=top]:slide-in-from-bottom-2",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useParams } from "react-router";
2+
3+
import { constructPath } from "@/shared/api/rest/fetch";
4+
import BreadcrumbBranchSelector from "@/shared/components/layout/breadcrumb-navigation/items/breadcrumb-branch-selector";
5+
import { Breadcrumb, BreadcrumbItem, BreadcrumbSeparator } from "@/shared/components/ui/breadcrumb";
6+
7+
export function BreadcrumbBranches() {
8+
const { "*": branchName } = useParams();
9+
10+
return (
11+
<Breadcrumb data-testid="breadcrumb-branches">
12+
<BreadcrumbSeparator />
13+
<BreadcrumbItem href={constructPath("/branches")}>Branches</BreadcrumbItem>
14+
{branchName && (
15+
<>
16+
<BreadcrumbSeparator />
17+
<BreadcrumbBranchSelector currentBranchName={branchName} />
18+
</>
19+
)}
20+
</Breadcrumb>
21+
);
22+
}

frontend/app/src/shared/components/layout/breadcrumb-navigation/breadcrumb-navigation.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from "react";
2-
import { type UIMatch, useMatches } from "react-router";
2+
import { matchPath, type UIMatch, useLocation, useMatches } from "react-router";
33

4+
import { BreadcrumbBranches } from "@/shared/components/layout/breadcrumb-navigation/breadcrumb-branches";
45
import {
56
BreadcrumbDynamicElement,
67
type BreadcrumbDynamicElementProps,
@@ -10,11 +11,16 @@ import { Breadcrumb, BreadcrumbSeparator } from "@/shared/components/ui/breadcru
1011
import { classNames } from "@/shared/utils/common";
1112

1213
export default function BreadcrumbNavigation() {
14+
const { pathname } = useLocation();
1315
const matches = useMatches() as UIMatch<
1416
unknown,
1517
{ breadcrumb?: (match: UIMatch) => BreadcrumbDynamicElementProps }
1618
>[];
1719

20+
if (matchPath({ path: "/branches", end: false }, pathname)) {
21+
return <BreadcrumbBranches />;
22+
}
23+
1824
const crumbs = matches
1925
.map((match) => match.handle?.breadcrumb?.(match))
2026
.filter((match) => !!match);
Lines changed: 44 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,56 @@
1-
import { useAtomValue } from "jotai";
2-
import { useEffect, useState } from "react";
3-
import { Link, useNavigate } from "react-router";
1+
import { Icon } from "@iconify-icon/react";
2+
import { ChevronsUpDownIcon } from "lucide-react";
3+
import { useFilter } from "react-aria-components";
44

5-
import { queryClient } from "@/shared/api/rest/client";
65
import { constructPath } from "@/shared/api/rest/fetch";
7-
import { breadcrumbItemStyle } from "@/shared/components/layout/breadcrumb-navigation/style";
8-
import {
9-
Combobox,
10-
ComboboxContent,
11-
ComboboxList,
12-
ComboboxTrigger,
13-
} from "@/shared/components/ui/combobox";
14-
import { CommandEmpty, CommandItem } from "@/shared/components/ui/command";
15-
import { classNames } from "@/shared/utils/common";
6+
import { Autocomplete } from "@/shared/components/aria/autocomplete";
7+
import { ListBox, ListBoxItem } from "@/shared/components/aria/list-box";
8+
import { Popover, PopoverDialog, PopoverTrigger } from "@/shared/components/aria/popover";
9+
import { BreadcrumbItem } from "@/shared/components/ui/breadcrumb";
1610

17-
import { getBranchesQueryOptions } from "@/entities/branches/domain/get-branches.query";
18-
import { branchesState } from "@/entities/branches/stores";
11+
import { useGetBranches } from "@/entities/branches/domain/get-branches.query";
12+
13+
interface BreadcrumbBranchSelectorProps {
14+
currentBranchName: string;
15+
}
1916

2017
export default function BreadcrumbBranchSelector({
21-
value,
22-
className,
18+
currentBranchName,
2319
...props
24-
}: {
25-
value: string;
26-
className?: string;
27-
}) {
28-
const branches = useAtomValue(branchesState);
29-
const navigate = useNavigate();
30-
const [isOpen, setIsOpen] = useState(false);
31-
32-
useEffect(() => {
33-
if (isOpen) queryClient.invalidateQueries(getBranchesQueryOptions());
34-
}, [isOpen]);
20+
}: BreadcrumbBranchSelectorProps) {
21+
const { data: branches = [] } = useGetBranches();
22+
const { contains } = useFilter({ sensitivity: "base" });
3523

3624
return (
37-
<Combobox open={isOpen} onOpenChange={setIsOpen}>
38-
<ComboboxTrigger className={classNames(breadcrumbItemStyle, className)} {...props}>
39-
{value}
40-
</ComboboxTrigger>
25+
<PopoverTrigger>
26+
<BreadcrumbItem {...props}>
27+
<span className="truncate">{currentBranchName}</span>
28+
<ChevronsUpDownIcon className="ml-2 size-4" />
29+
</BreadcrumbItem>
4130

42-
<ComboboxContent align="start" fitTriggerWidth={false}>
43-
<ComboboxList>
44-
<CommandEmpty>No branch found.</CommandEmpty>
45-
{branches.map((branch) => {
46-
const branchUrl = constructPath(`/branches/${branch.name}`);
47-
return (
48-
<CommandItem
49-
key={branch.name}
50-
value={branch.name}
51-
onSelect={() => {
52-
setIsOpen(false);
53-
navigate(branchUrl);
54-
}}
55-
asChild
31+
<Popover className="bg-stone-100/50 backdrop-blur">
32+
<PopoverDialog aria-label="Branch selector">
33+
{({ close }) => (
34+
<Autocomplete filter={contains}>
35+
<ListBox
36+
items={branches}
37+
emptyMessage="No branches found."
38+
className="p-1"
39+
onAction={close}
5640
>
57-
<Link to={branchUrl}>{branch.name}</Link>
58-
</CommandItem>
59-
);
60-
})}
61-
</ComboboxList>
62-
</ComboboxContent>
63-
</Combobox>
41+
{(branch) => (
42+
<ListBoxItem
43+
textValue={branch.name}
44+
href={constructPath(`/branches/${branch.name}`)}
45+
>
46+
<Icon icon="mdi:source-branch" /> {branch.name}
47+
</ListBoxItem>
48+
)}
49+
</ListBox>
50+
</Autocomplete>
51+
)}
52+
</PopoverDialog>
53+
</Popover>
54+
</PopoverTrigger>
6455
);
6556
}

0 commit comments

Comments
 (0)