diff --git a/app/components/form/fields/DisksTableField.tsx b/app/components/form/fields/DisksTableField.tsx index d268b00aa..1b8b677b3 100644 --- a/app/components/form/fields/DisksTableField.tsx +++ b/app/components/form/fields/DisksTableField.tsx @@ -16,7 +16,6 @@ import { CreateDiskSideModalForm } from '~/forms/disk-create' import type { InstanceCreateInput } from '~/forms/instance-create' import { Button } from '~/ui/lib/Button' import { MiniTable } from '~/ui/lib/MiniTable' -import { Truncate } from '~/ui/lib/Truncate' import { Size } from '~/ui/lib/ValueUnit' export type DiskTableItem = @@ -26,7 +25,7 @@ export type DiskTableItem = const diskTableColumns = [ { header: 'Name', - cell: (item: DiskTableItem) => , + text: (item: DiskTableItem) => item.name, }, { header: 'Action', diff --git a/app/components/form/fields/NetworkInterfaceField.tsx b/app/components/form/fields/NetworkInterfaceField.tsx index a9fd0f3ef..7b9767d31 100644 --- a/app/components/form/fields/NetworkInterfaceField.tsx +++ b/app/components/form/fields/NetworkInterfaceField.tsx @@ -19,9 +19,9 @@ import { MiniTable } from '~/ui/lib/MiniTable' import { Radio } from '~/ui/lib/Radio' const networkInterfaceTableColumns = [ - { header: 'Name', cell: (item: InstanceNetworkInterfaceCreate) => item.name }, - { header: 'VPC', cell: (item: InstanceNetworkInterfaceCreate) => item.vpcName }, - { header: 'Subnet', cell: (item: InstanceNetworkInterfaceCreate) => item.subnetName }, + { header: 'Name', text: (item: InstanceNetworkInterfaceCreate) => item.name }, + { header: 'VPC', text: (item: InstanceNetworkInterfaceCreate) => item.vpcName }, + { header: 'Subnet', text: (item: InstanceNetworkInterfaceCreate) => item.subnetName }, ] /** diff --git a/app/components/form/fields/TlsCertsField.tsx b/app/components/form/fields/TlsCertsField.tsx index 097824e0d..545ec9ca1 100644 --- a/app/components/form/fields/TlsCertsField.tsx +++ b/app/components/form/fields/TlsCertsField.tsx @@ -23,7 +23,7 @@ import { FileField } from './FileField' import { NameField } from './NameField' const tlsCertTableColumns = [ - { header: 'Name', cell: (item: CertificateCreate) => item.name }, + { header: 'Name', text: (item: CertificateCreate) => item.name }, ] export function TlsCertsField({ control }: { control: Control }) { diff --git a/app/forms/firewall-rules-common.tsx b/app/forms/firewall-rules-common.tsx index f8f70d253..b597bd786 100644 --- a/app/forms/firewall-rules-common.tsx +++ b/app/forms/firewall-rules-common.tsx @@ -320,11 +320,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 2895873ea..ee48755a6 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -94,8 +94,8 @@ import { GiB } from '~/util/units' const EMPTY_NAME_OR_ID_LIST: NameOrId[] = [] const floatingIpTableColumns = [ - { header: 'Name', cell: (item: FloatingIp) => item.name }, - { header: 'IP', cell: (item: FloatingIp) => item.ip }, + { header: 'Name', text: (item: FloatingIp) => item.name }, + { header: 'IP', text: (item: FloatingIp) => item.ip }, ] const getBootDiskAttachment = ( diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index cbc7ef6d0..909d71dfc 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -34,7 +34,7 @@ import { KEYS } from '~/ui/util/keys' import { parseIpNet, validateIpNet } from '~/util/ip' import { docLinks, links } from '~/util/links' -const transitIpTableColumns = [{ header: 'Transit IPs', cell: (ip: string) => ip }] +const transitIpTableColumns = [{ header: 'Transit IPs', text: (ip: string) => ip }] type EditNetworkInterfaceFormProps = { editing: InstanceNetworkInterface diff --git a/app/ui/lib/MiniTable.tsx b/app/ui/lib/MiniTable.tsx index 0cf348973..a99048b9c 100644 --- a/app/ui/lib/MiniTable.tsx +++ b/app/ui/lib/MiniTable.tsx @@ -1,3 +1,6 @@ +import { useRef, useState, type ReactNode, useMemo } from 'react' +import * as R from 'remeda' + /* * 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 +15,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 +34,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}
) } @@ -79,6 +92,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 @@ -108,12 +151,19 @@ export const ClearAndAddButtons = ({ type Column = { header: string - cell: (item: T) => React.ReactNode -} +} & ( + | { cell: (item: T) => 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 items: T[] + /** Keep this array referentially stable so column-width memoization is effective. */ columns: Column[] rowKey: (item: T, index: number) => string onRemoveItem: (item: T) => void @@ -126,6 +176,82 @@ type MiniTableProps = { className?: string } +function isTextColumn( + col: Column +): col is { header: string; text: (item: T) => string } { + return 'text' in col +} + +type ColumnWidthProps = { + className?: string + style?: React.CSSProperties +} + +/** + * Measure the widest rendered value in one text column. Returns 0 when the + * column is custom-rendered or contains no measurable text. + */ +function measureColumnWidth(column: Column, items: T[]) { + if (!isTextColumn(column)) return 0 + + // Keep these in sync with the table's text-sans-md class. + const font = '400 14px SuisseIntl' + const letterSpacing = '0.03rem' + let maxWidth = 0 + for (const item of items) { + maxWidth = Math.max(maxWidth, textWidth(column.text(item), font, letterSpacing)) + } + return maxWidth +} + +/** + * Clamp measured text-column widths around their shared average. Using the + * square root of the ratio for both bounds keeps the clamp symmetric while + * limiting the widest-to-narrowest allocation to 2.5. Zero marks a column + * without measurable text and is preserved. + */ +function clampColumnWidths(widths: number[], averageWidth: number) { + const spread = Math.sqrt(5 / 2) + const floor = averageWidth / spread + const ceiling = averageWidth * spread + return widths.map((width) => (width > 0 ? Math.min(Math.max(width, floor), ceiling) : 0)) +} + +/** + * Build sizing props for every table column. Text columns are measured + * independently, clamped together to prevent extreme allocations, and then + * normalized into percentages. Custom-rendered columns receive no sizing + * props and remain fit-to-content. When no text is measurable, the first + * column receives the table's original `w-full` fallback. + */ +function useColumnWidths(columns: Column[], items: T[]): ColumnWidthProps[] { + 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 ? { className: 'w-full' } : {})) + } + + const maxWidths = columns.map((column) => measureColumnWidth(column, items)) + + const textColCount = maxWidths.filter((w) => w > 0).length + if (textColCount === 0) { + return columns.map((_, i) => (i === 0 ? { className: 'w-full' } : {})) + } + + const averageWidth = R.sum(maxWidths) / textColCount + const clampedWidths = clampColumnWidths(maxWidths, averageWidth) + const totalClampedWidth = R.sum(clampedWidths) + + // Text columns share available space proportionally; others fit content + return columns.map((col, i) => { + if (!isTextColumn(col)) return {} + const pct = (clampedWidths[i] / totalClampedWidth) * 100 + return { style: { width: `${pct.toFixed(1)}%` } } + }) + }, [columns, items]) +} + /** If `emptyState` is left out, `MiniTable` renders null when `items` is empty. */ export function MiniTable({ ariaLabel, @@ -137,6 +263,8 @@ export function MiniTable({ emptyState, className, }: MiniTableProps) { + const colWidths = useColumnWidths(columns, items) + if (!emptyState && items.length === 0) return null return ( @@ -153,9 +281,17 @@ export function MiniTable({ {items.length ? ( items.map((item, index) => ( - {columns.map((column, colIndex) => ( - {column.cell(item)} - ))} + {columns.map((column, colIndex) => { + return ( + + {isTextColumn(column) ? ( + + ) : ( + column.cell(item) + )} + + ) + })} onRemoveItem(item)} diff --git a/app/ui/lib/text-width.ts b/app/ui/lib/text-width.ts new file mode 100644 index 000000000..6666a8dd0 --- /dev/null +++ b/app/ui/lib/text-width.ts @@ -0,0 +1,30 @@ +/* + * 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 +} + +/** + * Measure the rendered pixel width of `text` using Canvas `measureText`. + * Accounts for font shaping, kerning, and letter-spacing. Reuses a single + * offscreen canvas context. + */ +export function textWidth(text: string, font: string, letterSpacing = '0px'): number { + const context = getContext() + context.font = font + context.letterSpacing = letterSpacing + return context.measureText(text).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 */