Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions app/components/form/fields/DisksTableField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -26,7 +25,7 @@ export type DiskTableItem =
const diskTableColumns = [
{
header: 'Name',
cell: (item: DiskTableItem) => <Truncate text={item.name} maxLength={35} />,
text: (item: DiskTableItem) => item.name,
},
{
header: 'Action',
Expand Down
6 changes: 3 additions & 3 deletions app/components/form/fields/NetworkInterfaceField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
]

/**
Expand Down
2 changes: 1 addition & 1 deletion app/components/form/fields/TlsCertsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SiloCreateFormValues> }) {
Expand Down
4 changes: 2 additions & 2 deletions app/forms/firewall-rules-common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down
4 changes: 2 additions & 2 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
2 changes: 1 addition & 1 deletion app/forms/network-interface-edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
152 changes: 144 additions & 8 deletions app/ui/lib/MiniTable.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }

Expand All @@ -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 (
<td>
<div>{children}</div>
<td className={className} style={style}>
<div className="relative whitespace-nowrap">{children}</div>
</td>
)
}
Expand Down Expand Up @@ -79,6 +92,36 @@ const RemoveCell = ({ onClick, label }: { onClick: () => void; label: string })
</Cell>
)

const TruncateCell = ({ text }: { text: string }) => {
const ref = useRef<HTMLDivElement>(null)
const [isTruncated, setIsTruncated] = useState(false)

const inner = (
<div
ref={ref}
className="absolute inset-x-3 truncate"
onMouseEnter={() => {
const el = ref.current
setIsTruncated(!!el && el.scrollWidth > el.clientWidth)
}}
>
{text}
</div>
)

return (
<div className="flex h-full w-full items-center justify-center">
{isTruncated ? (
<Tooltip content={text} placement="bottom">
{inner}
</Tooltip>
) : (
inner
)}
</div>
)
}

type ClearAndAddButtonsProps = {
addButtonCopy: string
disabled: boolean
Expand Down Expand Up @@ -108,12 +151,19 @@ export const ClearAndAddButtons = ({

type Column<T> = {
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<T> = {
ariaLabel: string
items: T[]
/** Keep this array referentially stable so column-width memoization is effective. */
columns: Column<T>[]
rowKey: (item: T, index: number) => string
onRemoveItem: (item: T) => void
Expand All @@ -126,6 +176,82 @@ type MiniTableProps<T> = {
className?: string
}

function isTextColumn<T>(
col: Column<T>
): 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<T>(column: Column<T>, 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<T>(columns: Column<T>[], 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<T>({
ariaLabel,
Expand All @@ -137,6 +263,8 @@ export function MiniTable<T>({
emptyState,
className,
}: MiniTableProps<T>) {
const colWidths = useColumnWidths(columns, items)

if (!emptyState && items.length === 0) return null

return (
Expand All @@ -153,9 +281,17 @@ export function MiniTable<T>({
{items.length ? (
items.map((item, index) => (
<Row tabIndex={0} aria-rowindex={index + 1} key={rowKey(item, index)}>
{columns.map((column, colIndex) => (
<Cell key={colIndex}>{column.cell(item)}</Cell>
))}
{columns.map((column, colIndex) => {
return (
<Cell key={colIndex} {...colWidths[colIndex]}>
{isTextColumn(column) ? (
<TruncateCell text={column.text(item)} />
) : (
column.cell(item)
)}
</Cell>
)
})}

<RemoveCell
onClick={() => onRemoveItem(item)}
Expand Down
30 changes: 30 additions & 0 deletions app/ui/lib/text-width.ts
Original file line number Diff line number Diff line change
@@ -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`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is relatively quick, though you could certainly give up a meaningful chunk of the render cycle calculating a sufficiently large table (i mean on the order of, say, thousands of unique cells). realistically i'm not sure that's a reachable scale, but given the constraints on names (which are most of these columns), i'd wager that text.length is a good-enough estimation that also eliminates the need for a cache

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this specific use case ... perhaps. Though it depends very much on the character choice and combinations. For columns as narrow as these get that effect can be quite pronounced.

I mention it briefly here: #3182 (comment)

Would also say there's value in consistency beyond this use case. That's to say, rather than adding an exemption here and using string length, it's probably clearer to use text measurement everywhere (which is my recommendation for the Truncate function).

Mini-tables are mini, so we can safely say we're not going to be calculating significant numbers of cells.

* 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
}
2 changes: 1 addition & 1 deletion app/ui/styles/components/mini-table.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading