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
219 changes: 149 additions & 70 deletions app/ui/lib/MiniTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,78 +5,110 @@
*
* Copyright Oxide Computer Company
*/
import { Error16Icon } from '@oxide/design-system/icons/react'
import cn from 'classnames'
import { useRef, useState, type JSX, 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'

/*
* The table is laid out with CSS grid rather than native table layout so text
Comment thread
david-crespo marked this conversation as resolved.
* columns can share leftover space and shrink (truncating their contents)
* when there isn't enough room, which table layout can't express. Because
* `display: grid` (and `display: contents` on the row groups) strips implicit
* table semantics anyway, we use divs with explicit ARIA table roles rather
* than the semantic elements.
*/

type Children = { children: React.ReactNode }
/** Like `classed.div`, but with an ARIA role too */
function roleDiv(role: string, baseClassName: string) {
const Comp = ({ className, ...rest }: JSX.IntrinsicElements['div']) => (
<div role={role} className={cn(baseClassName, className)} {...rest} />
)
Comp.displayName = `roled.${role}`
return Comp
}

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) => (
<BigTable.Header>
<BigTable.HeaderRow>{children}</BigTable.HeaderRow>
</BigTable.Header>
const Table = roleDiv('table', 'text-sans-md grid w-full')
const RowGroup = roleDiv('rowgroup', 'contents')
const HeaderRow = roleDiv('row', 'col-span-full grid grid-cols-subgrid')
const HeadCell = roleDiv(
'columnheader',
'text-mono-sm text-secondary bg-secondary border-default flex h-9 items-center border-y px-3'
)
const Cell = roleDiv(
'cell',
'border-default flex h-9 min-w-0 items-center border-y pr-4 pl-3'
)

const HeadCell = BigTable.HeadCell

const Body = classed.tbody``
const ItemRow = roleDiv(
'row',
`bg-default before:border-default relative col-span-full grid grid-cols-subgrid pt-2 before:pointer-events-none before:absolute before:inset-0 before:border-x before:content-[''] last:pb-2 last:before:rounded-b-lg last:before:border-b`
)

const Row = classed.tr`*:border-default last:*:border-b *:first:border-l *:last:border-r`
const TruncateCell = ({ text }: { text: string }) => {
const ref = useRef<HTMLDivElement>(null)
const [isTruncated, setIsTruncated] = useState(false)

const inner = (
<div
ref={ref}
className="truncate"
// check on hover so the tooltip only shows when the text is actually cut off
onMouseEnter={() => {
const el = ref.current
setIsTruncated(!!el && el.scrollWidth > el.clientWidth)
}}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this is ingenious what the hell

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Turns out this was copied from #3182

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.

I taught the robot everything it knows!

>
{text}
</div>
)

const Cell = ({ children }: Children) => {
return (
<td>
<div>{children}</div>
</td>
return isTruncated ? (
<Tooltip content={text} placement="bottom">
{inner}
</Tooltip>
) : (
inner
)
}

const EmptyState = (props: { title: string; body: string; colSpan: number }) => (
<Row>
<td colSpan={props.colSpan}>
<div className="m-0! w-full! flex-col! border-none! bg-transparent! py-14!">
<EmptyMessage title={props.title} body={props.body} />
</div>
</td>
</Row>
const EmptyRow = roleDiv(
'row',
`bg-default before:border-default relative col-span-full grid grid-cols-subgrid py-2 before:pointer-events-none before:absolute before:inset-0 before:rounded-b-lg before:border-x before:border-b before:content-['']`
)

export const InputCell = ({
colSpan,
defaultValue,
placeholder,
}: {
colSpan?: number
defaultValue: string
placeholder: string
}) => (
<td colSpan={colSpan}>
<div>
<input
type="text"
className="text-default placeholder:text-quaternary m-0 w-full bg-transparent p-0 text-sm outline-hidden!"
placeholder={placeholder}
aria-label={placeholder}
defaultValue={defaultValue}
/>
</div>
</td>
const EmptyCell = roleDiv('cell', 'col-span-full flex flex-col items-center py-4')

const EmptyState = (props: { title: string; body: string }) => (
<EmptyRow>
<EmptyCell>
<EmptyMessage title={props.title} body={props.body} />
</EmptyCell>
</EmptyRow>
)

// followed this for icon in button best practices
// https://www.sarasoueidan.com/blog/accessible-icon-buttons/
const RemoveCellWrapper = roleDiv('cell', 'flex h-9 w-11 items-center justify-center')

const RemoveCell = ({ onClick, label }: { onClick: () => void; label: string }) => (
<Cell>
<button type="button" onClick={onClick} aria-label={label}>
<RemoveCellWrapper>
<button
type="button"
className="text-tertiary hover:text-secondary focus:text-secondary -m-2 flex items-center justify-center p-2"
onClick={onClick}
aria-label={label}
>
<Error16Icon aria-hidden focusable="false" />
</button>
</Cell>
</RemoveCellWrapper>
)

type ClearAndAddButtonsProps = {
Expand Down Expand Up @@ -108,7 +140,19 @@ export const ClearAndAddButtons = ({

type Column<T> = {
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<T>(
col: Column<T>
): col is { header: string; text: (item: T) => string } {
return 'text' in col
}

type MiniTableProps<T> = {
Expand Down Expand Up @@ -139,38 +183,73 @@ export function MiniTable<T>({
}: MiniTableProps<T>) {
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 (
<Table aria-label={ariaLabel} className={className}>
<Header>
{columns.map((column, index) => (
<HeadCell key={index}>{column.header}</HeadCell>
))}
{/* For remove button */}
<HeadCell />
</Header>

<Body>
<Table aria-label={ariaLabel} style={{ gridTemplateColumns }} className={className}>
<RowGroup>
<HeaderRow>
{columns.map((column, index) => (
<HeadCell
key={index}
className={index === 0 ? 'rounded-tl-lg border-l' : headerSeparator}
>
{column.header}
</HeadCell>
))}
{/* For remove button */}
<HeadCell className={cn(headerSeparator, 'w-11 rounded-tr-lg border-r')} />
</HeaderRow>
</RowGroup>

<RowGroup>
{items.length ? (
items.map((item, index) => (
<Row tabIndex={0} aria-rowindex={index + 1} key={rowKey(item, index)}>
<ItemRow tabIndex={0} aria-rowindex={index + 1} key={rowKey(item, index)}>
{columns.map((column, colIndex) => (
<Cell key={colIndex}>{column.cell(item)}</Cell>
<Cell
key={colIndex}
className={cn(
colIndex === 0 ? 'ml-2 rounded-l-md border-l' : rowSeparator,
colIndex === columns.length - 1 && 'rounded-r-md border-r'
)}
>
{isTextColumn(column) ? (
<TruncateCell text={column.text(item)} />
) : (
column.cell(item)
)}
</Cell>
))}

<RemoveCell
onClick={() => onRemoveItem(item)}
label={removeLabel?.(item) || `Remove item ${index + 1}`}
/>
</Row>
</ItemRow>
))
) : emptyState ? (
<EmptyState
title={emptyState.title}
body={emptyState.body}
colSpan={columns.length + 1}
/>
<EmptyState title={emptyState.title} body={emptyState.body} />
) : null}
</Body>
</RowGroup>
</Table>
)
}
Loading
Loading