Skip to content
Draft
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
2 changes: 2 additions & 0 deletions build-tools/utils/custom-css-properties.js
Original file line number Diff line number Diff line change
Expand Up @@ -207,5 +207,7 @@ const customCssPropertiesList = [
// Resolved Style API values for calc(): postcss-calc 7 fails on deeply nested var() inside calc().
'containerBorderRadius',
'popoverArrowBorderWidth',
// Table style API, read by the container header of the table
'tableStickyHeaderShadow',
];
module.exports = customCssPropertiesList;
214 changes: 214 additions & 0 deletions pages/table/style-api-v2.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useContext, useState } from 'react';
import clsx from 'clsx';

import { Checkbox, FormField, Header, Select, SpaceBetween, Table, TableProps } from '~components';
import { StyleClassNames } from '~components/table/style-api';

import AppContext, { AppContextType } from '../app/app-context';
import { SimplePage } from '../app/templates';
import { generateItems, Instance, InstanceState } from './generate-data';
import { columnsConfig } from './shared-configs';

import styles from './style-api-v2.scss';

interface Settings {
selectionType: 'none' | TableProps.SelectionType;
stickyColumnsFirst: string;
stickyColumnsLast: string;
statusHighlight: 'none' | 'cells' | 'rows';
nestedRowsHighlight: boolean;
}

type PageContext = React.Context<AppContextType<Partial<Settings>>>;

const allItems = generateItems(30);
const rootItems = allItems.slice(0, 10);
// Four root items have five children each; the first one is expanded by default.
const parentItems = [0, 1, 3, 5].map(index => rootItems[index]);
const itemChildren = new Map(
parentItems.map((item, index) => [item, allItems.slice(10 + index * 5, 15 + index * 5)] as const)
);
const nestedItems = new Set([...itemChildren.values()].flat());

const columnDefinitions = columnsConfig.map(column => ({ ...column, minWidth: 200 }));
const instanceColumns = ['id', 'type', 'state'];
const networkColumns = ['dnsName', 'imageId'];
const columnGroups = new Map([
...instanceColumns.map(id => [id, 'instance'] as const),
...networkColumns.map(id => [id, 'network'] as const),
]);
const groupDefinitions: TableProps.GroupDefinition<Instance>[] = [
{ id: 'instance', header: 'Instance' },
{ id: 'network', header: 'Network' },
];
const columnDisplay: TableProps.ColumnDisplayProperties[] = [
{
type: 'group',
id: 'instance',
visible: true,
children: instanceColumns.map(id => ({ id, visible: true })),
},
{
type: 'group',
id: 'network',
visible: true,
children: networkColumns.map(id => ({ id, visible: true })),
},
];

const ariaLabels: TableProps.AriaLabels<Instance> = {
selectionGroupLabel: 'Items selection',
allItemsSelectionLabel: () => 'Select all',
itemSelectionLabel: (_, item) => `Select ${item.id}`,
expandButtonLabel: () => 'Expand',
collapseButtonLabel: () => 'Collapse',
};

const selectionTypeOptions = ['none', 'single', 'multi'].map(value => ({ value }));
const stickyColumnsOptions = ['0', '1', '2'].map(value => ({ value }));
const statusHighlightOptions = ['none', 'cells', 'rows'].map(value => ({ value }));

const statuses: Partial<Record<InstanceState, string>> = {
RUNNING: 'running',
STOPPING: 'transitioning',
STOPPED: 'stopped',
TERMINATING: 'transitioning',
TERMINATED: 'terminated',
};

export default function () {
const {
urlParams: {
selectionType = 'multi',
stickyColumnsFirst = '1',
stickyColumnsLast = '0',
statusHighlight = 'cells',
nestedRowsHighlight = true,
},
setUrlParams,
} = useContext(AppContext as PageContext);

const [selectedItems, setSelectedItems] = useState<Instance[]>([rootItems[2], rootItems[4]]);
const [expandedItems, setExpandedItems] = useState<Instance[]>([parentItems[0]]);
const [sortingColumn, setSortingColumn] = useState<TableProps.SortingColumn<Instance>>(columnDefinitions[1]);
const [sortingDescending, setSortingDescending] = useState(false);

const sortingField = sortingColumn.sortingField as keyof Instance;
const items = [...rootItems].sort(
(a, b) => String(a[sortingField]).localeCompare(String(b[sortingField])) * (sortingDescending ? -1 : 1)
);

const booleanSetting = (key: keyof Settings, label: string, checked: boolean) => (
<Checkbox checked={checked} onChange={({ detail }) => setUrlParams({ [key]: detail.checked })}>
{label}
</Checkbox>
);
const selectSetting = (key: keyof Settings, label: string, value: string, options: { value: string }[]) => (
<FormField label={label}>
<Select
selectedOption={{ value }}
options={options}
onChange={({ detail }) => setUrlParams({ [key]: detail.selectedOption.value })}
ariaLabel={label}
/>
</FormField>
);

const statusClassName = (item: Instance, slot: 'row' | 'cell') =>
statuses[item.state] && styles[`status-${statuses[item.state]}-${slot}`];

const isItemDisabled = (item: Instance) => item.state === 'TERMINATING' || item.state === 'STOPPING';
// Mirrors the table: no selection without a selection type, and only the first item in single selection.
const isItemSelected = (item: Instance) =>
selectionType !== 'none' && (selectionType === 'single' ? selectedItems[0] === item : selectedItems.includes(item));

// Selected rows keep the selection color: status and nested highlights skip them.
const styleClassNames: StyleClassNames<Instance> = {
root: styles.table,
headerCell: args => {
if (args.type === 'selection') {
return clsx(styles['header-cell'], selectedItems.length > 0 && styles['select-all-selected']);
}
const groupId = args.type === 'group' ? args.group.id : columnGroups.get(args.column.id ?? '');
return groupId ? styles[`${groupId}-header-cell`] : styles['header-cell'];
},
sortingIndicator: styles['sorting-indicator'],
row: ({ item, selected }) =>
clsx(
styles.row,
isItemDisabled(item) && styles['row-disabled'],
selected
? styles['row-selected']
: (statusHighlight === 'rows' && statusClassName(item, 'row')) ||
(nestedRowsHighlight && nestedItems.has(item) && styles['row-nested'])
),
cell: args =>
clsx(
styles.cell,
statusHighlight === 'cells' &&
args.type === 'data' &&
args.column.id === 'state' &&
!isItemSelected(args.item) &&
statusClassName(args.item, 'cell')
),
selectionCheckbox: { control: styles.checkbox },
selectionRadio: { control: styles.radio },
};

return (
<SimplePage
title="Table - Style API v2"
screenshotArea={{}}
settings={
<SpaceBetween size="m">
<SpaceBetween size="m" direction="horizontal" alignItems="end">
{selectSetting('selectionType', 'Selection type', selectionType, selectionTypeOptions)}
{selectSetting('stickyColumnsFirst', 'Sticky columns first', stickyColumnsFirst, stickyColumnsOptions)}
{selectSetting('stickyColumnsLast', 'Sticky columns last', stickyColumnsLast, stickyColumnsOptions)}
{selectSetting('statusHighlight', 'Highlight status', statusHighlight, statusHighlightOptions)}
</SpaceBetween>
<SpaceBetween size="m" direction="horizontal">
{booleanSetting('nestedRowsHighlight', 'Highlight nested rows', nestedRowsHighlight)}
</SpaceBetween>
</SpaceBetween>
}
>
<Table
enableKeyboardNavigation={true}
header={<Header counter={`(${items.length})`}>Instances</Header>}
variant="embedded"
items={items}
columnDefinitions={columnDefinitions}
groupDefinitions={groupDefinitions}
columnDisplay={columnDisplay}
trackBy="id"
ariaLabels={ariaLabels}
resizableColumns={true}
stickyHeader={true}
stickyColumns={{ first: +stickyColumnsFirst, last: +stickyColumnsLast }}
selectionType={selectionType === 'none' ? undefined : selectionType}
selectedItems={selectedItems}
onSelectionChange={({ detail }) => setSelectedItems(detail.selectedItems)}
isItemDisabled={isItemDisabled}
sortingColumn={sortingColumn}
sortingDescending={sortingDescending}
onSortingChange={({ detail }) => {
setSortingColumn(detail.sortingColumn);
setSortingDescending(!!detail.isDescending);
}}
expandableRows={{
getItemChildren: item => itemChildren.get(item) ?? [],
isItemExpandable: item => itemChildren.has(item),
expandedItems,
onExpandableItemToggle: ({ detail }) =>
setExpandedItems(prev =>
detail.expanded ? [...prev, detail.item] : prev.filter(item => item !== detail.item)
),
}}
{...{ styleClassNames }}
/>
</SimplePage>
);
}
128 changes: 128 additions & 0 deletions pages/table/style-api-v2.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
SPDX-License-Identifier: Apache-2.0
*/

@use '~components/styling/checkbox';
@use '~components/styling/radio-button';
@use '~components/styling/table';
@use '../style-api/palette' as palette;

$selected-surface: light-dark(hsl(265, 100%, 98.5%), hsl(265, 40%, 13%));
$instance-surface: light-dark(hsl(45, 90%, 88%), hsl(45, 60%, 18%));
$instance-text: light-dark(hsl(35, 80%, 20%), hsl(45, 90%, 85%));
$network-surface: light-dark(hsl(200, 60%, 93%), hsl(200, 45%, 18%));
$network-text: light-dark(hsl(200, 80%, 22%), hsl(200, 70%, 88%));
$nested-surface: light-dark(hsl(220, 30%, 96.5%), hsl(220, 20%, 18%));
$transitioning-surface: light-dark(hsl(0, 0%, 96.5%), hsl(0, 0%, 21%));

.table {
@include table.root(
$selected-row-outline-color: palette.$genai,
$selected-row-outline-radius: 4px,
$sticky-header-shadow: (
0 1px 0 0 palette.$text,
0 16px 16px -12px rgba(0, 7, 22, 0.1),
)
);
}

// The color also applies to hovered and sorted cells.
@mixin header-cell($background, $color) {
@include table.headerCell(
$background: $background,
$color: $color,
$font-weight: 800,
$letter-spacing: 0.02em,
$border-color: $color,
$divider-color: $color,
$focus-ring-color: palette.$genai,
$focus-ring-width: 3px,
$focus-ring-radius: 6px
);
}

.cell {
@include table.cell($focus-ring-color: palette.$genai, $focus-ring-width: 3px, $focus-ring-radius: 6px);
}

.header-cell {
@include header-cell(palette.$subtle, $instance-text);
}

.instance-header-cell {
@include header-cell($instance-surface, $instance-text);
}

.network-header-cell {
@include header-cell($network-surface, $network-text);
}

.sorting-indicator {
@include table.sortingIndicator($color: palette.$genai);
}

.row {
@include table.row($divider-color: palette.$border);
}

.row-selected {
@include table.row($background-color: $selected-surface);
}

.row-nested {
@include table.row($background-color: $nested-surface);
}

@each $status,
$surface
in (
running: palette.$success-surface,
transitioning: $transitioning-surface,
stopped: palette.$warning-surface,
terminated: palette.$error-surface
)
{
.status-#{$status}-row {
@include table.row($background-color: $surface);
}

.status-#{$status}-cell {
@include table.cell($background-color: $surface);
}
}

// Control looks come from the row and selection header cell classes, as a consumer would write them.
/* stylelint-disable selector-combinator-disallowed-list */
.checkbox {
@include checkbox.control($border-color: palette.$genai, $focus-ring-color: palette.$genai);
}

.row-selected .checkbox,
.select-all-selected .checkbox {
@include checkbox.control(
$fill-color: palette.$genai,
$border-color: palette.$genai,
$indicator-color: palette.$genai-surface
);
}

.row-disabled .checkbox {
@include checkbox.control($fill-color: palette.$control-bg-disabled, $border-color: palette.$border-disabled);
}

.radio {
@include radio-button.control($border-color: palette.$genai, $focus-ring-color: palette.$genai);
}

.row-selected .radio {
@include radio-button.control(
$fill-color: palette.$genai,
$border-color: palette.$genai,
$indicator-color: palette.$genai-surface
);
}

.row-disabled .radio {
@include radio-button.control($fill-color: palette.$control-bg-disabled, $border-color: palette.$border-disabled);
}
Loading
Loading