(null)
+ const [isTruncated, setIsTruncated] = useState(false)
+
+ 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 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
-}) => (
-
-
-
-
- |
+const EmptyCell = roleDiv('cell', 'col-span-full flex flex-col items-center py-4')
+
+const EmptyState = (props: { title: string; body: string }) => (
+
+
+
+
+
)
// 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 }) => (
-
- |
+
)
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,73 @@ 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);
diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts
index 01bf5a146..f1bfc3ee3 100644
--- a/test/e2e/utils.ts
+++ b/test/e2e/utils.ts
@@ -125,13 +125,16 @@ export async function expectRowVisible(
table: Locator,
expectedRow: Record
) {
+ // locate by role rather than thead/tbody because MiniTable is divs with
+ // table roles. header rows are the ones containing column headers
+ const columnheader = table.page().getByRole('columnheader')
+ const headerRowLoc = table.getByRole('row').filter({ has: columnheader })
+ const bodyRowLoc = table.getByRole('row').filter({ hasNot: columnheader })
+
// wait for header and rows to avoid flake town
- const headerLoc = table.locator('thead >> role=columnheader')
// unlike most things, waitFor has no timeout by default
- await headerLoc.first().waitFor({ timeout: 10_000 }) // nth=0 bc error if there's more than 1
-
- const rowLoc = table.locator('tbody >> role=row')
- await rowLoc.first().waitFor({ timeout: 10_000 })
+ await headerRowLoc.first().waitFor({ timeout: 10_000 }) // nth=0 bc error if there's more than 1
+ await bodyRowLoc.first().waitFor({ timeout: 10_000 })
async function getRows() {
// need to pull header keys every time because the whole page can change
@@ -139,14 +142,13 @@ export async function expectRowVisible(
// filter out data-test-ignore is specifically for making the header cells
// match up with the contents on the double-header utilization table
- const headerKeys = await table
- .locator('thead')
- .getByRole('row')
+ const headerKeys = await headerRowLoc
.last()
- .locator('th:not([data-test-ignore])')
+ .getByRole('columnheader')
+ .and(table.page().locator(':not([data-test-ignore])'))
.allTextContents()
- const rows = await map(table.locator('tbody >> role=row'), async (row) => {
+ const rows = await map(bodyRowLoc, async (row) => {
// accessible name would be better than cell text but it's not in yet
// https://github.com/microsoft/playwright/issues/13517
const textContents = await row.locator('role=cell').allTextContents()