From afea36d6ce12d99741903cf2a554901990b90b03 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 14 Aug 2026 11:48:27 +0100 Subject: [PATCH 1/3] Width-aware truncation --- app/components/ImageDetailSideModal.tsx | 2 +- app/components/IpPoolDetailSideModal.tsx | 2 +- app/components/Sidebar.tsx | 2 +- app/components/SnapshotDetailSideModal.tsx | 2 +- .../form/fields/DisksTableField.tsx | 2 +- .../project/disks/DiskDetailSideModal.tsx | 2 +- .../project/vpcs/internet-gateway-edit.tsx | 10 +- app/pages/settings/AccessTokensPage.tsx | 10 +- app/pages/system/inventory/DisksTab.tsx | 12 +- app/pages/system/inventory/SledsTab.tsx | 9 +- app/pages/system/inventory/sled/SledPage.tsx | 8 +- app/pages/system/silos/SiloScimTab.tsx | 4 +- app/table/cells/DescriptionCell.tsx | 10 +- app/table/columns/common.tsx | 10 +- app/ui/lib/FileInput.tsx | 12 +- app/ui/lib/PropertiesTable.tsx | 12 +- app/ui/lib/Toast.tsx | 2 +- app/ui/lib/Truncate.tsx | 146 ++++++++++++++++-- test/e2e/access-tokens.e2e.ts | 10 +- test/e2e/inventory.e2e.ts | 16 +- test/e2e/scim-tokens.e2e.ts | 16 +- 21 files changed, 208 insertions(+), 91 deletions(-) diff --git a/app/components/ImageDetailSideModal.tsx b/app/components/ImageDetailSideModal.tsx index 0629e28104..99968c49da 100644 --- a/app/components/ImageDetailSideModal.tsx +++ b/app/components/ImageDetailSideModal.tsx @@ -41,7 +41,7 @@ export function ImageDetailSideModal({ > - + {visibility} {image.os} {image.version} diff --git a/app/components/IpPoolDetailSideModal.tsx b/app/components/IpPoolDetailSideModal.tsx index 3b97a78282..05020812a3 100644 --- a/app/components/IpPoolDetailSideModal.tsx +++ b/app/components/IpPoolDetailSideModal.tsx @@ -35,7 +35,7 @@ export function IpPoolDetailSideModal({ pool, onDismiss }: IpPoolDetailSideModal > - + diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 16d1b92c50..769eb7eb09 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -86,7 +86,7 @@ Sidebar.Nav = ({ children, heading }: SidebarNav) => (
{heading && (
- +
)}
-
+
{file && !dragOver ? ( -
- - ({formatBytes(file.size).label}) +
+ + + ({formatBytes(file.size).label}) +
diff --git a/app/ui/lib/Truncate.tsx b/app/ui/lib/Truncate.tsx index 27f4bc9943..6ce5b236a4 100644 --- a/app/ui/lib/Truncate.tsx +++ b/app/ui/lib/Truncate.tsx @@ -6,6 +6,9 @@ * Copyright Oxide Computer Company */ +import cn from 'classnames' +import { useLayoutEffect, useRef, useState } from 'react' + import { CopyToClipboard } from './CopyToClipboard' import { Tooltip } from './Tooltip' @@ -13,36 +16,99 @@ type TruncatePosition = 'middle' | 'end' interface TruncateProps { text: string - maxLength: number position?: TruncatePosition hasCopyButton?: boolean tooltipDelay?: number + /** + * Tailwind max-width class capping how wide the text can grow. Without a + * cap, the text truncates to fit its container — which is what you want in + * width-constrained contexts like side modals and toasts. But in auto-layout + * tables the column sizes itself to the text, so table cells need a cap for + * truncation to ever kick in. + */ + maxWidth?: `max-w-${string}` } export const Truncate = ({ text, - maxLength, position = 'end', hasCopyButton, tooltipDelay = 300, + maxWidth, }: TruncateProps) => { - // Only use the tooltip if the text is longer than maxLength - // "truncate" class used for CSS truncation when cell rendered narrowly - const content = - text.length <= maxLength ? ( -
{text}
+ const ref = useRef(null) + // for middle truncation, the ellipsized string; null means the full text fits + const [middleText, setMiddleText] = useState(null) + const [truncated, setTruncated] = useState(false) + + // Middle truncation has to be computed up front in order to render at all, + // and recomputed whenever the container resizes + useLayoutEffect(() => { + const el = ref.current + if (position !== 'middle' || !el) return + + const update = () => { + const fitted = truncateToFit(text, el) + setMiddleText(fitted === text ? null : fitted) + setTruncated(fitted !== text) + } + + update() + const observer = new ResizeObserver(update) + observer.observe(el) + return () => observer.disconnect() + }, [text, position]) + + // For end truncation, CSS does the actual truncating and the only decision + // JS makes is whether to show the tooltip — which only matters at hover + // time. Checking lazily here avoids a per-instance ResizeObserver and can't + // go stale the way an observer-updated value can between resize and hover. + const checkEndTruncation = + position === 'end' + ? () => { + const el = ref.current + if (el) setTruncated(el.scrollWidth > el.clientWidth) + } + : undefined + + const inner = + position === 'end' ? ( +
+ {text} +
) : ( - -
- {truncate(text, maxLength, position)} -
-
+
+ {/* invisible copy of the full text keeps the layout width stable, so + swapping in the shorter ellipsized text can't shrink the container + and trigger another round of truncation */} + + {text} + + {middleText && ( + + {middleText} + + )} +
) return ( // overflow-hidden required to make inner truncate work -
- {content} +
+ {/* Tooltip stays mounted with content gated on `truncated` so its hover + tracking is already running when the lazy check flips it on. With no + content it renders just the child. */} + + {inner} + {hasCopyButton && (
@@ -52,6 +118,58 @@ export const Truncate = ({ ) } +let canvasCtx: CanvasRenderingContext2D | null = null + +/** null in environments without canvas support, like jsdom */ +function getCanvasCtx(): CanvasRenderingContext2D | null { + if (!canvasCtx) canvasCtx = document.createElement('canvas').getContext('2d') + return canvasCtx +} + +/** + * Middle-truncate `text` to fit the rendered width of `el`, measuring + * candidate strings with canvas `measureText`, which accounts for font + * shaping, kerning, and letter-spacing. + */ +function truncateToFit(text: string, el: HTMLElement): string { + const ctx = getCanvasCtx() + // if we can't measure (jsdom) or the element isn't laid out yet, leave it alone + if (!ctx || el.clientWidth === 0) return text + + const style = getComputedStyle(el) + // build the font shorthand from parts; `style.font` is empty in Firefox + ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}` + ctx.letterSpacing = style.letterSpacing === 'normal' ? '0px' : style.letterSpacing + + const width = el.clientWidth + if (ctx.measureText(text).width <= width) return text + + const fits = (keep: number) => ctx.measureText(middleEllipsis(text, keep)).width <= width + + // binary search for the largest number of kept characters that fits + let lo = 0 + let hi = text.length - 1 + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2) + if (fits(mid)) { + lo = mid + } else { + hi = mid - 1 + } + } + return middleEllipsis(text, lo) +} + +function middleEllipsis(text: string, keep: number) { + return ( + text.slice(0, Math.ceil(keep / 2)) + + '…' + + text.slice(text.length - Math.floor(keep / 2)) + ) +} + +/** Truncate `text` to `maxLength` characters. For truncation that adapts to + * the rendered width instead, use the `Truncate` component. */ export function truncate( text: string, maxLength: number, diff --git a/test/e2e/access-tokens.e2e.ts b/test/e2e/access-tokens.e2e.ts index 2f3f80b927..f24b969940 100644 --- a/test/e2e/access-tokens.e2e.ts +++ b/test/e2e/access-tokens.e2e.ts @@ -25,17 +25,17 @@ test('Access tokens', async ({ page }) => { const table = page.getByRole('table') await expectRowVisible(table, { - ID: token1, + ID: expect.stringContaining(token1), created: expect.stringContaining('May 27, 2025'), Expires: expect.stringContaining('Jul 3, 2025'), }) await expectRowVisible(table, { - ID: token2, + ID: expect.stringContaining(token2), created: expect.stringContaining('May 20, 2025'), Expires: expect.stringContaining('Aug 2, 2025'), }) await expectRowVisible(table, { - ID: token3, + ID: expect.stringContaining(token3), created: expect.stringContaining('May 31, 2025'), Expires: 'Never', }) @@ -49,6 +49,6 @@ test('Access tokens', async ({ page }) => { await expect(page.getByRole('cell', { name: token1 })).toBeHidden() // Other two tokens should still be there - await expectRowVisible(table, { ID: token2 }) - await expectRowVisible(table, { ID: token3 }) + await expectRowVisible(table, { ID: expect.stringContaining(token2) }) + await expectRowVisible(table, { ID: expect.stringContaining(token3) }) }) diff --git a/test/e2e/inventory.e2e.ts b/test/e2e/inventory.e2e.ts index b77f9e65d3..bc0d11d8d7 100644 --- a/test/e2e/inventory.e2e.ts +++ b/test/e2e/inventory.e2e.ts @@ -23,28 +23,28 @@ test('Sled inventory page', async ({ page }) => { // expectRowVisible currently only looks at the last header row in case of // grouping, hence the slightly weird column names await expectRowVisible(sledsTable, { - id: sleds[0].id, + id: expect.stringContaining(sleds[0].id), 'serial number': sleds[0].baseboard.serial, Kind: 'In service', 'Provision policy': 'Provisionable', state: 'active', }) await expectRowVisible(sledsTable, { - id: sleds[1].id, + id: expect.stringContaining(sleds[1].id), 'serial number': sleds[1].baseboard.serial, Kind: 'In service', 'Provision policy': 'Not provisionable', state: 'active', }) await expectRowVisible(sledsTable, { - id: sleds[2].id, + id: expect.stringContaining(sleds[2].id), 'serial number': sleds[2].baseboard.serial, Kind: 'Expunged', 'Provision policy': '—', state: 'active', }) await expectRowVisible(sledsTable, { - id: sleds[3].id, + id: expect.stringContaining(sleds[3].id), 'serial number': sleds[3].baseboard.serial, Kind: 'Expunged', 'Provision policy': '—', @@ -77,21 +77,21 @@ test('Disk inventory page', async ({ page }) => { await expect(disksTab).toHaveClass(/is-selected/) const table = page.getByRole('table') - await expectRowVisible(table, { id: physicalDisks[0].id, 'Form factor': 'U.2' }) + await expectRowVisible(table, { id: expect.stringContaining(physicalDisks[0].id), 'Form factor': 'U.2' }) await expectRowVisible(table, { - id: physicalDisks[3].id, + id: expect.stringContaining(physicalDisks[3].id), 'Form factor': 'M.2', policy: 'in service', state: 'active', }) await expectRowVisible(table, { - id: physicalDisks[4].id, + id: expect.stringContaining(physicalDisks[4].id), 'Form factor': 'M.2', policy: 'expunged', state: 'active', }) await expectRowVisible(table, { - id: physicalDisks[5].id, + id: expect.stringContaining(physicalDisks[5].id), 'Form factor': 'M.2', policy: 'expunged', state: 'decommissioned', diff --git a/test/e2e/scim-tokens.e2e.ts b/test/e2e/scim-tokens.e2e.ts index 9948ab3351..cf6abfe1c9 100644 --- a/test/e2e/scim-tokens.e2e.ts +++ b/test/e2e/scim-tokens.e2e.ts @@ -15,8 +15,8 @@ import { test, } from './utils' -const tokenId1 = 'a1b2c3d4…34567890' -const tokenId2 = 'b2c3d4e5…45678901' +const tokenId1 = 'a1b2c3d4-e5f6-4890-abcd-ef1234567890' +const tokenId2 = 'b2c3d4e5-f6a7-4901-bcde-f12345678901' test('SCIM tokens tab', async ({ page }) => { await page.goto('/system/silos/maze-war/scim') @@ -26,8 +26,8 @@ test('SCIM tokens tab', async ({ page }) => { const table = page.getByRole('table', { name: 'SCIM Tokens' }) // Check that existing tokens are visible - await expectRowVisible(table, { ID: tokenId1 }) - await expectRowVisible(table, { ID: tokenId2 }) + await expectRowVisible(table, { ID: expect.stringContaining(tokenId1) }) + await expectRowVisible(table, { ID: expect.stringContaining(tokenId2) }) }) test('SCIM tokens tab empty state', async ({ page }) => { @@ -104,10 +104,10 @@ test('Delete SCIM token', async ({ page }) => { await expect(table.getByRole('row')).toHaveCount(2) // header + 1 token // The deleted token should not be visible - await expectNotVisible(page, [page.getByText('a1b2c3d4…34567890')]) + await expectNotVisible(page, [page.getByText(tokenId1)]) // The other token should still be visible - await expectRowVisible(table, { ID: 'b2c3d4e5…45678901' }) + await expectRowVisible(table, { ID: expect.stringContaining(tokenId2) }) // Delete the second token await clickRowAction(page, 'b2c3d4e5', 'Delete') @@ -122,7 +122,7 @@ test('Delete SCIM token', async ({ page }) => { test('Only fleet or silo admin can view SCIM tokens', async ({ page, browser }) => { await page.goto('/system/silos/maze-war/scim') - await expect(page.getByText(tokenId1)).toBeVisible() + await expect(page.getByLabel(tokenId1)).toBeVisible() // Jane Austen is a fleet viewer but not a silo admin on maze-war const page2 = await getPageAsUser(browser, 'Jane Austen') @@ -134,5 +134,5 @@ test('Only fleet or silo admin can view SCIM tokens', async ({ page, browser }) await expect(page2.getByRole('button', { name: 'Create token' })).toBeHidden() // Tokens should not be visible - await expect(page2.getByText(tokenId1)).toBeHidden() + await expect(page2.getByLabel(tokenId1)).toBeHidden() }) From 59b51478ac98ce9efba5d4508b39a2f4b7985f52 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 14 Aug 2026 11:54:25 +0100 Subject: [PATCH 2/3] Swap `maxWidth` for `className` --- app/components/form/fields/DisksTableField.tsx | 2 +- app/pages/settings/AccessTokensPage.tsx | 2 +- app/pages/system/inventory/DisksTab.tsx | 2 +- app/pages/system/inventory/SledsTab.tsx | 2 +- app/pages/system/silos/SiloScimTab.tsx | 2 +- app/table/cells/DescriptionCell.tsx | 2 +- app/table/columns/common.tsx | 2 +- app/ui/lib/Truncate.tsx | 15 +++++++-------- 8 files changed, 14 insertions(+), 15 deletions(-) diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx index 991d5ac647..43224bc0ee 100644 --- a/app/components/form/fields/DisksTableField.tsx +++ b/app/components/form/fields/DisksTableField.tsx @@ -26,7 +26,7 @@ export type DiskTableItem = const diskTableColumns = [ { header: 'Name', - cell: (item: DiskTableItem) => , + cell: (item: DiskTableItem) => , }, { header: 'Action', diff --git a/app/pages/settings/AccessTokensPage.tsx b/app/pages/settings/AccessTokensPage.tsx index 5e8e488960..fd2f0c32cd 100644 --- a/app/pages/settings/AccessTokensPage.tsx +++ b/app/pages/settings/AccessTokensPage.tsx @@ -83,7 +83,7 @@ export default function AccessTokensPage() { ), diff --git a/app/pages/system/inventory/DisksTab.tsx b/app/pages/system/inventory/DisksTab.tsx index 30b10c59e6..22f60e5f25 100644 --- a/app/pages/system/inventory/DisksTab.tsx +++ b/app/pages/system/inventory/DisksTab.tsx @@ -56,7 +56,7 @@ const staticCols = [ ), diff --git a/app/pages/system/inventory/SledsTab.tsx b/app/pages/system/inventory/SledsTab.tsx index a03c6de1c0..6c2df85610 100644 --- a/app/pages/system/inventory/SledsTab.tsx +++ b/app/pages/system/inventory/SledsTab.tsx @@ -32,7 +32,7 @@ const staticCols = [ colHelper.accessor('id', { cell: (info) => ( - + ), }), diff --git a/app/pages/system/silos/SiloScimTab.tsx b/app/pages/system/silos/SiloScimTab.tsx index c3e4d22e2c..b601bbc8ea 100644 --- a/app/pages/system/silos/SiloScimTab.tsx +++ b/app/pages/system/silos/SiloScimTab.tsx @@ -62,7 +62,7 @@ const staticColumns = [ colHelper.accessor('id', { header: 'ID', cell: (info) => ( - + ), }), colHelper.accessor('timeCreated', Columns.timeCreated), diff --git a/app/table/cells/DescriptionCell.tsx b/app/table/cells/DescriptionCell.tsx index 191c019cc6..44f14220cb 100644 --- a/app/table/cells/DescriptionCell.tsx +++ b/app/table/cells/DescriptionCell.tsx @@ -10,4 +10,4 @@ import { EmptyCell } from '~/table/cells/EmptyCell' import { Truncate } from '~/ui/lib/Truncate' export const DescriptionCell = ({ text }: { text?: string }) => - text ? : + text ? : diff --git a/app/table/columns/common.tsx b/app/table/columns/common.tsx index 856556ee8c..9e6a0fa833 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -23,7 +23,7 @@ function dateCell(info: Info) { function idCell(info: Info) { return ( - + ) } diff --git a/app/ui/lib/Truncate.tsx b/app/ui/lib/Truncate.tsx index 6ce5b236a4..596466974b 100644 --- a/app/ui/lib/Truncate.tsx +++ b/app/ui/lib/Truncate.tsx @@ -20,13 +20,12 @@ interface TruncateProps { hasCopyButton?: boolean tooltipDelay?: number /** - * Tailwind max-width class capping how wide the text can grow. Without a - * cap, the text truncates to fit its container — which is what you want in - * width-constrained contexts like side modals and toasts. But in auto-layout - * tables the column sizes itself to the text, so table cells need a cap for - * truncation to ever kick in. + * Extra classes for the wrapper, most commonly a `max-w-*` cap on how wide + * the text can grow. Constrained containers (side modals, toasts) don't need + * one, but in auto-layout tables the column sizes itself to the text, so + * table cells need a cap for truncation to ever kick in. */ - maxWidth?: `max-w-${string}` + className?: string } export const Truncate = ({ @@ -34,7 +33,7 @@ export const Truncate = ({ position = 'end', hasCopyButton, tooltipDelay = 300, - maxWidth, + className, }: TruncateProps) => { const ref = useRef(null) // for middle truncation, the ellipsized string; null means the full text fits @@ -99,7 +98,7 @@ export const Truncate = ({ return ( // overflow-hidden required to make inner truncate work
From 85a11dcabf2f6aceeb9614213ca0456b059d1676 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Fri, 14 Aug 2026 11:56:16 +0100 Subject: [PATCH 3/3] Fmt --- test/e2e/inventory.e2e.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/e2e/inventory.e2e.ts b/test/e2e/inventory.e2e.ts index bc0d11d8d7..3a77530625 100644 --- a/test/e2e/inventory.e2e.ts +++ b/test/e2e/inventory.e2e.ts @@ -77,7 +77,10 @@ test('Disk inventory page', async ({ page }) => { await expect(disksTab).toHaveClass(/is-selected/) const table = page.getByRole('table') - await expectRowVisible(table, { id: expect.stringContaining(physicalDisks[0].id), 'Form factor': 'U.2' }) + await expectRowVisible(table, { + id: expect.stringContaining(physicalDisks[0].id), + 'Form factor': 'U.2', + }) await expectRowVisible(table, { id: expect.stringContaining(physicalDisks[3].id), 'Form factor': 'M.2',