This is an automated email from the ASF dual-hosted git repository.
ryanahamilton pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 5076bbc0654 Refine the visual design, interaction, and accessibility
of the global navigation (#57455)
5076bbc0654 is described below
commit 5076bbc06546c47c148b3c7e2ba463713f646018
Author: Ryan Hamilton <[email protected]>
AuthorDate: Wed Oct 29 07:09:24 2025 -0400
Refine the visual design, interaction, and accessibility of the global
navigation (#57455)
---
.../src/airflow/ui/src/layouts/Nav/AdminButton.tsx | 2 +-
.../airflow/ui/src/layouts/Nav/BrowseButton.tsx | 2 +-
.../src/airflow/ui/src/layouts/Nav/DocsButton.tsx | 13 +-
.../src/airflow/ui/src/layouts/Nav/Nav.tsx | 25 ++--
.../src/airflow/ui/src/layouts/Nav/NavButton.tsx | 140 +++++++++++++-------
.../src/airflow/ui/src/layouts/Nav/PluginMenus.tsx | 2 +-
.../airflow/ui/src/layouts/Nav/SecurityButton.tsx | 2 +-
.../ui/src/layouts/Nav/TimezoneMenuItem.tsx | 5 +-
.../ui/src/layouts/Nav/UserSettingsButton.tsx | 143 ++++++++++-----------
9 files changed, 188 insertions(+), 146 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
index ddc01d9db28..427d994c55a 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/AdminButton.tsx
@@ -79,7 +79,7 @@ export const AdminButton = ({
return (
<Menu.Root positioning={{ placement: "right" }}>
<Menu.Trigger asChild>
- <NavButton icon={<FiSettings size={28} />}
title={translate("nav.admin")} />
+ <NavButton icon={FiSettings} title={translate("nav.admin")} />
</Menu.Trigger>
<Menu.Content>
{menuItems}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
index 170f89a94f5..986fa973bbc 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/BrowseButton.tsx
@@ -70,7 +70,7 @@ export const BrowseButton = ({
return (
<Menu.Root positioning={{ placement: "right" }}>
<Menu.Trigger asChild>
- <NavButton icon={<FiGlobe size={28} />}
title={translate("nav.browse")} />
+ <NavButton icon={FiGlobe} title={translate("nav.browse")} />
</Menu.Trigger>
<Menu.Content>
{menuItems}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx
index d73b323b3e1..3af4e7f93a6 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/DocsButton.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Link } from "@chakra-ui/react";
+import { Box, Icon, Link } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { FiBookOpen, FiExternalLink } from "react-icons/fi";
@@ -61,7 +61,7 @@ export const DocsButton = ({
return (
<Menu.Root positioning={{ placement: "right" }}>
<Menu.Trigger asChild>
- <NavButton icon={<FiBookOpen size={28} />}
title={translate("nav.docs")} />
+ <NavButton icon={FiBookOpen} title={translate("nav.docs")} />
</Menu.Trigger>
<Menu.Content>
{links
@@ -73,17 +73,18 @@ export const DocsButton = ({
href={link.href}
rel="noopener noreferrer"
target="_blank"
+ textDecoration="none"
>
- {translate(`docs.${link.key}`)}
- <FiExternalLink />
+ <Box flex="1">{translate(`docs.${link.key}`)}</Box>
+ <Icon as={FiExternalLink} boxSize={4} color="fg.muted" />
</Link>
</Menu.Item>
))}
{version === undefined ? undefined : (
<Menu.Item asChild key={version} value={version}>
<Link aria-label={version} href={versionLink} rel="noopener
noreferrer" target="_blank">
- {version}
- <FiExternalLink />
+ <Box flex="1">{version}</Box>
+ <Icon as={FiExternalLink} boxSize={4} color="fg.muted" />
</Link>
</Menu.Item>
)}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
index b8a134930ff..1ee7444b3bd 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/Nav.tsx
@@ -19,7 +19,7 @@
import { Box, Flex, VStack } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { FiDatabase, FiHome } from "react-icons/fi";
-import { NavLink } from "react-router-dom";
+import { Link } from "react-router-dom";
import {
useAuthLinksServiceGetAuthMenus,
@@ -140,14 +140,14 @@ export const Nav = () => {
height="100%"
justifyContent="space-between"
position="fixed"
- py={3}
+ py={1}
top={0}
- width={20}
+ width={16}
zIndex={2}
>
- <Flex alignItems="center" flexDir="column" width="100%">
- <Box mb={3}>
- <NavLink to="/">
+ <Flex alignItems="center" flexDir="column" width="100%" gap={1}>
+ <Box asChild boxSize={14} display="flex" alignItems="center"
justifyContent="center">
+ <Link to="/" title={translate("nav.home")}>
<AirflowPin
_motionSafe={{
_hover: {
@@ -155,21 +155,20 @@ export const Nav = () => {
transition: "transform 0.8s ease-in-out"
}
}}
- height="35px"
- width="35px"
+ boxSize={8}
/>
- </NavLink>
+ </Link>
</Box>
- <NavButton icon={<FiHome size="28px" />} title={translate("nav.home")}
to="/" />
+ <NavButton icon={FiHome} title={translate("nav.home")} to="/" />
<NavButton
disabled={!authLinks?.authorized_menu_items.includes("Dags")}
- icon={<DagIcon height="28px" width="28px" />}
+ icon={DagIcon}
title={translate("nav.dags")}
to="dags"
/>
<NavButton
disabled={!authLinks?.authorized_menu_items.includes("Assets")}
- icon={<FiDatabase size="28px" />}
+ icon={FiDatabase}
title={translate("nav.assets")}
to="assets"
/>
@@ -184,7 +183,7 @@ export const Nav = () => {
<SecurityButton />
<PluginMenus navItems={navItemsWithLegacy} />
</Flex>
- <Flex flexDir="column">
+ <Flex flexDir="column" gap={1}>
<DocsButton
externalViews={docsItems}
showAPI={authLinks?.authorized_menu_items.includes("Docs")}
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.tsx
index e8f14803366..3ebea71bef1 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/NavButton.tsx
@@ -16,62 +16,104 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Box, Button, Link, type ButtonProps } from "@chakra-ui/react";
-import type { ReactElement } from "react";
-import { NavLink } from "react-router-dom";
+import { Box, type BoxProps, Button, Icon, type IconProps, Link, type
ButtonProps } from "@chakra-ui/react";
+import { useMemo, type ForwardRefExoticComponent, type RefAttributes } from
"react";
+import { IconType } from "react-icons";
+import { Link as RouterLink, useMatch } from "react-router-dom";
-const styles = {
- _active: {
- bg: "brand.emphasized",
- },
- // Fix inverted hover and active colors
- _hover: {
- bg: "brand.emphasized", // Even darker for better light mode contrast
- },
- alignItems: "center",
- borderRadius: "none",
- colorPalette: "brand",
- flexDir: "column",
- height: 20,
- variant: "ghost",
- whiteSpace: "wrap",
- width: 20,
-} satisfies ButtonProps;
+const commonLabelProps: BoxProps = {
+ fontSize: "2xs",
+ overflow: "hidden",
+ textAlign: "center",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ width: "full",
+};
type NavButtonProps = {
- readonly icon: ReactElement;
+ readonly icon: IconType | ForwardRefExoticComponent<IconProps &
RefAttributes<SVGSVGElement>>;
readonly isExternal?: boolean;
- readonly title?: string;
+ readonly title: string;
readonly to?: string;
} & ButtonProps;
-export const NavButton = ({ icon, isExternal = false, title, to, ...rest }:
NavButtonProps) =>
- to === undefined ? (
- <Button {...styles} {...rest}>
- <Box alignSelf="center">{icon}</Box>
- <Box fontSize="xs">{title}</Box>
- </Button>
- ) : isExternal ? (
- <Link href={to} px={2} rel="noopener noreferrer" target="_blank">
- <Button {...styles} variant="ghost" {...rest}>
- <Box alignSelf="center">{icon}</Box>
- <Box fontSize="xs">{title}</Box>
+export const NavButton = ({ icon, isExternal = false, title, to, ...rest }:
NavButtonProps) => {
+ // Use useMatch to determine if the current route matches the button's
destination
+ // This provides the same functionality as NavLink's isActive prop
+ // Only applies to buttons with a to prop (but needs to be before any return
statements)
+ const match = to ? useMatch({
+ path: to,
+ end: to === "/" // Only exact match for root path
+ }) : undefined;
+ // Only applies to buttons with a to prop
+ const isActive = Boolean(match);
+
+ const commonButtonProps = useMemo<ButtonProps>(() => ({
+ _expanded: isActive ? undefined : {
+ bg: "brand.emphasized", // Even darker for better light mode contrast
+ color: "fg",
+ },
+ _focus: isActive ? undefined : {
+ color: "fg",
+ },
+ _hover: isActive ? undefined : {
+ bg: "brand.emphasized", // Even darker for better light mode contrast
+ color: "fg",
+ _active: {
+ bg: "brand.solid",
+ color: "white",
+ },
+ },
+ alignItems: "center",
+ bg: isActive ? "brand.solid" : undefined,
+ borderRadius: "md",
+ borderWidth: 0,
+ boxSize: 14,
+ color: isActive ? "white" : "fg.muted",
+ colorPalette: "brand",
+ cursor: "pointer",
+ flexDir: "column",
+ gap: 0,
+ overflow: "hidden",
+ padding: 0,
+ textDecoration: "none",
+ title,
+ transition: "background-color 0.2s ease, color 0.2s ease",
+ variant: "plain",
+ whiteSpace: "wrap",
+ ...rest,
+ }), [isActive, rest, title]);
+
+ if (to === undefined) {
+ return (
+ <Button {...commonButtonProps}>
+ <Icon as={icon} boxSize={5} />
+ <Box {...commonLabelProps}>{title}</Box>
</Button>
- </Link>
- ) : (
- <NavLink to={to}>
- {({ isActive }: { readonly isActive: boolean }) => (
- <Button
- {...styles}
- _active={isActive ? { bg: "brand.solid" } : { bg: "brand.emphasized"
}}
- // Override styles for active state to ensure proper colors
- _hover={isActive ? { bg: "brand.solid" } : { bg: "brand.emphasized"
}}
- variant={isActive ? "solid" : "ghost"}
- {...rest}
- >
- <Box alignSelf="center">{icon}</Box>
- <Box fontSize="xs">{title}</Box>
+ );
+ }
+
+ if (isExternal) {
+ return (
+ <Link href={to} asChild rel="noopener noreferrer" target="_blank">
+ <Button {...commonButtonProps}>
+ <Icon as={icon} boxSize={5} />
+ <Box {...commonLabelProps}>{title}</Box>
</Button>
- )}
- </NavLink>
+ </Link>
+ );
+ }
+
+ return (
+ <Button
+ as={Link}
+ asChild
+ {...commonButtonProps}
+ >
+ <RouterLink to={to}>
+ <Icon as={icon} boxSize={5} />
+ <Box {...commonLabelProps}>{title}</Box>
+ </RouterLink>
+ </Button>
);
+};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
index 2dfbf58ac3d..c8727804890 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/PluginMenus.tsx
@@ -53,7 +53,7 @@ export const PluginMenus = ({ navItems }: { readonly
navItems: Array<NavItemResp
return navItems.length >= 2 ? (
<Menu.Root positioning={{ placement: "right" }}>
<Menu.Trigger>
- <NavButton as={Box} icon={<LuPlug />} title={translate("nav.plugins")}
/>
+ <NavButton as={Box} icon={LuPlug} title={translate("nav.plugins")} />
</Menu.Trigger>
<Menu.Content>
{buttons.map((navItem) => (
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx
index 6d9fdca68f0..2955abce5ee 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/SecurityButton.tsx
@@ -36,7 +36,7 @@ export const SecurityButton = () => {
return (
<Menu.Root positioning={{ placement: "right" }}>
<Menu.Trigger asChild>
- <NavButton icon={<FiLock size={28} />}
title={translate("nav.security")} />
+ <NavButton icon={FiLock} title={translate("nav.security")} />
</Menu.Trigger>
<Menu.Content>
{authLinks.extra_menu_items.map(({ text }) => {
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/TimezoneMenuItem.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/TimezoneMenuItem.tsx
index 596cf79bb1b..cb13b3c9763 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/TimezoneMenuItem.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/TimezoneMenuItem.tsx
@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { Box, Icon } from "@chakra-ui/react";
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
@@ -48,8 +49,8 @@ export const TimezoneMenuItem = ({ onOpen }: { readonly
onOpen: () => void }) =>
return (
<Menu.Item onClick={onOpen} value="timezone">
- <FiClock size={20} style={{ marginRight: "8px" }} />
- {translate("timezone")}: {dayjs(time).tz(selectedTimezone).format("HH:mm
z (Z)")}
+ <Icon as={FiClock} boxSize={4} />
+ <Box flex="1">{translate("timezone")}:
{dayjs(time).tz(selectedTimezone).format("HH:mm z (Z)")}</Box>
</Menu.Item>
);
};
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
index 31e2dd0078a..15ee91b0422 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useDisclosure } from "@chakra-ui/react";
+import { Box, Icon, useDisclosure } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import {
FiGrid,
@@ -55,9 +55,29 @@ type ColorMode = (typeof COLOR_MODES)[keyof typeof
COLOR_MODES];
export const UserSettingsButton = ({ externalViews }: { readonly
externalViews: Array<NavItemResponse> }) => {
const { i18n, t: translate } = useTranslation();
const { selectedTheme, setColorMode } = useColorMode();
+
+ const colorModeOptions = [
+ {
+ icon: FiSun,
+ label: translate("appearance.lightMode"),
+ value: COLOR_MODES.LIGHT,
+ },
+ {
+ icon: FiMoon,
+ label: translate("appearance.darkMode"),
+ value: COLOR_MODES.DARK,
+ },
+ {
+ icon: FiMonitor,
+ label: translate("appearance.systemMode"),
+ value: COLOR_MODES.SYSTEM,
+ },
+ ];
+
const { onClose: onCloseTimezone, onOpen: onOpenTimezone, open:
isOpenTimezone } = useDisclosure();
const { onClose: onCloseLogout, onOpen: onOpenLogout, open: isOpenLogout } =
useDisclosure();
const { onClose: onCloseLanguage, onOpen: onOpenLanguage, open:
isOpenLanguage } = useDisclosure();
+
const [dagView, setDagView] = useLocalStorage<"graph" |
"grid">("default_dag_view", "grid");
const theme = selectedTheme ?? COLOR_MODES.SYSTEM;
@@ -65,79 +85,58 @@ export const UserSettingsButton = ({ externalViews }: {
readonly externalViews:
const isRTL = i18n.dir() === "rtl";
return (
- <Menu.Root positioning={{ placement: "right" }}>
- <Menu.Trigger asChild>
- <NavButton icon={<FiUser size={28} />} title={translate("user")} />
- </Menu.Trigger>
- <Menu.Content>
- <Menu.Item onClick={onOpenLanguage} value="language">
- <FiGlobe size={20} style={{ marginRight: "8px" }} />
- {translate("selectLanguage")}
- </Menu.Item>
- <Menu.Root>
- <Menu.TriggerItem>
- <FiEye size={20} style={{ marginRight: "8px" }} />
- {translate("appearance.appearance")}
- {isRTL ? (
- <FiChevronLeft size={20} style={{ marginRight: "auto" }} />
- ) : (
- <FiChevronRight size={20} style={{ marginLeft: "auto" }} />
- )}
- </Menu.TriggerItem>
- <Menu.Content>
- <Menu.RadioItemGroup
- onValueChange={(element) => setColorMode(element.value as
ColorMode)}
- value={theme}
- >
- <Menu.RadioItem value={COLOR_MODES.LIGHT}>
- <FiSun size={20} style={{ marginRight: "8px" }} />
- {translate("appearance.lightMode")}
- <Menu.ItemIndicator />
- </Menu.RadioItem>
- <Menu.RadioItem value={COLOR_MODES.DARK}>
- <FiMoon size={20} style={{ marginRight: "8px" }} />
- {translate("appearance.darkMode")}
- <Menu.ItemIndicator />
- </Menu.RadioItem>
- <Menu.RadioItem value={COLOR_MODES.SYSTEM}>
- <FiMonitor size={20} style={{ marginRight: "8px" }} />
- {translate("appearance.systemMode")}
- <Menu.ItemIndicator />
- </Menu.RadioItem>
- </Menu.RadioItemGroup>
- </Menu.Content>
- </Menu.Root>
- <Menu.Item
- onClick={() => (dagView === "grid" ? setDagView("graph") :
setDagView("grid"))}
- value={dagView}
- >
- {dagView === "grid" ? (
- <>
- <MdOutlineAccountTree size={20} style={{ marginRight: "8px" }} />
- {translate("defaultToGraphView")}
- </>
- ) : (
- <>
- <FiGrid size={20} style={{ marginRight: "8px" }} />
- {translate("defaultToGridView")}
- </>
- )}
- </Menu.Item>
- <TimezoneMenuItem onOpen={onOpenTimezone} />
- {externalViews.map((view) => (
- <PluginMenuItem {...view} key={view.name} />
- ))}
- <Menu.Item onClick={onOpenLogout} value="logout">
- <FiLogOut
- size={20}
- style={{ marginRight: "8px", transform: isRTL ? "rotate(180deg)" :
undefined }}
- />
- {translate("logout")}
- </Menu.Item>
- </Menu.Content>
+ <>
+ <Menu.Root positioning={{ placement: "right" }}>
+ <Menu.Trigger asChild>
+ <NavButton icon={FiUser} title={translate("user")} />
+ </Menu.Trigger>
+ <Menu.Content>
+ <Menu.Item onClick={onOpenLanguage} value="language">
+ <Icon as={FiGlobe} boxSize={4} />
+ <Box flex="1">{translate("selectLanguage")}</Box>
+ </Menu.Item>
+ <Menu.Root>
+ <Menu.TriggerItem>
+ <Icon as={FiEye} boxSize={4} />
+ <Box flex="1">{translate("appearance.appearance")}</Box>
+ <Icon as={isRTL ? FiChevronLeft : FiChevronRight} boxSize={4}
color="fg.muted" />
+ </Menu.TriggerItem>
+ <Menu.Content>
+ <Menu.RadioItemGroup
+ onValueChange={(element) => setColorMode(element.value as
ColorMode)}
+ value={theme}
+ >
+ {colorModeOptions.map(({ icon, label, value }) => (
+ <Menu.RadioItem key={value} value={value}>
+ <Icon as={icon} boxSize={4} />
+ <Box flex="1">{label}</Box>
+ <Menu.ItemIndicator color="fg.muted" />
+ </Menu.RadioItem>
+ ))}
+ </Menu.RadioItemGroup>
+ </Menu.Content>
+ </Menu.Root>
+ <Menu.Item
+ onClick={() => (dagView === "grid" ? setDagView("graph") :
setDagView("grid"))}
+ value={dagView}
+ >
+ <Icon as={dagView === "grid" ? MdOutlineAccountTree : FiGrid}
boxSize={4} />
+ <Box flex="1">{dagView === "grid" ?
translate("defaultToGraphView") : translate("defaultToGridView")}</Box>
+ </Menu.Item>
+ <TimezoneMenuItem onOpen={onOpenTimezone} />
+ {externalViews.map((view) => (
+ <PluginMenuItem {...view} key={view.name} />
+ ))}
+ <Menu.Separator />
+ <Menu.Item onClick={onOpenLogout} value="logout">
+ <Icon as={FiLogOut} boxSize={4} transform={isRTL ?
"rotate(180deg)" : undefined} />
+ <Box flex="1">{translate("logout")}</Box>
+ </Menu.Item>
+ </Menu.Content>
+ </Menu.Root>
<LanguageModal isOpen={isOpenLanguage} onClose={onCloseLanguage} />
<TimezoneModal isOpen={isOpenTimezone} onClose={onCloseTimezone} />
<LogoutModal isOpen={isOpenLogout} onClose={onCloseLogout} />
- </Menu.Root>
+ </>
);
};