From b8d81208b84fe253bc70a5d33c8000b8cba1917e Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 1 Sep 2026 11:25:21 -0500 Subject: [PATCH 1/7] Improve mobile styling and navigation --- app/components/PageSkeleton.tsx | 6 +- app/components/Sidebar.tsx | 39 +++++++--- app/components/TopBar.tsx | 50 +++++++++--- app/layouts/helpers.tsx | 14 +++- app/stores/mobile-nav.ts | 20 +++++ app/ui/lib/DropdownMenu.tsx | 2 + app/ui/lib/Modal.tsx | 8 +- app/ui/lib/SideModal.tsx | 2 +- app/ui/lib/Toast.tsx | 2 +- app/ui/styles/components/Tabs.css | 10 +++ app/ui/styles/index.css | 2 +- test/e2e/mobile-layout.e2e.ts | 123 ++++++++++++++++++++++++++++++ 12 files changed, 248 insertions(+), 30 deletions(-) create mode 100644 app/stores/mobile-nav.ts create mode 100644 test/e2e/mobile-layout.e2e.ts 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..e9b67c4e0 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,6 +14,7 @@ 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' @@ -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/TopBar.tsx b/app/components/TopBar.tsx index 11f277b7c..ce153b463 100644 --- a/app/components/TopBar.tsx +++ b/app/components/TopBar.tsx @@ -10,6 +10,8 @@ import { Link } from 'react-router' import { api, navToLogin, useApiMutation } from '@oxide/api' import { + MenuClose12Icon, + MenuOpen12Icon, Monitor12Icon, Moon12Icon, Organization16Icon, @@ -22,7 +24,8 @@ 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' @@ -35,14 +38,15 @@ export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) { const { me } = useCurrentUser() return (
-
+
-
-
+
+
+
-
+
{me.fleetViewer && }
@@ -51,6 +55,26 @@ export function TopBar({ systemOrSilo }: { systemOrSilo: 'system' | 'silo' }) { ) } +function MobileNavToggle() { + const isOpen = useMobileNavStore((state) => state.isOpen) + const Icon = isOpen ? MenuClose12Icon : MenuOpen12Icon + return ( + + ) +} + const bigIconBox = 'flex h-[34px] w-[34px] items-center justify-center rounded-md' const BigIdenticon = ({ name }: { name: string }) => ( @@ -138,15 +162,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/layouts/helpers.tsx b/app/layouts/helpers.tsx index b59199960..2a0815d8d 100644 --- a/app/layouts/helpers.tsx +++ b/app/layouts/helpers.tsx @@ -16,14 +16,20 @@ export const PageContainer = classed.div`min-h-full pt-[calc(var(--top-bar-heigh // shared with PageSkeleton so the skeleton doesn't drift from the real layout export const topBarWrapperClass = - 'bg-default border-secondary fixed top-(--preview-banner-height) right-0 left-0 z-(--z-top-bar) grid h-(--top-bar-height) grid-cols-[var(--sidebar-width)_1fr] border-b' + 'bg-default border-secondary fixed top-(--preview-banner-height) right-0 left-0 z-(--z-top-bar) grid h-(--top-bar-height) grid-cols-[1fr] 1000:grid-cols-[var(--sidebar-width)_1fr] border-b' +// home button cell only exists at desktop width — on mobile the sidebar is an +// overlay toggled from the top bar, so the top bar is a single cell +export const topBarHomeCellClass = 'border-secondary hidden items-center border-r 1000:flex' +// below the 1000px breakpoint the sidebar becomes an overlay whose visibility +// is controlled by translate-x classes in Sidebar (and PageSkeleton, which +// always renders it closed) export const sidebarWrapperClass = - 'border-secondary fixed top-[calc(var(--top-bar-height)+var(--preview-banner-height))] bottom-0 left-0 w-(--sidebar-width) border-r' + 'bg-default border-secondary fixed top-[calc(var(--top-bar-height)+var(--preview-banner-height))] bottom-0 left-0 w-(--sidebar-width) border-r max-1000:z-(--z-side-modal) max-1000:transition-transform max-1000:duration-200 max-1000:ease-out motion-reduce:max-1000:transition-none' export function ContentPane() { useScrollRestoration() return ( -
+
{/* id/tabIndex make this the skip link target and where useRouteAnnouncer puts focus after a nav. It has to be a real element in the a11y tree @@ -47,7 +53,7 @@ export function ContentPane() { * `
` because we don't need it. */ export const SerialConsoleContentPane = () => ( -
+
diff --git a/app/stores/mobile-nav.ts b/app/stores/mobile-nav.ts new file mode 100644 index 000000000..1d04383e5 --- /dev/null +++ b/app/stores/mobile-nav.ts @@ -0,0 +1,20 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { create } from 'zustand' + +/** + * Whether the sidebar nav is open as an overlay on small screens. Above the + * mobile breakpoint the sidebar is always visible and this state is ignored. + */ +export const useMobileNavStore = create<{ isOpen: boolean }>(() => ({ isOpen: false })) + +export const closeMobileNav = () => useMobileNavStore.setState({ isOpen: false }) + +export const toggleMobileNav = () => + useMobileNavStore.setState(({ isOpen }) => ({ isOpen: !isOpen })) diff --git a/app/ui/lib/DropdownMenu.tsx b/app/ui/lib/DropdownMenu.tsx index 6e9b086f4..4b0dcba14 100644 --- a/app/ui/lib/DropdownMenu.tsx +++ b/app/ui/lib/DropdownMenu.tsx @@ -158,6 +158,8 @@ export const Item = ({ className, onSelect, label, disabled, ref }: ItemProps) = export const Submenu = Menu.SubmenuRoot export const SubmenuTrigger = Menu.SubmenuTrigger +export const Group = Menu.Group +export const GroupLabel = Menu.GroupLabel export const RadioGroup = Menu.RadioGroup export const RadioItem = Menu.RadioItem export const Separator = Menu.Separator diff --git a/app/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index 15d3a8d3f..155e49356 100644 --- a/app/ui/lib/Modal.tsx +++ b/app/ui/lib/Modal.tsx @@ -20,10 +20,12 @@ import { ModalContext } from './modal-context' type Width = 'narrow' | 'medium' | 'free' +// the 100vw terms keep modals from going edge-to-edge on small screens const widthClass: Record = { - narrow: 'w-full max-w-[24rem]', - medium: 'w-full max-w-md', - free: 'min-w-[24rem] max-w-3xl', // give it a big max just to be safe + narrow: 'w-full max-w-[min(24rem,calc(100vw-2rem))]', + medium: 'w-full max-w-[min(28rem,calc(100vw-2rem))]', + // give it a big max just to be safe + free: 'min-w-[min(24rem,calc(100vw-2rem))] max-w-[min(48rem,calc(100vw-2rem))]', } export type ModalProps = { diff --git a/app/ui/lib/SideModal.tsx b/app/ui/lib/SideModal.tsx index 575ddbd28..a25e05876 100644 --- a/app/ui/lib/SideModal.tsx +++ b/app/ui/lib/SideModal.tsx @@ -66,7 +66,7 @@ export function SideModal({ initial={{ x: animate ? 40 : 0 }} animate={{ x: 0 }} transition={{ type: 'spring', duration: 0.4, bounce: 0 }} - className="ox-side-modal bg-raise shadow-modal pointer-events-auto fixed top-0 right-0 bottom-0 z-(--z-side-modal) m-0 flex w-lg flex-col justify-between p-0" + className="ox-side-modal bg-raise shadow-modal 1000:w-lg pointer-events-auto fixed top-0 right-0 bottom-0 z-(--z-side-modal) m-0 flex w-full flex-col justify-between p-0" /> } > diff --git a/app/ui/lib/Toast.tsx b/app/ui/lib/Toast.tsx index 565b1994b..8bd702633 100644 --- a/app/ui/lib/Toast.tsx +++ b/app/ui/lib/Toast.tsx @@ -64,7 +64,7 @@ export const Toast = ({ return (
{ + await page.setViewportSize({ width: 404, height: 800 }) + await page.goto('/projects') + + await page.getByRole('button', { name: 'User menu' }).click() + + const menu = page.getByRole('menu') + await expect(menu.getByText('Hannah Arendt')).toBeVisible() + // username is not an interactive item + await expect(menu.getByRole('menuitem', { name: 'Hannah Arendt' })).toHaveCount(0) +}) + +test('mobile sidebar opens as an overlay and closes on dismiss or navigation', async ({ + page, +}) => { + await page.setViewportSize({ width: 404, height: 800 }) + await page.goto('/projects/mock-project/instances/db1/networking') + + const toggle = page.getByRole('button', { name: 'Toggle sidebar' }) + const disksLink = page + .locator('nav[aria-label="Sidebar navigation"]') + .getByRole('link', { name: 'Disks', exact: true }) + const userName = page + .getByRole('button', { name: 'User menu' }) + .getByText('Hannah Arendt') + + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + await expect(disksLink).not.toBeInViewport() + await expect(userName).toBeHidden() + + // The instance tab list is wider than a phone and should scroll within the page. + const tabList = page.getByRole('tablist') + await expect + .poll(() => + tabList.evaluate((element) => ({ + overflowX: getComputedStyle(element).overflowX, + overflows: element.scrollWidth > element.clientWidth, + })) + ) + .toEqual({ overflowX: 'auto', overflows: true }) + + await toggle.click() + await expect(toggle).toHaveAttribute('aria-expanded', 'true') + await expect(disksLink).toBeInViewport() + + // Click the scrim to the right of the sidebar. + await page.mouse.click(390, 100) + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + await expect(disksLink).not.toBeInViewport() + + await toggle.click() + await disksLink.click() + await expect(page).toHaveURL('/projects/mock-project/disks') + await expect(toggle).toHaveAttribute('aria-expanded', 'false') + await expect(disksLink).not.toBeInViewport() + + // Just below the desktop breakpoint, the drawer and hidden username persist. + await page.setViewportSize({ width: 999, height: 800 }) + await expect(toggle).toBeVisible() + await expect(disksLink).not.toBeInViewport() + await expect(userName).toBeHidden() + + // At the desktop breakpoint the persistent sidebar replaces the toggle and + // the username reappears. + await page.setViewportSize({ width: 1000, height: 800 }) + await expect(toggle).toBeHidden() + await expect(disksLink).toBeInViewport() + await expect(userName).toBeVisible() +}) + +test('mobile dialogs and toasts stay within the viewport', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 800 }) + await page.goto('/projects/mock-project/disks') + + await page.getByRole('link', { name: 'disk-1', exact: true }).click() + const sideModal = page.getByRole('dialog', { name: 'Disk details' }) + await expect(sideModal).toBeVisible() + await expect + .poll(() => + sideModal.evaluate((element) => { + const { left, right, width } = element.getBoundingClientRect() + return { left, right, width } + }) + ) + .toEqual({ left: 0, right: 320, width: 320 }) + await page.keyboard.press('Escape') + await expect(sideModal).toBeHidden() + + await clickRowAction(page, 'disk-3', 'Delete') + const confirmModal = page.getByRole('dialog', { name: 'Delete disk' }) + await expect(confirmModal).toBeVisible() + await expect + .poll(() => + confirmModal.evaluate((element) => { + const { left, right, width } = element.getBoundingClientRect() + return { left, right, width } + }) + ) + .toEqual({ left: 16, right: 304, width: 288 }) + + await confirmModal.getByRole('button', { name: 'Confirm' }).click() + await expect(page.getByTestId('Toasts').getByText('Disk disk-3 deleted')).toBeVisible() + + const toast = page.locator('.shadow-toast') + await expect + .poll(() => + toast.evaluate((element) => { + const { left, right, width } = element.getBoundingClientRect() + return { left, right, width } + }) + ) + .toEqual({ left: 16, right: 304, width: 288 }) +}) From 3b6135a08b71f3f4fa85306610d01d3a3ad76b60 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 1 Sep 2026 11:26:14 -0500 Subject: [PATCH 2/7] Collapse overflowing breadcrumbs --- app/components/TopBar.tsx | 164 ++++++++++++++++++++++++++++++++---- test/e2e/breadcrumbs.e2e.ts | 95 +++++++++++++++++++++ 2 files changed, 243 insertions(+), 16 deletions(-) diff --git a/app/components/TopBar.tsx b/app/components/TopBar.tsx index ce153b463..1af71636d 100644 --- a/app/components/TopBar.tsx +++ b/app/components/TopBar.tsx @@ -6,6 +6,7 @@ * 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' @@ -31,7 +32,6 @@ 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' }) { @@ -122,30 +122,162 @@ 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 }), diff --git a/test/e2e/breadcrumbs.e2e.ts b/test/e2e/breadcrumbs.e2e.ts index e53631d3a..32c2801d2 100644 --- a/test/e2e/breadcrumbs.e2e.ts +++ b/test/e2e/breadcrumbs.e2e.ts @@ -94,3 +94,98 @@ test('breadcrumbs', async ({ page }) => { await expect(page.getByRole('dialog', { name: 'Add IP range' })).toBeVisible() await expectCrumbs(page, poolCrumbs) }) + +test('mobile breadcrumbs keep top-bar actions visible and expose the full path', async ({ + page, +}) => { + await page.setViewportSize({ width: 404, height: 800 }) + await page.goto('/projects/mock-project/instances/db1/networking') + + const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumbs' }) + await expect(breadcrumbs.getByRole('link', { name: 'Networking' })).toBeVisible() + + // A long path must leave the persistent actions usable at narrow widths. + await expect( + page.getByRole('button', { name: 'Switch between system and silo' }) + ).toBeInViewport({ ratio: 1 }) + await expect(page.getByRole('button', { name: 'User menu' })).toBeInViewport({ ratio: 1 }) + + await page.setViewportSize({ width: 650, height: 800 }) + await expect + .poll(() => breadcrumbs.getByRole('link').allTextContents()) + .toEqual(expect.arrayContaining(['db1', 'Networking'])) + + await page.setViewportSize({ width: 750, height: 800 }) + await expect + .poll(() => breadcrumbs.getByRole('link').allTextContents()) + .toEqual(expect.arrayContaining(['Instances', 'db1', 'Networking'])) + + await page.setViewportSize({ width: 404, height: 800 }) + + await breadcrumbs.getByRole('button', { name: 'Show full breadcrumb path' }).click() + const menuItems = page.getByRole('menuitem') + await expect(menuItems).toHaveText([ + 'Projects', + 'mock-project', + 'Instances', + 'db1', + 'Networking', + ]) + + await page.getByRole('menuitem', { name: 'Instances' }).click() + await expect(page).toHaveURL('/projects/mock-project/instances') +}) + +test('long breadcrumbs collapse without displacing top-bar actions', async ({ page }) => { + await page.setViewportSize({ width: 650, height: 800 }) + await page.goto('/projects/other-project/instances/failed-cooled-restart-never/settings') + + const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumbs' }) + const visibleCrumbs = breadcrumbs.getByRole('link') + await expect(visibleCrumbs.last()).toHaveText('Settings') + for (const crumb of await visibleCrumbs.all()) { + if (await crumb.getAttribute('aria-current')) continue + await expect + .poll(() => crumb.evaluate((element) => element.scrollWidth === element.clientWidth)) + .toBe(true) + } + await expect( + page.getByRole('button', { name: 'Switch between system and silo' }) + ).toBeInViewport({ ratio: 1 }) + await expect(page.getByRole('button', { name: 'User menu' })).toBeInViewport({ ratio: 1 }) + + await breadcrumbs.getByRole('button', { name: 'Show full breadcrumb path' }).click() + const longMenuItem = page.getByRole('menuitem', { + name: 'failed-cooled-restart-never', + }) + await expect(longMenuItem).toBeVisible() + await expect + .poll(() => + longMenuItem.evaluate((element) => { + const { left, right } = element.getBoundingClientRect() + return left >= 0 && right <= window.innerWidth + }) + ) + .toBe(true) +}) + +test('desktop breadcrumbs collapse whole leading crumbs instead of shrinking each one', async ({ + page, +}) => { + await page.setViewportSize({ width: 1024, height: 800 }) + await page.goto('/projects/mock-project/vpcs/default/routers/mock-system-router') + + const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumbs' }) + await expect( + breadcrumbs.getByRole('button', { name: 'Show full breadcrumb path' }) + ).toBeVisible() + + const visibleCrumbs = breadcrumbs.getByRole('link') + await expect(visibleCrumbs.last()).toHaveText('Routes') + for (const crumb of await visibleCrumbs.all()) { + if (await crumb.getAttribute('aria-current')) continue + await expect + .poll(() => crumb.evaluate((element) => element.scrollWidth === element.clientWidth)) + .toBe(true) + } +}) From 3211b474b97bf79316739512fe638924b66f7d41 Mon Sep 17 00:00:00 2001 From: David Crespo Date: Tue, 1 Sep 2026 11:59:19 -0500 Subject: [PATCH 3/7] port Ben's responsive component tweaks from responsiveness-continued --- app/components/ErrorPage.tsx | 2 +- app/components/Terminal.tsx | 4 ++-- app/components/form/fields/DateTimeRangePicker.tsx | 4 ++-- app/components/form/fields/DisksTableField.tsx | 10 ++++++++-- app/pages/project/instances/SerialConsolePage.tsx | 2 +- app/ui/lib/CardBlock.tsx | 2 +- app/ui/lib/DropdownMenu.tsx | 3 ++- app/ui/lib/PageHeader.tsx | 2 +- app/ui/lib/Radio.tsx | 6 ++++-- app/ui/styles/components/side-modal.css | 6 ++++++ app/ui/styles/index.css | 1 + 11 files changed, 29 insertions(+), 13 deletions(-) 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/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/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 ( -
+
-
-
-
+
diff --git a/app/ui/lib/CardBlock.tsx b/app/ui/lib/CardBlock.tsx index f0d390805..269debed9 100644 --- a/app/ui/lib/CardBlock.tsx +++ b/app/ui/lib/CardBlock.tsx @@ -53,7 +53,7 @@ CardBlock.Header = ({ title, description, children, titleId }: HeaderProps) => ( {description &&
{description}
}
-
{children}
+
{children}
) diff --git a/app/ui/lib/DropdownMenu.tsx b/app/ui/lib/DropdownMenu.tsx index 4b0dcba14..066d43c6f 100644 --- a/app/ui/lib/DropdownMenu.tsx +++ b/app/ui/lib/DropdownMenu.tsx @@ -72,7 +72,8 @@ export function Content({ anchor = 'bottom end', gap, zIndex = 'dropdown', - collisionPadding, + // keep menus off the viewport edge, mainly for small screens + collisionPadding = 12, }: ContentProps) { const { side, align, sideOffset, alignOffset } = parseAnchor(anchor, gap) return ( diff --git a/app/ui/lib/PageHeader.tsx b/app/ui/lib/PageHeader.tsx index d7bc44267..89b9ff7b5 100644 --- a/app/ui/lib/PageHeader.tsx +++ b/app/ui/lib/PageHeader.tsx @@ -9,7 +9,7 @@ import type { ReactElement } from 'react' import { classed } from '~/util/classed' -export const PageHeader = classed.header`mb-16 mt-12 flex items-center justify-between` +export const PageHeader = classed.header`mb-16 mt-12 flex items-center justify-between max-1000:mt-8` interface PageTitleProps { icon?: ReactElement diff --git a/app/ui/lib/Radio.tsx b/app/ui/lib/Radio.tsx index 7d5fdf843..09eb3b2be 100644 --- a/app/ui/lib/Radio.tsx +++ b/app/ui/lib/Radio.tsx @@ -66,9 +66,11 @@ export function RadioCard({ children, className, ...inputProps }: RadioProps) { // HACK: This forces the focus states for storybook stories const focus = className?.includes(':focus') ? ':focus' : '' return ( -