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 @@ -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' })
Expand Down Expand Up @@ -52,7 +51,7 @@ export function DisksTableField({
columns={[
{
header: 'Name',
cell: (item) => <Truncate text={item.name} maxLength={35} />,
text: (item) => 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 @@ -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) =>
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 @@ -50,7 +50,7 @@ export function TlsCertsField({ control }: { control: Control<SiloCreateFormValu
className="mb-4"
ariaLabel="TLS Certificates"
items={items}
columns={[{ header: 'Name', cell: (item) => 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}`}
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 @@ -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 = [
{
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 @@ -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)}
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 @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion app/table/columns/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function instanceStateCell(info: Info<InstanceState>) {
export function sizeCellInner(value: number) {
const size = filesize(value, { base: 2, output: 'object' })
return (
<span className="text-default">
<span className="text-default text-nowrap">
{size.value} <span className="text-tertiary">{size.unit}</span>
</span>
)
Expand Down
137 changes: 129 additions & 8 deletions app/ui/lib/MiniTable.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 }

Expand All @@ -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 (
<td>
<div>{children}</div>
<td className={className} style={style}>
<div className="relative">{children}</div>
</td>
)
}
Expand Down Expand Up @@ -78,6 +90,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 @@ -107,8 +149,14 @@ export const ClearAndAddButtons = ({

type Column<T> = {
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<T> = {
ariaLabel: string
Expand All @@ -125,6 +173,66 @@ type MiniTableProps<T> = {
className?: string
}

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

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

return (
Expand All @@ -152,9 +262,20 @@ 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, index)}</Cell>
))}
{columns.map((column, colIndex) => {
const w = colWidths[colIndex]
const className = typeof w === 'string' ? w : undefined
const style = typeof w === 'object' ? w : undefined
return (
<Cell key={colIndex} className={className} style={style}>
{isTextColumn(column) ? (
<TruncateCell text={column.text(item)} />
) : (
column.cell(item, index)
)}
</Cell>
)
})}

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

/**
* 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
}
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