From d93c22a552527208be201fc934679e6c89cc536a Mon Sep 17 00:00:00 2001 From: David Crespo Date: Fri, 17 Jul 2026 17:40:23 -0500 Subject: [PATCH] rewrite MiniTable as CSS grid with truncating text columns --- .../form/fields/DisksTableField.tsx | 3 +- .../form/fields/NetworkInterfaceField.tsx | 6 +- app/components/form/fields/TlsCertsField.tsx | 2 +- app/forms/firewall-rules-common.tsx | 4 +- app/forms/instance-create.tsx | 4 +- app/forms/network-interface-edit.tsx | 2 +- app/ui/lib/MiniTable.tsx | 230 ++++++++++++------ app/ui/styles/components/mini-table.css | 85 ------- app/ui/styles/index.css | 1 - 9 files changed, 170 insertions(+), 167 deletions(-) delete mode 100644 app/ui/styles/components/mini-table.css 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..f84ac4e0a 100644 --- a/app/ui/lib/MiniTable.tsx +++ b/app/ui/lib/MiniTable.tsx @@ -5,78 +5,110 @@ * * Copyright Oxide Computer Company */ -import { Error16Icon } from '@oxide/design-system/icons/react' +import cn from 'classnames' +import { useRef, useState, type ReactNode } from 'react' -import { classed } from '~/util/classed' +import { Error16Icon } from '@oxide/design-system/icons/react' import { Button } from './Button' import { EmptyMessage } from './EmptyMessage' -import { Table as BigTable } from './Table' +import { Tooltip } from './Tooltip' -type Children = { children: React.ReactNode } +/* + * The table is laid out with CSS grid rather than native table layout so text + * columns can share leftover space and shrink (truncating their contents) + * when there isn't enough room, which table layout can't express. The + * explicit ARIA roles look redundant but are required: `display: grid` (and + * `display: contents` on thead/tbody/tr) strips the implicit table semantics + * in some browsers. + */ +/* eslint-disable jsx-a11y/no-redundant-roles, jsx-a11y/no-interactive-element-to-noninteractive-role */ -const Table = classed.table`ox-mini-table w-full border-separate text-sans-md` +/** Divider between cells, inset so it doesn't touch the row's y borders */ +const headerSeparator = `relative before:border-secondary before:absolute before:inset-y-px before:left-0 before:w-px before:border-l before:content-['']` +const rowSeparator = `relative before:border-tertiary before:absolute before:inset-y-px before:left-0 before:w-px before:border-l before:content-['']` -const Header = ({ children }: Children) => ( - - {children} - +const HeadCell = ({ + className, + children, +}: { + className?: string + children?: ReactNode +}) => ( + + {children} + ) -const HeadCell = BigTable.HeadCell +const Cell = ({ className, children }: { className?: string; children: ReactNode }) => ( + + {children} + +) -const Body = classed.tbody`` +const TruncateCell = ({ text }: { text: string }) => { + const ref = useRef(null) + const [isTruncated, setIsTruncated] = useState(false) -const Row = classed.tr`*:border-default last:*:border-b *:first:border-l *:last:border-r` + const inner = ( +
{ + const el = ref.current + setIsTruncated(!!el && el.scrollWidth > el.clientWidth) + }} + > + {text} +
+ ) -const Cell = ({ children }: Children) => { - return ( - -
{children}
- + return isTruncated ? ( + + {inner} + + ) : ( + inner ) } -const EmptyState = (props: { title: string; body: string; colSpan: number }) => ( - - -
- -
+const EmptyState = (props: { title: string; body: string }) => ( + + + -
-) - -export const InputCell = ({ - colSpan, - defaultValue, - placeholder, -}: { - colSpan?: number - defaultValue: string - placeholder: string -}) => ( - -
- -
- + ) // followed this for icon in button best practices // https://www.sarasoueidan.com/blog/accessible-icon-buttons/ const RemoveCell = ({ onClick, label }: { onClick: () => void; label: string }) => ( - - - + ) type ClearAndAddButtonsProps = { @@ -108,7 +140,19 @@ export const ClearAndAddButtons = ({ type Column = { header: string - cell: (item: T) => React.ReactNode +} & ( + | { cell: (item: T) => ReactNode } + | { + /** Columns with `text` share leftover table width and truncate (with a + * tooltip) when there isn't room; `cell` columns fit their content. */ + text: (item: T) => string + } +) + +function isTextColumn( + col: Column +): col is { header: string; text: (item: T) => string } { + return 'text' in col } type MiniTableProps = { @@ -139,38 +183,84 @@ export function MiniTable({ }: MiniTableProps) { if (!emptyState && items.length === 0) return null + const hasTextCol = columns.some(isTextColumn) + // Text columns get `minmax(0, auto)`: sized to their content when + // everything fits, and shrunk (truncating) when it doesn't, sharing the + // available space. Empty text columns use `1fr` because there is no body + // content to make the auto tracks fill the table. `cell` columns always fit + // their content. If no column is a text column, the first one stretches so + // the table fills its container. + const gridTemplateColumns = [ + ...columns.map((col, i) => + isTextColumn(col) + ? items.length === 0 + ? 'minmax(0, 1fr)' + : 'minmax(0, auto)' + : i === 0 && !hasTextCol + ? 'auto' + : 'max-content' + ), + 'min-content', // remove button column + ].join(' ') + return ( - -
- {columns.map((column, index) => ( - {column.header} - ))} - {/* For remove button */} - -
- - +
+ + + {columns.map((column, index) => ( + + {column.header} + + ))} + {/* For remove button */} + + + + + {items.length ? ( items.map((item, index) => ( - + {columns.map((column, colIndex) => ( - {column.cell(item)} + + {isTextColumn(column) ? ( + + ) : ( + column.cell(item) + )} + ))} onRemoveItem(item)} label={removeLabel?.(item) || `Remove item ${index + 1}`} /> - + )) ) : emptyState ? ( - + ) : null} - -
+ + ) } diff --git a/app/ui/styles/components/mini-table.css b/app/ui/styles/components/mini-table.css deleted file mode 100644 index 862745431..000000000 --- a/app/ui/styles/components/mini-table.css +++ /dev/null @@ -1,85 +0,0 @@ -/* - * 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 - */ - -.ox-mini-table { - & { - border-spacing: 0px; - } - - /* all rows */ - & tr { - @apply bg-default; - @apply relative; - } - - /* all cells */ - & td { - @apply relative px-0 pt-2; - } - - /* a fake left border for all cells that aren't first */ - & td + td:before { - @apply border-secondary absolute top-[calc(0.5rem+1px)] bottom-[2px] block w-[1px] border-l; - content: ' '; - } - - & tr td:last-child:before { - @apply hidden; - } - - & tr:last-child td + td:before { - @apply bottom-[calc(0.5rem+2px)]; - } - - /* all divs */ - & td > div { - @apply border-default flex h-9 items-center border border-y border-r-0 py-3 pr-6 pl-3; - } - - /* first cell's div */ - & td:first-child > div { - @apply ml-2 rounded-l-md border-l; - } - - /* second-to-last cell's div */ - & td:nth-last-child(2) > div { - @apply rounded-r-md border-r; - } - - /* last cell's div (the div for the delete button) */ - & td:last-child > div { - @apply flex w-8 items-center justify-center border-none px-5; - } - - /* the delete button */ - & td:last-child > div > button { - @apply text-tertiary hover:text-secondary focus:text-secondary -m-2 flex items-center justify-center p-2; - } - - & tr:last-child td { - @apply pb-2; - } - - & thead tr:first-of-type th:first-of-type { - border-top-left-radius: var(--radius-lg); - @apply overflow-hidden border-l; - } - - & thead tr:first-of-type th:last-of-type { - border-top-right-radius: var(--radius-lg); - @apply w-8 overflow-hidden border-r; - } - - & tbody tr:last-of-type td:first-of-type { - border-bottom-left-radius: var(--radius-lg); - } - - & tbody tr:last-of-type td:last-of-type { - border-bottom-right-radius: var(--radius-lg); - } -} diff --git a/app/ui/styles/index.css b/app/ui/styles/index.css index d65e951fb..ac2ca7028 100644 --- a/app/ui/styles/index.css +++ b/app/ui/styles/index.css @@ -43,7 +43,6 @@ @import './components/Tabs.css' layer(components); @import './components/form.css' layer(components); @import './components/login-page.css' layer(components); -@import './components/mini-table.css' layer(components); @import './components/side-modal.css' layer(components); @import './components/spinner.css' layer(components); @import './components/tooltip.css' layer(components);