diff --git a/app/components/DocsPopover.tsx b/app/components/DocsPopover.tsx index 185d7a948..33035d8a9 100644 --- a/app/components/DocsPopover.tsx +++ b/app/components/DocsPopover.tsx @@ -55,7 +55,7 @@ export const DocsPopover = ({ heading, icon, summary, links }: DocsPopoverProps)

diff --git a/app/components/ErrorPage.tsx b/app/components/ErrorPage.tsx index feb4643f6..c6f731ce3 100644 --- a/app/components/ErrorPage.tsx +++ b/app/components/ErrorPage.tsx @@ -40,7 +40,7 @@ export function ErrorPage({ children }: Props) {

-
+
diff --git a/app/components/PageSkeleton.tsx b/app/components/PageSkeleton.tsx index 5a997d50c..e1ce93c55 100644 --- a/app/components/PageSkeleton.tsx +++ b/app/components/PageSkeleton.tsx @@ -13,6 +13,7 @@ import { ContentPane, PageContainer, sidebarWrapperClass, + topBarHomeCellClass, topBarWrapperClass, } from '~/layouts/helpers' import { classed } from '~/util/classed' @@ -31,7 +32,7 @@ export function PageSkeleton({ skipPaths }: { skipPaths?: RegExp[] }) { {/* TopBar */}
-
+
@@ -44,7 +45,8 @@ export function PageSkeleton({ skipPaths }: { skipPaths?: RegExp[] }) {
{/* Sidebar */} -
+ {/* on mobile the sidebar is an overlay, closed (translated off-screen) by default */} +
diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 769eb7eb0..4d88d1ea3 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -6,6 +6,7 @@ * Copyright Oxide Computer Company */ import cn from 'classnames' +import { useEffect } from 'react' import { Link, useLocation } from 'react-router' import { Action16Icon, Document16Icon } from '@oxide/design-system/icons/react' @@ -13,12 +14,13 @@ import { Action16Icon, Document16Icon } from '@oxide/design-system/icons/react' import { useIsActivePath } from '~/hooks/use-is-active-path' import { openQuickActions } from '~/hooks/use-quick-actions' import { sidebarWrapperClass } from '~/layouts/helpers' +import { closeMobileNav, useMobileNavStore } from '~/stores/mobile-nav' import { Button } from '~/ui/lib/Button' import { Truncate } from '~/ui/lib/Truncate' const linkStyles = (isActive = false) => cn( - 'flex h-7 items-center rounded-md px-2 text-sans-md [&>svg]:mr-2', + 'flex h-7 items-center rounded-md px-2 text-sans-md pointer-coarse:h-8 [&>svg]:mr-2', isActive ? 'text-accent bg-accent hover:bg-accent-hover [&>svg]:text-accent-tertiary' : 'hover:bg-hover [&>svg]:text-quaternary text-default' @@ -62,18 +64,37 @@ const JumpToButton = () => { } export function Sidebar({ children }: { children: React.ReactNode }) { + const mobileNavOpen = useMobileNavStore((state) => state.isOpen) + const { pathname } = useLocation() + + // close the mobile nav overlay on any navigation, including ones triggered + // outside the sidebar (breadcrumbs, quick actions) + useEffect(() => closeMobileNav(), [pathname]) + return ( -
+ {/* scrim behind the mobile nav overlay. covers everything below the top + bar so the toggle button in the top bar stays clickable */} + {mobileNavOpen && ( +
)} - > -
- +
+
+ +
+ {children}
- {children} -
+ ) } diff --git a/app/components/Terminal.tsx b/app/components/Terminal.tsx index 9a287c1cd..7c24fca05 100644 --- a/app/components/Terminal.tsx +++ b/app/components/Terminal.tsx @@ -126,10 +126,10 @@ export function Terminal({ ws }: TerminalProps) { <>
-
+
term?.scrollToTop()} aria-label="Scroll to top"> diff --git a/app/components/TopBar.tsx b/app/components/TopBar.tsx index 11f277b7c..8c1eacff0 100644 --- a/app/components/TopBar.tsx +++ b/app/components/TopBar.tsx @@ -6,12 +6,16 @@ * Copyright Oxide Computer Company */ import cn from 'classnames' +import { Fragment, useLayoutEffect, useRef, useState } from 'react' import { Link } from 'react-router' import { api, navToLogin, useApiMutation } from '@oxide/api' import { + MenuClose12Icon, + MenuOpen12Icon, Monitor12Icon, Moon12Icon, + More12Icon, Organization16Icon, Profile16Icon, SelectArrows6Icon, @@ -22,27 +26,28 @@ import { import { useCrumbs } from '~/hooks/use-crumbs' import { useCurrentUser } from '~/hooks/use-current-user' -import { topBarWrapperClass } from '~/layouts/helpers' +import { topBarHomeCellClass, topBarWrapperClass } from '~/layouts/helpers' +import { toggleMobileNav, useMobileNavStore } from '~/stores/mobile-nav' import { useThemeStore, type Theme } from '~/stores/theme' import { buttonStyle } from '~/ui/lib/Button' import * as DropdownMenu from '~/ui/lib/DropdownMenu' import { Identicon } from '~/ui/lib/Identicon' import { Slash } from '~/ui/lib/Slash' -import { intersperse } from '~/util/array' import { pb } from '~/util/path-builder' export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) { const { me } = useCurrentUser() return (
-
+
-
-
+
+
+
-
+
{me.fleetViewer && }
@@ -51,6 +56,26 @@ export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) { ) } +function MobileNavToggle() { + const isOpen = useMobileNavStore((state) => state.isOpen) + const Icon = isOpen ? MenuClose12Icon : MenuOpen12Icon + return ( + // full-height cell with a right border so the toggle reads as its own + // region, mirroring the desktop home button cell +
+ +
+ ) +} + const bigIconBox = 'flex h-[34px] w-[34px] items-center justify-center rounded-md' const BigIdenticon = ({ name }: { name: string }) => ( @@ -98,30 +123,163 @@ function HomeButton({ level }: { level: 'system' | 'silo' }) { function Breadcrumbs() { const crumbs = useCrumbs().filter((c) => !c.titleOnly) + const lastCrumb = crumbs.length - 1 + const { firstVisibleCrumb, measurementRef, navRef } = useBreadcrumbOverflow(crumbs) + const hasHiddenCrumbs = firstVisibleCrumb > 0 + const visibleCrumbs = crumbs.slice(firstVisibleCrumb) + return ( ) } +type Breadcrumb = ReturnType[number] + +function useBreadcrumbOverflow(crumbs: Breadcrumb[]) { + const navRef = useRef(null) + const measurementRef = useRef(null) + const [firstVisibleCrumb, setFirstVisibleCrumb] = useState(() => + Math.max(0, crumbs.length - 1) + ) + const crumbLabels = crumbs.map(({ label }) => label).join('\0') + + useLayoutEffect(() => { + const nav = navRef.current + const measurement = measurementRef.current + if (!nav || !measurement || crumbs.length === 0) return + + const update = () => { + const crumbElements = Array.from( + measurement.querySelectorAll('[data-breadcrumb-crumb]') + ) + const ellipsis = measurement.querySelector('[data-breadcrumb-ellipsis]') + const slash = measurement.querySelector('.breadcrumb-measure-slash') + if (crumbElements.length !== crumbs.length || !ellipsis || !slash) return + + const style = getComputedStyle(measurement) + const gap = Number.parseFloat(style.columnGap) || 0 + const slashStyle = getComputedStyle(slash) + const slashWidth = + slash.getBoundingClientRect().width + + (Number.parseFloat(slashStyle.marginLeft) || 0) + + (Number.parseFloat(slashStyle.marginRight) || 0) + const ellipsisWidth = ellipsis.getBoundingClientRect().width + const crumbWidths = crumbElements.map( + (element) => element.getBoundingClientRect().width + ) + const lastCrumbIndex = crumbs.length - 1 + + // Prefer the longest complete suffix that fits. The current crumb remains when no + // suffix fits and its CSS ellipsis becomes the final fallback. + let nextFirstVisible = lastCrumbIndex + for (let candidate = 0; candidate <= lastCrumbIndex; candidate++) { + const visibleCount = crumbs.length - candidate + const visibleCrumbWidth = crumbWidths + .slice(candidate) + .reduce((total, width) => total + width, 0) + const separatorWidth = (visibleCount - 1) * (slashWidth + gap * 2) + const collapsedPrefixWidth = + candidate > 0 ? ellipsisWidth + slashWidth + gap * 2 : 0 + + if (visibleCrumbWidth + separatorWidth + collapsedPrefixWidth <= nav.clientWidth) { + nextFirstVisible = candidate + break + } + } + + setFirstVisibleCrumb((current) => + current === nextFirstVisible ? current : nextFirstVisible + ) + } + + update() + const observer = new ResizeObserver(update) + observer.observe(nav) + observer.observe(measurement) + return () => observer.disconnect() + }, [crumbLabels, crumbs.length]) + + return { + firstVisibleCrumb: Math.min(firstVisibleCrumb, Math.max(0, crumbs.length - 1)), + measurementRef, + navRef, + } +} + function UserMenu() { const logout = useApiMutation(api.logout, { onSuccess: () => navToLogin({ includeCurrent: false }), @@ -138,15 +296,23 @@ function UserMenu() { )} > - + {me.displayName || 'User'}
- Settings - - logout.mutate({})} label="Sign out" /> + + +
User
+
+ {me.displayName || 'User'} +
+
+ Settings + + logout.mutate({})} label="Sign out" /> +
) diff --git a/app/components/form/fields/DateTimeRangePicker.tsx b/app/components/form/fields/DateTimeRangePicker.tsx index 81d62af44..49f1081bb 100644 --- a/app/components/form/fields/DateTimeRangePicker.tsx +++ b/app/components/form/fields/DateTimeRangePicker.tsx @@ -127,9 +127,9 @@ export function DateTimeRangePicker({ items, }: DateTimeRangePickerProps) { return ( -
+
-
-