diff --git a/app/components/CopyCode.tsx b/app/components/CopyCode.tsx index 3f0da9348..4d97decba 100644 --- a/app/components/CopyCode.tsx +++ b/app/components/CopyCode.tsx @@ -98,7 +98,12 @@ export function EquivalentCliCommand({ project, instance }: EquivProps) { return ( <> - -
+
diff --git a/app/components/Pagination.tsx b/app/components/Pagination.tsx index f6b795bcc..dbaf87682 100644 --- a/app/components/Pagination.tsx +++ b/app/components/Pagination.tsx @@ -17,7 +17,7 @@ const Tunnel = tunnel() export function Pagination(props: UIPaginationProps) { return ( - + ) } diff --git a/app/components/RouteTabs.tsx b/app/components/RouteTabs.tsx index 630b59103..338bbeed4 100644 --- a/app/components/RouteTabs.tsx +++ b/app/components/RouteTabs.tsx @@ -38,6 +38,22 @@ const selectTab = (e: React.KeyboardEvent) => { } } +function RouteTabList({ + className, + children, +}: { + className?: string + children: ReactNode +}) { + return ( + // Keyboard nav is handled on the tablist; individual tabs take focus. + // oxlint-disable-next-line jsx-a11y/interactive-supports-focus +
+ {children} +
+ ) +} + export interface RouteTabsProps { children: ReactNode fullWidth?: boolean @@ -54,23 +70,24 @@ export function RouteTabs({ tabListClassName, }: RouteTabsProps) { /* TODO: Add aria-describedby for active tab */ + const tabList = ( + + {children} + + ) + return (
- {/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */} -
- {children} -
+ {sideTabs ? tabList :
{tabList}
}
diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index f01381bfd..4d9af13f6 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -481,7 +481,7 @@ const MetricsMessage = ({
+
diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx index dc9773603..a73223eb9 100644 --- a/app/components/form/fields/DisksTableField.tsx +++ b/app/components/form/fields/DisksTableField.tsx @@ -75,25 +75,23 @@ export function DisksTableField({ emptyState={{ title: 'No disks', body: 'Add a disk to see it here' }} /> -
- - -
+ +
{showDiskCreate && ( diff --git a/app/components/oxql-metrics/OxqlMetric.tsx b/app/components/oxql-metrics/OxqlMetric.tsx index 7a28b68ae..0c1c58043 100644 --- a/app/components/oxql-metrics/OxqlMetric.tsx +++ b/app/components/oxql-metrics/OxqlMetric.tsx @@ -124,7 +124,13 @@ export function OxqlMetric({ title, description, unit, ...queryObj }: OxqlMetric export const MetricHeader = ({ children }: { children: ReactNode }) => { // If header has only one child, align it to the end of the container const justify = Children.count(children) === 1 ? 'justify-end' : 'justify-between' - return
{children}
+ return ( +
*]:w-full flex flex-wrap gap-2 ${justify}`} + > + {children} +
+ ) } export const MetricCollection = classed.div`mt-3 flex flex-col gap-4` diff --git a/app/pages/SiloUtilizationPage.tsx b/app/pages/SiloUtilizationPage.tsx index a653a596b..4c0bfa894 100644 --- a/app/pages/SiloUtilizationPage.tsx +++ b/app/pages/SiloUtilizationPage.tsx @@ -96,13 +96,13 @@ export default function SiloUtilizationPage() { -
-
+
+
{intervalPicker}
-
{dateTimeRangePicker}
+
{dateTimeRangePicker}
diff --git a/app/pages/project/instances/CpuMetricsTab.tsx b/app/pages/project/instances/CpuMetricsTab.tsx index 62ca844da..22d085ef1 100644 --- a/app/pages/project/instances/CpuMetricsTab.tsx +++ b/app/pages/project/instances/CpuMetricsTab.tsx @@ -62,9 +62,9 @@ export default function CpuMetricsTab() { return ( <> -
+
-
+
in RouteTabs return ( - + CPU Disk Network diff --git a/app/pages/project/instances/NetworkMetricsTab.tsx b/app/pages/project/instances/NetworkMetricsTab.tsx index a6f60cb1c..7aad72294 100644 --- a/app/pages/project/instances/NetworkMetricsTab.tsx +++ b/app/pages/project/instances/NetworkMetricsTab.tsx @@ -100,9 +100,9 @@ function NetworkMetrics({ nics }: { nics: InstanceNetworkInterface[] }) { return ( <> -
+
-
- - -
+ +
diff --git a/app/pages/system/UtilizationPage.tsx b/app/pages/system/UtilizationPage.tsx index 95b5d60d1..90eaa1694 100644 --- a/app/pages/system/UtilizationPage.tsx +++ b/app/pages/system/UtilizationPage.tsx @@ -128,13 +128,13 @@ const MetricsTab = () => { return ( <> -
-
+
+
{intervalPicker} { onChange={setFilterId} />
-
{dateTimeRangePicker}
+
{dateTimeRangePicker}
( -
+
{title} @@ -53,7 +53,9 @@ CardBlock.Header = ({ title, description, children, titleId }: HeaderProps) => ( {description &&
{description}
}
-
{children}
+
+ {children} +
) diff --git a/app/ui/lib/DateRangePicker.tsx b/app/ui/lib/DateRangePicker.tsx index d7a328451..eeb99bc2c 100644 --- a/app/ui/lib/DateRangePicker.tsx +++ b/app/ui/lib/DateRangePicker.tsx @@ -69,7 +69,7 @@ export function DateRangePicker(props: DateRangePickerProps) { : 'border-default ring-accent-secondary' )} > -
+
{label}
{state.isInvalid && (
@@ -77,8 +77,8 @@ export function DateRangePicker(props: DateRangePickerProps) {
)}
-
- +
+
diff --git a/app/ui/lib/PageHeader.tsx b/app/ui/lib/PageHeader.tsx index 89b9ff7b5..6b82bd966 100644 --- a/app/ui/lib/PageHeader.tsx +++ b/app/ui/lib/PageHeader.tsx @@ -9,17 +9,22 @@ import type { ReactElement } from 'react' import { classed } from '~/util/classed' -export const PageHeader = classed.header`mb-16 mt-12 flex items-center justify-between max-1000:mt-8` +import { Truncate } from './Truncate' + +// Title is allowed to shrink (`min-w-0`); actions keep their intrinsic width so +// long names ellipsize instead of shoving buttons off-screen. Below 500px the +// actions stack above the title and each row can use the full width. +export const PageHeader = classed.header`mb-16 mt-12 flex w-full min-w-0 justify-between gap-4 max-500:flex-col max-500:*:last:order-0 max-500:*:first:order-1 500:items-center 500:[&>h1]:flex-1 500:[&>:not(h1)]:shrink-0 max-1000:mt-8` interface PageTitleProps { icon?: ReactElement - children: React.ReactNode + children: string } export const PageTitle = ({ children: title, icon }: PageTitleProps) => { return ( -

+

{icon} - {title} +

) } diff --git a/app/ui/lib/PropertiesTable.tsx b/app/ui/lib/PropertiesTable.tsx index b691b4d65..47f2a4a59 100644 --- a/app/ui/lib/PropertiesTable.tsx +++ b/app/ui/lib/PropertiesTable.tsx @@ -46,13 +46,15 @@ export function PropertiesTable({ aria-label="Properties table" className={cn( className, - 'properties-table bg-default border-default min-w-min basis-6/12 rounded-lg border', - '*:border-secondary *:border-t *:pr-6 *:pl-3 [&>*:nth-child(-n+2)]:border-t-0!', - 'grid grid-cols-[minmax(min-content,1fr)_3fr]', - { - '1000:grid-cols-[minmax(min-content,1fr)_3fr_minmax(min-content,1fr)_3fr] 1000:[&>*:nth-child(-n+4)]:border-t-0! 1000:[&>*:nth-child(4n-2)]:border-r': - columns === 2, - } + 'properties-table bg-default border-default w-full min-w-0 basis-6/12 rounded-lg border 1000:min-w-min', + '*:border-secondary *:border-t *:pr-6 *:pl-3 [&>*:nth-child(1)]:border-t-0!', + // stack label above value below the layout breakpoint so long IDs don't overflow + 'grid grid-cols-1', + 'max-1000:[&>*:nth-child(odd)]:pt-2 max-1000:[&>*:nth-child(odd)]:pb-1', + 'max-1000:[&>*:nth-child(even)]:border-t-0 max-1000:[&>*:nth-child(even)]:pb-2', + columns === 2 + ? '1000:grid-cols-[minmax(min-content,1fr)_3fr_minmax(min-content,1fr)_3fr] 1000:[&>*:nth-child(-n+4)]:border-t-0! 1000:[&>*:nth-child(4n-2)]:border-r' + : '1000:grid-cols-[minmax(min-content,1fr)_3fr] 1000:[&>*:nth-child(-n+2)]:border-t-0!' )} > {children} @@ -69,7 +71,7 @@ PropertiesTable.Row = ({ label, children }: PropertiesTableRowProps) => ( {label} -
+
{children}
diff --git a/app/ui/lib/Tabs.tsx b/app/ui/lib/Tabs.tsx index 12c48d9d0..d5fda191d 100644 --- a/app/ui/lib/Tabs.tsx +++ b/app/ui/lib/Tabs.tsx @@ -23,7 +23,9 @@ export const Tabs = { ), List: ({ className, ...props }: BaseTabs.List.Props) => ( - +
+ +
), Content: ({ className, ...props }: BaseTabs.Panel.Props) => ( diff --git a/app/ui/styles/components/Tabs.css b/app/ui/styles/components/Tabs.css index bd86395ca..924efcb9e 100644 --- a/app/ui/styles/components/Tabs.css +++ b/app/ui/styles/components/Tabs.css @@ -6,9 +6,18 @@ * Copyright Oxide Computer Company */ +/* Sticky lives on a wrapper so the list can overflow-x without becoming + its own scroll container (which would prevent sticking). */ +.ox-tabs-list-wrap { + position: sticky; + top: calc(var(--top-bar-height) + var(--preview-banner-height)); + z-index: var(--z-sticky); + @apply bg-default light:bg-raise; +} + /* Tab list container styles */ .ox-tabs-list { - @apply mb-8 flex bg-transparent; + @apply flex; } /* let long tab lists scroll horizontally on small screens instead of @@ -21,11 +30,31 @@ } } +.ox-tabs > .ox-tabs-panel { + @apply mt-8; +} + +/* forms can have multiple tab lists on one page; keep those in flow */ +.ox-form .ox-tabs-list-wrap { + position: static; + top: auto; + z-index: auto; + background-color: transparent; +} + .ox-tabs-list:after { - @apply border-secondary block w-full border-b; + @apply border-secondary block w-full flex-grow border-b; content: ''; } +@media (max-width: 999px) { + .ox-tabs-list:after { + width: var(--content-gutter); + min-width: max-content; + flex-shrink: 0; + } +} + /* Panel styles */ .ox-tabs-panel:focus-visible { @apply outline-accent-secondary outline-2 outline-offset-[1rem]; @@ -77,15 +106,30 @@ } .ox-tabs.full-width .ox-tabs-list:before { - @apply border-secondary block w-10 min-w-max flex-shrink-0 border-b; + @apply border-secondary block min-w-max flex-shrink-0 border-b; + width: var(--content-gutter); content: ''; } /* Side tabs styles */ +.ox-side-tabs { + @apply max-1000:flex-col flex; +} + .ox-side-tabs-list { @apply sticky top-10 flex w-[180px] flex-shrink-0 flex-col gap-0.5 self-start; } +@media (max-width: 999px) { + .ox-side-tabs-list { + position: static; + top: auto; + width: 100%; + flex-direction: row; + flex-wrap: wrap; + } +} + .ox-side-tabs-list .ox-tab { @apply text-sans-md text-secondary h-auto rounded-md p-2 py-1.5 whitespace-nowrap normal-case !no-underline; border-bottom: none; diff --git a/app/ui/styles/index.css b/app/ui/styles/index.css index d8d364fd7..d8b60ecd1 100644 --- a/app/ui/styles/index.css +++ b/app/ui/styles/index.css @@ -165,11 +165,11 @@ body, #root { height: 100%; + overscroll-behavior-y: none; /* open for discussion but makes it feel more app-like */ } body { @apply text-default bg-default font-sans; - overscroll-behavior-y: none; } /* https://github.com/tailwindlabs/tailwindcss/blob/v2.2.4/src/plugins/css/preflight.css#L57 */ diff --git a/app/ui/styles/vars.css b/app/ui/styles/vars.css index 43426a150..07797d82d 100644 --- a/app/ui/styles/vars.css +++ b/app/ui/styles/vars.css @@ -16,6 +16,7 @@ --z-side-modal-overlay: 25; --z-top-bar-dropdown: 20; --z-top-bar: 15; + --z-sticky: 12; --z-popover: 10; --z-content-dropdown: 10; --z-content: 0; diff --git a/test/e2e/mobile-layout.e2e.ts b/test/e2e/mobile-layout.e2e.ts index e67b69cd0..af0ef5d0d 100644 --- a/test/e2e/mobile-layout.e2e.ts +++ b/test/e2e/mobile-layout.e2e.ts @@ -121,3 +121,96 @@ test('mobile dialogs and toasts stay within the viewport', async ({ page }) => { ) .toEqual({ left: 16, right: 304, width: 288 }) }) + +test('properties table stacks label above value on small screens', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1') + const props = page.getByLabel('Properties table') + const label = props.getByText('cpu', { exact: true }) + const value = props.getByText('2 vCPUs') + const columnCount = () => + props.evaluate( + (element) => getComputedStyle(element).gridTemplateColumns.split(' ').length + ) + + await page.setViewportSize({ width: 404, height: 800 }) + await expect.poll(columnCount).toBe(1) + const stacked = await Promise.all([label.boundingBox(), value.boundingBox()]) + expect(stacked[0]).toBeTruthy() + expect(stacked[1]).toBeTruthy() + expect(stacked[0]!.y).toBeLessThan(stacked[1]!.y) + + await page.setViewportSize({ width: 1000, height: 800 }) + await expect.poll(columnCount).toBe(4) + const sideBySide = await Promise.all([label.boundingBox(), value.boundingBox()]) + expect(sideBySide[0]).toBeTruthy() + expect(sideBySide[1]).toBeTruthy() + expect(sideBySide[0]!.x).toBeLessThan(sideBySide[1]!.x) +}) + +test('date range picker is icon-only on small screens', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/metrics/cpu') + const picker = page.getByLabel('Choose a date range', { exact: true }) + const button = picker.getByRole('button') + + await page.setViewportSize({ width: 404, height: 800 }) + await expect + .poll(() => button.evaluate((element) => element.getBoundingClientRect().width)) + .toBeLessThan(50) + + await page.setViewportSize({ width: 1000, height: 800 }) + await expect + .poll(() => button.evaluate((element) => element.getBoundingClientRect().width)) + .toBeGreaterThan(200) +}) + +test('metrics filters go full width on small screens', async ({ page }) => { + await page.goto('/system/utilization?tab=metrics') + const listbox = page.getByRole('button', { name: 'Filter by silo' }) + + await page.setViewportSize({ width: 404, height: 800 }) + await expect + .poll(() => listbox.evaluate((element) => element.getBoundingClientRect().width)) + .toBeGreaterThan(300) + + await page.setViewportSize({ width: 1000, height: 800 }) + await expect + .poll(() => + listbox.evaluate((element) => Math.round(element.getBoundingClientRect().width)) + ) + .toBe(208) +}) + +test('tab list sticks under the top bar', async ({ page }) => { + // Short viewport so the page can scroll far enough for the tabs to hit the + // stick point (stacked properties above the tabs eat most of an 800px frame). + await page.setViewportSize({ width: 404, height: 480 }) + await page.goto('/projects/mock-project/instances/db1/storage') + + const tabList = page.getByRole('tablist') + const wrap = page.locator('.ox-tabs-list-wrap') + await expect + .poll(() => wrap.evaluate((element) => getComputedStyle(element).position)) + .toBe('sticky') + + await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight)) + await expect + .poll(async () => { + const box = await tabList.boundingBox() + const top = await wrap.evaluate((element) => + parseFloat(getComputedStyle(element).top) + ) + return box ? Math.abs(box.y - top) < 2 : false + }) + .toBe(true) +}) + +test('CLI command is hidden on small screens', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/connect') + const cli = page.getByRole('button', { name: 'CLI Command' }) + + await page.setViewportSize({ width: 404, height: 800 }) + await expect(cli).toBeHidden() + + await page.setViewportSize({ width: 1000, height: 800 }) + await expect(cli).toBeVisible() +})