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