diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 0a6439d9c0..4cf00492c3 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -22,6 +22,7 @@ const pluralizationMap = { CodeEditor: 'CodeEditors', CollectionPreferences: 'CollectionPreferences', ColumnLayout: 'ColumnLayouts', + ComparisonTable: 'ComparisonTables', Container: 'Containers', ContentLayout: 'ContentLayouts', CopyToClipboard: 'CopyToClipboards', diff --git a/pages/comparison-table/simple.page.tsx b/pages/comparison-table/simple.page.tsx new file mode 100644 index 0000000000..00e050a71b --- /dev/null +++ b/pages/comparison-table/simple.page.tsx @@ -0,0 +1,79 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; + +import Box from '~components/box'; +import ComparisonTable, { ComparisonTableProps } from '~components/comparison-table'; +import SpaceBetween from '~components/space-between'; +import StatusIndicator from '~components/status-indicator'; + +import ScreenshotArea from '../utils/screenshot-area'; + +const attributes: ComparisonTableProps['attributes'] = [ + { id: 'engine', label: 'Database engine' }, + { id: 'vcpu', label: 'vCPU' }, + { id: 'memory', label: 'Memory' }, + { + id: 'multiAz', + label: 'Multi-AZ', + render: value => ( + {value ? 'Enabled' : 'Disabled'} + ), + }, + { id: 'storage', label: 'Max storage' }, +]; + +const entities: ComparisonTableProps['entities'] = [ + { + id: 'db-r6g', + title: 'db.r6g.large', + data: { engine: 'MySQL', vcpu: 2, memory: '16 GiB', multiAz: true, storage: '64 TiB' }, + }, + { + id: 'db-r6g-xl', + title: 'db.r6g.xlarge', + data: { engine: 'MySQL', vcpu: 4, memory: '32 GiB', multiAz: true, storage: '64 TiB' }, + }, + { + id: 'db-r6g-2xl', + title: 'db.r6g.2xlarge', + data: { engine: 'MySQL', vcpu: 8, memory: '64 GiB', multiAz: false, storage: '64 TiB' }, + }, +]; + +export default function ComparisonTablePage() { + const [highlight, setHighlight] = React.useState(true); + const [sticky, setSticky] = React.useState(true); + + return ( + <> +

Comparison table (WIP)

+ + + + + + + + + + + Empty state + + + + ); +} diff --git a/src/__tests__/required-props-for-components.ts b/src/__tests__/required-props-for-components.ts index 3430c62d2d..933fa8c139 100644 --- a/src/__tests__/required-props-for-components.ts +++ b/src/__tests__/required-props-for-components.ts @@ -6,6 +6,7 @@ export { defaultSplitPanelContextProps } from '../split-panel/__tests__/helpers' const defaultProps: Record> = { tabs: { tabs: [] }, table: { columnDefinitions: [] }, + 'comparison-table': { attributes: [], entities: [] }, cards: { cardDefinition: {} }, autosuggest: { options: [], enteredPrefix: '' }, 'anchor-navigation': { anchors: [] }, diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..200f12e5de 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -10214,6 +10214,115 @@ and the maximum number of columns as defined by the \`columns\` property.", } `; +exports[`Components definition for comparison-table matches the snapshot: comparison-table 1`] = ` +{ + "dashCaseName": "comparison-table", + "description": "ComparisonTable renders a side-by-side comparison of two or more entities: attributes are +rendered as rows, entities as columns, with a sticky attribute column and optional +highlighting of rows whose values differ across entities. + +This is a work-in-progress (v0) component built as a thin, opt-in composition over the +existing Table component.", + "events": [], + "functions": [], + "name": "ComparisonTable", + "properties": [ + { + "description": "Provides an accessible name for the underlying table, announced by assistive technology.", + "name": "ariaLabel", + "optional": true, + "type": "string", + }, + { + "defaultValue": "''", + "description": "Header text rendered above the sticky attribute (first) column. +Defaults to an empty string.", + "name": "attributeColumnHeader", + "optional": true, + "type": "string", + }, + { + "description": "The attributes to compare across entities. Each attribute is rendered as a **row**. + +The order of this array is the order the rows appear in. Use the \`render\` function on an +attribute to customize how the value for each entity is displayed.", + "name": "attributes", + "optional": false, + "type": "ReadonlyArray", + }, + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "The entities being compared. Each entity is rendered as a **column** next to the sticky +attribute column. + +Every entity exposes its per-attribute values through the \`data\` map, keyed by attribute \`id\`.", + "name": "entities", + "optional": false, + "type": "ReadonlyArray", + }, + { + "defaultValue": "false", + "description": "When \`true\`, attribute rows whose values are not identical across all entities are visually +emphasized so differences stand out. + +Equality is determined with \`Object.is\` against the raw \`entity.data[attribute.id]\` values. +Rows with a custom \`render\` are still compared using their raw \`data\` values, so keep the +\`data\` values comparable (primitives) when relying on this feature.", + "name": "highlightDifferences", + "optional": true, + "type": "boolean", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "defaultValue": "true", + "description": "Whether the attribute (first) column is pinned to the inline-start edge while the entity +columns scroll horizontally. Defaults to \`true\`.", + "name": "stickyAttributeColumn", + "optional": true, + "type": "boolean", + }, + { + "defaultValue": "'container'", + "description": "The visual variant of the comparison table. + +- \`"container"\` (default) — renders inside a bordered container. +- \`"embedded"\` — removes the outer container border for embedding in another surface. +- \`"borderless"\` — removes borders entirely. +- \`"stacked"\` — for use in a vertically stacked set of containers.", + "inlineType": { + "name": "ComparisonTableProps.Variant", + "type": "union", + "values": [ + "container", + "embedded", + "stacked", + "borderless", + ], + }, + "name": "variant", + "optional": true, + "type": "string", + }, + ], + "regions": [], + "releaseStatus": "stable", +} +`; + exports[`Components definition for container matches the snapshot: container 1`] = ` { "dashCaseName": "container", @@ -39171,6 +39280,53 @@ Note that, despite its typings, this may return null for group items since group ], "name": "ColumnLayoutWrapper", }, + { + "methods": [ + { + "description": "Returns all attribute (row header) label elements.", + "name": "findAttributeLabels", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "Returns all cells that are highlighted because their attribute row differs across entities.", + "name": "findHighlightedCells", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "Returns all rendered rows of the underlying table (one per attribute).", + "name": "findRows", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + ], + "name": "ComparisonTableWrapper", + }, { "methods": [ { @@ -50181,6 +50337,53 @@ Note that, despite its typings, this may return null for group items since group ], "name": "ColumnLayoutWrapper", }, + { + "methods": [ + { + "description": "Returns all attribute (row header) label elements.", + "name": "findAttributeLabels", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "Returns all cells that are highlighted because their attribute row differs across entities.", + "name": "findHighlightedCells", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "Returns all rendered rows of the underlying table (one per attribute).", + "name": "findRows", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + ], + "name": "ComparisonTableWrapper", + }, { "methods": [ { diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap index 4b1af42bc5..0e2e82e77f 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-selectors.test.tsx.snap @@ -191,6 +191,11 @@ exports[`test-utils selectors 1`] = ` "awsui_column-layout_vvxn7", "awsui_item_zqq3x", ], + "comparison-table": [ + "awsui_attribute-label_gfu3l", + "awsui_cell-highlighted_gfu3l", + "awsui_root_gfu3l", + ], "container": [ "awsui_content-inner_1mwlm", "awsui_content-wrapper_14iqq", diff --git a/src/comparison-table/__tests__/comparison-table.test.tsx b/src/comparison-table/__tests__/comparison-table.test.tsx new file mode 100644 index 0000000000..1776c054bf --- /dev/null +++ b/src/comparison-table/__tests__/comparison-table.test.tsx @@ -0,0 +1,78 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import ComparisonTable, { ComparisonTableProps } from '../../../lib/components/comparison-table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +const attributes: ComparisonTableProps['attributes'] = [ + { id: 'engine', label: 'Engine' }, + { id: 'vcpu', label: 'vCPU' }, +]; + +const entities: ComparisonTableProps['entities'] = [ + { id: 'a', title: 'Option A', data: { engine: 'MySQL', vcpu: 2 } }, + { id: 'b', title: 'Option B', data: { engine: 'MySQL', vcpu: 4 } }, +]; + +function renderComparisonTable(jsx: React.ReactElement) { + const { container } = render(jsx); + return createWrapper(container).findComparisonTable()!; +} + +describe('ComparisonTable', () => { + test('renders and is findable via test-utils', () => { + const wrapper = renderComparisonTable(); + expect(wrapper).not.toBeNull(); + }); + + test('renders one row per attribute (attributes as rows)', () => { + const wrapper = renderComparisonTable(); + expect(wrapper.findRows()).toHaveLength(attributes.length); + }); + + test('renders one column header per entity plus the attribute column', () => { + const wrapper = renderComparisonTable(); + // Attribute column header + one per entity. + const headers = wrapper.getElement().querySelectorAll('thead th'); + expect(headers.length).toBe(entities.length + 1); + }); + + test('renders attribute labels in the sticky attribute column', () => { + const wrapper = renderComparisonTable(); + const labels = wrapper.findAttributeLabels().map(l => l.getElement().textContent); + expect(labels).toEqual(['Engine', 'vCPU']); + }); + + test('highlightDifferences emphasizes only differing rows', () => { + const wrapper = renderComparisonTable( + + ); + // "engine" is identical (MySQL/MySQL); "vcpu" differs (2 vs 4) => one differing row across 2 entity cells. + expect(wrapper.findHighlightedCells()).toHaveLength(2); + }); + + test('does not highlight anything when highlightDifferences is false', () => { + const wrapper = renderComparisonTable( + + ); + expect(wrapper.findHighlightedCells()).toHaveLength(0); + }); + + test('uses the render function to customize entity values', () => { + const wrapper = renderComparisonTable( + {`[${value}]`} }]} + entities={entities} + /> + ); + expect(wrapper.getElement().textContent).toContain('[MySQL]'); + }); + + test('renders without crashing when there are no attributes or entities', () => { + const wrapper = renderComparisonTable(); + expect(wrapper).not.toBeNull(); + expect(wrapper.findRows()).toHaveLength(0); + }); +}); diff --git a/src/comparison-table/index.tsx b/src/comparison-table/index.tsx new file mode 100644 index 0000000000..c48b022583 --- /dev/null +++ b/src/comparison-table/index.tsx @@ -0,0 +1,53 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import { getBaseProps } from '../internal/base-component'; +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { ComparisonTableProps } from './interfaces'; +import InternalComparisonTable from './internal'; + +export { ComparisonTableProps }; + +/** + * ComparisonTable renders a side-by-side comparison of two or more entities: attributes are + * rendered as rows, entities as columns, with a sticky attribute column and optional + * highlighting of rows whose values differ across entities. + * + * This is a work-in-progress (v0) component built as a thin, opt-in composition over the + * existing Table component. + */ +export default function ComparisonTable({ + attributes, + entities, + attributeColumnHeader = '', + stickyAttributeColumn = true, + highlightDifferences = false, + variant = 'container', + ariaLabel, + ...rest +}: ComparisonTableProps) { + const baseComponentProps = useBaseComponent('ComparisonTable', { + props: { stickyAttributeColumn, highlightDifferences, variant }, + metadata: { attributesCount: attributes.length, entitiesCount: entities.length }, + }); + const baseProps = getBaseProps(rest); + + return ( + + ); +} + +applyDisplayName(ComparisonTable, 'ComparisonTable'); diff --git a/src/comparison-table/interfaces.ts b/src/comparison-table/interfaces.ts new file mode 100644 index 0000000000..8b6e7eafeb --- /dev/null +++ b/src/comparison-table/interfaces.ts @@ -0,0 +1,100 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import { BaseComponentProps } from '../types/base-component'; + +export interface ComparisonTableProps extends BaseComponentProps { + /** + * The attributes to compare across entities. Each attribute is rendered as a **row**. + * + * The order of this array is the order the rows appear in. Use the `render` function on an + * attribute to customize how the value for each entity is displayed. + */ + attributes: ReadonlyArray; + + /** + * The entities being compared. Each entity is rendered as a **column** next to the sticky + * attribute column. + * + * Every entity exposes its per-attribute values through the `data` map, keyed by attribute `id`. + */ + entities: ReadonlyArray; + + /** + * Header text rendered above the sticky attribute (first) column. + * Defaults to an empty string. + */ + attributeColumnHeader?: string; + + /** + * Whether the attribute (first) column is pinned to the inline-start edge while the entity + * columns scroll horizontally. Defaults to `true`. + */ + stickyAttributeColumn?: boolean; + + /** + * When `true`, attribute rows whose values are not identical across all entities are visually + * emphasized so differences stand out. + * + * Equality is determined with `Object.is` against the raw `entity.data[attribute.id]` values. + * Rows with a custom `render` are still compared using their raw `data` values, so keep the + * `data` values comparable (primitives) when relying on this feature. + */ + highlightDifferences?: boolean; + + /** + * Provides an accessible name for the underlying table, announced by assistive technology. + */ + ariaLabel?: string; + + /** + * The visual variant of the comparison table. + * + * - `"container"` (default) — renders inside a bordered container. + * - `"embedded"` — removes the outer container border for embedding in another surface. + * - `"borderless"` — removes borders entirely. + * - `"stacked"` — for use in a vertically stacked set of containers. + */ + variant?: ComparisonTableProps.Variant; +} + +export namespace ComparisonTableProps { + export type Variant = 'container' | 'embedded' | 'borderless' | 'stacked'; + + export interface Attribute { + /** + * Unique identifier for the attribute. Used to look up each entity's value via + * `entity.data[id]` and as the React key for the row. + */ + id: string; + + /** + * The label rendered in the sticky attribute column for this row. + */ + label: React.ReactNode; + + /** + * Optional custom renderer for an entity's value for this attribute. Receives the raw value + * (`entity.data[id]`) and the full entity. When omitted, the raw value is rendered as-is. + */ + render?: (value: React.ReactNode, entity: ComparisonTableProps.Entity) => React.ReactNode; + } + + export interface Entity { + /** + * Unique identifier for the entity. Used as the column id and React key. + */ + id: string; + + /** + * The column header for this entity (for example, the resource name). + */ + title: React.ReactNode; + + /** + * The entity's values, keyed by attribute `id`. Missing keys render as a placeholder. + */ + data: Record; + } +} diff --git a/src/comparison-table/internal.tsx b/src/comparison-table/internal.tsx new file mode 100644 index 0000000000..c13f3423f2 --- /dev/null +++ b/src/comparison-table/internal.tsx @@ -0,0 +1,103 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import { getBaseProps } from '../internal/base-component'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { TableProps } from '../table/interfaces'; +import InternalTable from '../table/internal'; +import { ComparisonTableProps } from './interfaces'; + +import styles from './styles.css.js'; + +type InternalComparisonTableProps = ComparisonTableProps & InternalBaseComponentProps; + +const ATTRIBUTE_COLUMN_ID = 'awsui-comparison-table-attribute'; + +// Computes the set of attribute ids whose values differ across the compared entities. +// Comparison uses `Object.is` against the raw per-entity value, so it is reliable for +// primitive values. Deep object comparison is intentionally out of scope for v0. +function getDifferingAttributeIds( + attributes: ReadonlyArray, + entities: ReadonlyArray +): Set { + const differing = new Set(); + if (entities.length < 2) { + return differing; + } + for (const attribute of attributes) { + const [firstEntity, ...restEntities] = entities; + const firstValue = firstEntity.data?.[attribute.id]; + const allEqual = restEntities.every(entity => Object.is(entity.data?.[attribute.id], firstValue)); + if (!allEqual) { + differing.add(attribute.id); + } + } + return differing; +} + +export default function InternalComparisonTable({ + attributes, + entities, + attributeColumnHeader = '', + stickyAttributeColumn = true, + highlightDifferences = false, + ariaLabel, + variant = 'container', + __internalRootRef, + ...rest +}: InternalComparisonTableProps) { + const baseProps = getBaseProps(rest); + + const differingAttributeIds = highlightDifferences + ? getDifferingAttributeIds(attributes, entities) + : new Set(); + + const columnDefinitions: ReadonlyArray> = [ + { + id: ATTRIBUTE_COLUMN_ID, + header: attributeColumnHeader, + isRowHeader: true, + cell: attribute => {attribute.label}, + }, + ...entities.map( + (entity): TableProps.ColumnDefinition => ({ + id: entity.id, + header: entity.title, + cell: attribute => { + const rawValue = entity.data?.[attribute.id]; + const value = attribute.render ? attribute.render(rawValue, entity) : rawValue; + const isDifferent = differingAttributeIds.has(attribute.id); + return ( + + {value === undefined || value === null ? '-' : value} + + ); + }, + }) + ), + ]; + + // `__internalRootRef` is spread (not written inline) so it bypasses the excess-property check + // against the public TableProps, while still landing on the underlying table root. This carries + // the ComparisonTable base-component metadata onto the rendered root element. + const internalBaseProps: InternalBaseComponentProps = { __internalRootRef }; + + const tableProps: Parameters>[0] = { + ...baseProps, + ...internalBaseProps, + className: clsx(baseProps.className, styles.root), + variant, + items: [...attributes], + selectedItems: [], + firstIndex: 1, + cellVerticalAlign: 'top', + columnDefinitions, + trackBy: 'id', + stickyColumns: stickyAttributeColumn ? { first: 1 } : undefined, + ariaLabels: ariaLabel ? { tableLabel: ariaLabel } : undefined, + }; + + return ; +} diff --git a/src/comparison-table/styles.scss b/src/comparison-table/styles.scss new file mode 100644 index 0000000000..5c9c73b33a --- /dev/null +++ b/src/comparison-table/styles.scss @@ -0,0 +1,36 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../internal/styles' as styles; +@use '../internal/styles/tokens' as awsui; + +.root { + // Marker class applied to the underlying Table root so test-utils and consumers can target + // the comparison table specifically. Visual styling is inherited from Table. + inline-size: 100%; +} + +.attribute-label { + font-weight: styles.$font-weight-bold; + color: awsui.$color-text-body-default; +} + +.cell { + color: awsui.$color-text-body-default; +} + +// Emphasis applied to a cell when its attribute row differs across entities. +.cell-highlighted { + display: inline-block; + padding-block: awsui.$space-xxxs; + padding-inline: awsui.$space-xxs; + border-start-start-radius: awsui.$border-radius-badge; + border-start-end-radius: awsui.$border-radius-badge; + border-end-start-radius: awsui.$border-radius-badge; + border-end-end-radius: awsui.$border-radius-badge; + background-color: awsui.$color-background-status-warning; + color: awsui.$color-text-status-warning; + font-weight: styles.$font-weight-bold; +} diff --git a/src/test-utils/dom/comparison-table/index.ts b/src/test-utils/dom/comparison-table/index.ts new file mode 100644 index 0000000000..a4a6076653 --- /dev/null +++ b/src/test-utils/dom/comparison-table/index.ts @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import styles from '../../../comparison-table/styles.selectors.js'; +import tableStyles from '../../../table/styles.selectors.js'; + +export default class ComparisonTableWrapper extends ComponentWrapper { + static rootSelector: string = styles.root; + + /** Returns all attribute (row header) label elements. */ + findAttributeLabels(): Array { + return this.findAllByClassName(styles['attribute-label']); + } + + /** Returns all cells that are highlighted because their attribute row differs across entities. */ + findHighlightedCells(): Array { + return this.findAllByClassName(styles['cell-highlighted']); + } + + /** Returns all rendered rows of the underlying table (one per attribute). */ + findRows(): Array { + return this.findAllByClassName(tableStyles.row); + } +}