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