diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx index 2f56e61ae..73e403394 100644 --- a/app/components/form/fields/DisksTableField.tsx +++ b/app/components/form/fields/DisksTableField.tsx @@ -17,7 +17,6 @@ import type { InstanceCreateInput } from '~/forms/instance-create' import { sizeCellInner } from '~/table/columns/common' import { Button } from '~/ui/lib/Button' import { MiniTable } from '~/ui/lib/MiniTable' -import { Truncate } from '~/ui/lib/Truncate' export type DiskTableItem = | (DiskCreate & { action: 'create' }) @@ -52,7 +51,7 @@ export function DisksTableField({ columns={[ { header: 'Name', - cell: (item) => , + text: (item) => item.name, }, { header: 'Action', diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index 0dbe3b639..450a59645 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -116,9 +116,9 @@ export function NetworkInterfaceField({ ariaLabel="Network Interfaces" items={value.params} columns={[ - { header: 'Name', cell: (item) => item.name }, - { header: 'VPC', cell: (item) => item.vpcName }, - { header: 'Subnet', cell: (item) => item.subnetName }, + { header: 'Name', text: (item) => item.name }, + { header: 'VPC', text: (item) => item.vpcName }, + { header: 'Subnet', text: (item) => item.subnetName }, ]} rowKey={(item) => item.name} onRemoveItem={(item) => diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index 824f98612..9d8c1726a 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -50,7 +50,7 @@ export function TlsCertsField({ control }: { control: Control item.name }]} + columns={[{ header: 'Name', text: (item) => item.name }]} rowKey={(item) => item.name} onRemoveItem={(item) => onChange(items.filter((i) => i.name !== item.name))} removeLabel={(item) => `remove cert ${item.name}`} diff --git a/app/forms/firewall-rules-common.tsx b/app/forms/firewall-rules-common.tsx index 56340d04f..ef1ccdcd0 100644 --- a/app/forms/firewall-rules-common.tsx +++ b/app/forms/firewall-rules-common.tsx @@ -314,11 +314,11 @@ const targetAndHostTableColumns = [ }, { header: 'Value', - cell: (item: VpcFirewallRuleTarget | VpcFirewallRuleHostFilter) => item.value, + text: (item: VpcFirewallRuleTarget | VpcFirewallRuleHostFilter) => item.value, }, ] -const portTableColumns = [{ header: 'Port ranges', cell: (p: string) => p }] +const portTableColumns = [{ header: 'Port ranges', text: (p: string) => p }] const protocolTableColumns = [ { diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 33fc7ab8d..d46baa467 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -1017,8 +1017,8 @@ const NetworkingSection = ({ ariaLabel="Floating IPs" items={attachedFloatingIps} columns={[ - { header: 'Name', cell: (item) => item.name }, - { header: 'IP', cell: (item) => item.ip }, + { header: 'Name', text: (item) => item.name }, + { header: 'IP', text: (item) => item.ip }, ]} rowKey={(item) => item.name} onRemoveItem={(item) => detachFloatingIp(item.name)} diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index 8bd206ccd..0302c8b31 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -164,7 +164,7 @@ export function EditNetworkInterfaceForm({ className="mb-4" ariaLabel="Transit IPs" items={transitIps} - columns={[{ header: 'Transit IPs', cell: (ip) => ip }]} + columns={[{ header: 'Transit IPs', text: (ip) => ip }]} rowKey={(ip) => ip} onRemoveItem={(ip) => { form.setValue( diff --git a/app/table/columns/common.tsx b/app/table/columns/common.tsx index e6cb83103..e6904cdf0 100644 --- a/app/table/columns/common.tsx +++ b/app/table/columns/common.tsx @@ -42,7 +42,7 @@ function instanceStateCell(info: Info) { export function sizeCellInner(value: number) { const size = filesize(value, { base: 2, output: 'object' }) return ( - + {size.value} {size.unit} ) diff --git a/app/ui/lib/MiniTable.tsx b/app/ui/lib/MiniTable.tsx index 29e121d04..56dd76aa8 100644 --- a/app/ui/lib/MiniTable.tsx +++ b/app/ui/lib/MiniTable.tsx @@ -1,3 +1,5 @@ +import { useRef, useState, type ReactNode, useMemo } from 'react' + /* * 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 @@ -12,6 +14,8 @@ import { classed } from '~/util/classed' import { Button } from './Button' import { EmptyMessage } from './EmptyMessage' import { Table as BigTable } from './Table' +import { textWidth } from './text-width' +import { Tooltip } from './Tooltip' type Children = { children: React.ReactNode } @@ -29,10 +33,18 @@ const Body = classed.tbody`` const Row = classed.tr`*:border-default last:*:border-b *:first:border-l *:last:border-r` -const Cell = ({ children }: Children) => { +const Cell = ({ + children, + className, + style, +}: { + children: ReactNode + className?: string + style?: React.CSSProperties +}) => { return ( - -
{children}
+ +
{children}
) } @@ -78,6 +90,36 @@ const RemoveCell = ({ onClick, label }: { onClick: () => void; label: string }) ) +const TruncateCell = ({ text }: { text: string }) => { + const ref = useRef(null) + const [isTruncated, setIsTruncated] = useState(false) + + const inner = ( +
{ + const el = ref.current + setIsTruncated(!!el && el.scrollWidth > el.clientWidth) + }} + > + {text} +
+ ) + + return ( +
+ {isTruncated ? ( + + {inner} + + ) : ( + inner + )} +
+ ) +} + type ClearAndAddButtonsProps = { addButtonCopy: string disabled: boolean @@ -107,8 +149,14 @@ export const ClearAndAddButtons = ({ type Column = { header: string - cell: (item: T, index: number) => React.ReactNode -} +} & ( + | { cell: (item: T, index: number) => React.ReactNode } + | { + /** Columns with `text` auto-truncate and share remaining table width + * proportionally based on their measured text content. */ + text: (item: T) => string + } +) type MiniTableProps = { ariaLabel: string @@ -125,6 +173,66 @@ type MiniTableProps = { className?: string } +function isTextColumn( + col: Column +): col is { header: string; text: (item: T) => string } { + return 'text' in col +} + +/** + * For each text column, find the max text width across all items, then + * distribute remaining table width proportionally. Returns a per-column + * style object (undefined for fit-to-content columns). + */ +function useColumnWidths(columns: Column[], items: T[]) { + return useMemo(() => { + const hasTextCols = columns.some(isTextColumn) + if (!hasTextCols || items.length === 0) { + // Fall back to the old behavior: first column gets w-full + return columns.map((_, i) => (i === 0 ? 'w-full' : undefined)) + } + + // Measure max natural text width per text column. + // text-sans-md = 400 14px/1.125rem SuisseIntl, letter-spacing 0.03rem + const font = '400 14px SuisseIntl' + const letterSpacing = '0.03rem' + const maxWidths = columns.map((col) => { + if (!isTextColumn(col)) return 0 + let max = 0 + for (const item of items) { + const w = textWidth(col.text(item), font, letterSpacing) + if (w > max) max = w + } + return max + }) + + const textColCount = maxWidths.filter((w) => w > 0).length + const totalTextWidth = maxWidths.reduce((sum, w) => sum + w, 0) + if (totalTextWidth === 0 || textColCount === 0) { + return columns.map((_, i) => (i === 0 ? 'w-full' : undefined)) + } + + // Max ratio between widest and narrowest text column. + // 1 = all equal, higher = more variation. + const maxWidthRatio = 5 / 2 + const equalShare = totalTextWidth / textColCount + const spread = Math.sqrt(maxWidthRatio) + const floor = equalShare / spread + const ceiling = equalShare * spread + const clamped = maxWidths.map((w) => + w > 0 ? Math.min(Math.max(w, floor), ceiling) : 0 + ) + const clampedTotal = clamped.reduce((sum, w) => sum + w, 0) + + // Text columns share available space proportionally; others fit content + return columns.map((col, i) => { + if (!isTextColumn(col)) return undefined + const pct = (clamped[i] / clampedTotal) * 100 + return { width: `${pct.toFixed(1)}%` } as const + }) + }, [columns, items]) +} + /** If `emptyState` is left out, `MiniTable` renders null when `items` is empty. */ export function MiniTable({ ariaLabel, @@ -136,6 +244,8 @@ export function MiniTable({ emptyState, className, }: MiniTableProps) { + const colWidths = useColumnWidths(columns, items) + if (!emptyState && items.length === 0) return null return ( @@ -152,9 +262,20 @@ export function MiniTable({ {items.length ? ( items.map((item, index) => ( - {columns.map((column, colIndex) => ( - {column.cell(item, index)} - ))} + {columns.map((column, colIndex) => { + const w = colWidths[colIndex] + const className = typeof w === 'string' ? w : undefined + const style = typeof w === 'object' ? w : undefined + return ( + + {isTextColumn(column) ? ( + + ) : ( + column.cell(item, index) + )} + + ) + })} onRemoveItem(item)} diff --git a/app/ui/lib/text-width.ts b/app/ui/lib/text-width.ts new file mode 100644 index 000000000..768ff557f --- /dev/null +++ b/app/ui/lib/text-width.ts @@ -0,0 +1,38 @@ +/* + * 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 + */ + +let ctx: CanvasRenderingContext2D | null = null + +function getContext(): CanvasRenderingContext2D { + if (!ctx) { + const canvas = document.createElement('canvas') + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- offscreen canvas always has 2d context + ctx = canvas.getContext('2d')! + } + return ctx +} + +const cache = new Map() + +/** + * Measure the rendered pixel width of `text` using Canvas `measureText`. + * Accounts for font shaping, kerning, and letter-spacing. Reuses a single + * offscreen canvas context and caches results. + */ +export function textWidth(text: string, font: string, letterSpacing = '0px'): number { + const key = font + '\0' + letterSpacing + '\0' + text + const cached = cache.get(key) + if (cached != null) return cached + + const context = getContext() + context.font = font + context.letterSpacing = letterSpacing + const width = context.measureText(text).width + cache.set(key, width) + return width +} diff --git a/app/ui/styles/components/mini-table.css b/app/ui/styles/components/mini-table.css index 862745431..741d3697e 100644 --- a/app/ui/styles/components/mini-table.css +++ b/app/ui/styles/components/mini-table.css @@ -38,7 +38,7 @@ /* all divs */ & td > div { - @apply border-default flex h-9 items-center border border-y border-r-0 py-3 pr-6 pl-3; + @apply border-default flex h-9 items-center border border-y border-r-0 pr-4 pl-3; } /* first cell's div */