diff --git a/package.json b/package.json index b517cbd933..5afdf9ef86 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "start:integ": "cross-env NODE_ENV=development webpack serve --config pages/webpack.config.integ.cjs", "start:react18": "npm-run-all --parallel start:watch start:react18:dev", "start:react18:dev": "cross-env NODE_ENV=development REACT_VERSION=18 webpack serve --config pages/webpack.config.cjs", - "postinstall": "prepare-package-lock", + "postinstall": "prepare-package-lock && node ./scripts/install-peer-dependency.js collection-hooks:feat-table-selection-demo", "prepare": "husky" }, "dependencies": { diff --git a/pages/table/selection-controller-data.ts b/pages/table/selection-controller-data.ts new file mode 100644 index 0000000000..13c00a3af4 --- /dev/null +++ b/pages/table/selection-controller-data.ts @@ -0,0 +1,170 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export type FunctionRuntime = 'Node.js 22.x' | 'Node.js 20.x' | 'Python 3.13' | 'Python 3.9' | 'Java 21' | 'Go 1.x'; +export type PackageType = 'Zip' | 'Image'; +export type FunctionType = 'Standard' | 'Edge'; + +export interface LambdaFunction { + name: string; + description: string; + packageType: PackageType; + runtime: FunctionRuntime; + type: FunctionType; + lastModified: string; +} + +export const allFunctions: LambdaFunction[] = [ + { + name: 'PipelineWebsiteFallbackde-CustomCrossRegionExportW-X6JwH8BLHWUF', + description: '-', + packageType: 'Zip', + runtime: 'Node.js 22.x', + type: 'Standard', + lastModified: '59 minutes ago', + }, + { + name: 'PipelineCloudformationLog-CustomCrossRegionExportR-ZdYplOmIB5oy', + description: '-', + packageType: 'Zip', + runtime: 'Node.js 22.x', + type: 'Standard', + lastModified: '55 minutes ago', + }, + { + name: 'PipelineCertsdevrefreshDE-CustomCrossRegionExportW-HbANvB1Wu6wq', + description: '-', + packageType: 'Zip', + runtime: 'Node.js 22.x', + type: 'Standard', + lastModified: '60 minutes ago', + }, + { + name: 'PipelineCertsdevcoreCE42E-CustomCrossRegionExportW-PsS8Wpw9X38p', + description: '-', + packageType: 'Zip', + runtime: 'Node.js 22.x', + type: 'Standard', + lastModified: '59 minutes ago', + }, + { + name: 'PipelineWebsiteFallbackde-CustomCrossRegionExportW-ZFfCIzbCgpEx', + description: '-', + packageType: 'Zip', + runtime: 'Node.js 22.x', + type: 'Standard', + lastModified: '59 minutes ago', + }, + { + name: 'PipelineCloudformationLog-CustomCrossRegionExportR-l0V0g3Gws3fl', + description: '-', + packageType: 'Zip', + runtime: 'Node.js 22.x', + type: 'Standard', + lastModified: '51 minutes ago', + }, + { + name: 'PipelineCertsdevexternal1-CustomCrossRegionExportW-E7fpgzyT9l4V', + description: '-', + packageType: 'Zip', + runtime: 'Node.js 22.x', + type: 'Standard', + lastModified: '1 hour ago', + }, + { + name: 'PipelineCloudformationLog-CustomCrossRegionExportR-n8HdHqjDgbN7', + description: '-', + packageType: 'Zip', + runtime: 'Node.js 22.x', + type: 'Standard', + lastModified: '52 minutes ago', + }, + { + name: 'PipelineWebsiteFallbackde-CustomCrossRegionExportW-H06gmzu73vqO', + description: '-', + packageType: 'Zip', + runtime: 'Node.js 22.x', + type: 'Standard', + lastModified: '1 hour ago', + }, + { + name: 'DevScreenshotTestingSite-ScreenshotTestingSiteGene-7ULlVLcCWla4', + description: 'Copies the static resources from one spot to another', + packageType: 'Zip', + runtime: 'Python 3.9', + type: 'Standard', + lastModified: '4 hours ago', + }, + { + name: 'PipelineWebsiteFallbackde-CustomCDKBucketDeploymen-onuB2t8k3yAI', + description: '-', + packageType: 'Zip', + runtime: 'Python 3.13', + type: 'Standard', + lastModified: '59 minutes ago', + }, + { + name: 'PipelineWebsiteFallbackde-CustomCDKBucketDeploymen-1oAvAlypJFhu', + description: '-', + packageType: 'Zip', + runtime: 'Python 3.13', + type: 'Standard', + lastModified: '1 hour ago', + }, + { + name: 'AuthServiceStack-UserPoolTriggerHandler-a9Bx2kLm', + description: 'Handles Cognito user pool triggers', + packageType: 'Zip', + runtime: 'Node.js 20.x', + type: 'Standard', + lastModified: '2 hours ago', + }, + { + name: 'DataProcessingPipeline-TransformFunction-Qw3rTy8z', + description: 'Transforms incoming data records', + packageType: 'Zip', + runtime: 'Java 21', + type: 'Standard', + lastModified: '3 hours ago', + }, + { + name: 'ApiGatewayStack-AuthorizerFunction-Mn4pLk9x', + description: 'Custom API Gateway authorizer', + packageType: 'Zip', + runtime: 'Node.js 22.x', + type: 'Edge', + lastModified: '5 hours ago', + }, + { + name: 'MonitoringStack-AlarmHandlerFunction-Yz7wVb2c', + description: 'Processes CloudWatch alarm notifications', + packageType: 'Zip', + runtime: 'Python 3.13', + type: 'Standard', + lastModified: '1 day ago', + }, + { + name: 'ImageProcessingStack-ThumbnailGenerator-Hj6kRt5n', + description: 'Generates thumbnails for uploaded images', + packageType: 'Image', + runtime: 'Python 3.13', + type: 'Standard', + lastModified: '2 days ago', + }, + { + name: 'NotificationService-EmailSenderFunction-Wx8mNp3q', + description: 'Sends transactional email notifications', + packageType: 'Zip', + runtime: 'Node.js 20.x', + type: 'Standard', + lastModified: '6 hours ago', + }, + { + name: 'ScheduledTasksStack-DailyCleanupFunction-Bc4vFg7j', + description: 'Runs daily cleanup of expired resources', + packageType: 'Zip', + runtime: 'Go 1.x', + type: 'Standard', + lastModified: '12 hours ago', + }, +]; diff --git a/pages/table/selection-controller.page.tsx b/pages/table/selection-controller.page.tsx new file mode 100644 index 0000000000..c2ac0a27bc --- /dev/null +++ b/pages/table/selection-controller.page.tsx @@ -0,0 +1,296 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { useCollection } from '@cloudscape-design/collection-hooks'; + +import Box from '~components/box'; +import Button from '~components/button'; +import ButtonDropdown from '~components/button-dropdown'; +import CollectionPreferences, { CollectionPreferencesProps } from '~components/collection-preferences'; +import Header from '~components/header'; +import Link from '~components/link'; +import Pagination from '~components/pagination'; +import SpaceBetween from '~components/space-between'; +import Table, { TableProps } from '~components/table'; +import TextFilter from '~components/text-filter'; + +import { contentDisplayPreferenceI18nStrings } from '../common/i18n-strings'; +import ScreenshotArea from '../utils/screenshot-area'; +import { allFunctions, LambdaFunction } from './selection-controller-data'; +import { paginationLabels } from './shared-configs'; + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { + id: 'name', + header: 'Function name', + cell: item => {item.name}, + sortingField: 'name', + }, + + { id: 'description', header: 'Description', cell: item => item.description, sortingField: 'description' }, + { id: 'packageType', header: 'Package type', cell: item => item.packageType, sortingField: 'packageType' }, + { id: 'runtime', header: 'Runtime', cell: item => item.runtime, sortingField: 'runtime' }, + { id: 'type', header: 'Type', cell: item => item.type, sortingField: 'type' }, + { id: 'lastModified', header: 'Last modified', cell: item => item.lastModified, sortingField: 'lastModified' }, +]; + +const contentDisplayPreference = { + title: 'Column preferences', + description: 'Customize the columns visibility and order.', + options: [ + { id: 'name', label: 'Function name', alwaysVisible: true }, + { id: 'description', label: 'Description' }, + { id: 'packageType', label: 'Package type' }, + { id: 'runtime', label: 'Runtime' }, + { id: 'type', label: 'Type' }, + { id: 'lastModified', label: 'Last modified' }, + ], + ...contentDisplayPreferenceI18nStrings, +}; + +const defaultPreferences: CollectionPreferencesProps.Preferences = { + pageSize: 10, + contentDisplay: [ + { id: 'name', visible: true }, + { id: 'description', visible: true }, + { id: 'packageType', visible: true }, + { id: 'runtime', visible: true }, + { id: 'type', visible: true }, + { id: 'lastModified', visible: true }, + ], + wrapLines: false, +}; + +const predicates: Record boolean> = { + nodejs: f => f.runtime.startsWith('Node.js'), + python: f => f.runtime.startsWith('Python'), + java: f => f.runtime.startsWith('Java'), + go: f => f.runtime.startsWith('Go'), + zip: f => f.packageType === 'Zip', + image: f => f.packageType === 'Image', +}; + +function EmptyState({ action }: { action: React.ReactNode }) { + return ( + + + No functions + + + No functions to display. + + {action} + + ); +} + +function NoMatchState({ onClearFilter }: { onClearFilter: () => void }) { + return ( + + + No matches + + + We can't find a match. + + + + ); +} + +export default function SelectionControllerPage() { + return ( + + + + ); +} + +function FunctionsTable() { + const [preferences, setPreferences] = useState(defaultPreferences); + + const { items, actions, filteredItemsCount, collectionProps, filterProps, paginationProps } = useCollection( + allFunctions, + { + filtering: { + empty: Create function} />, + noMatch: actions.setFiltering('')} />, + }, + pagination: { pageSize: preferences.pageSize }, + sorting: { defaultState: { sortingColumn: columnDefinitions[3], isDescending: false } }, + selection: { + trackBy: 'name', + selectionControllerItems: (visibleItems, selectedItems) => { + const allSelected = (pred: (f: LambdaFunction) => boolean) => { + const m = visibleItems.filter(pred); + return m.length > 0 && m.every(f => selectedItems.some(s => s.name === f.name)); + }; + const has = (pred: (f: LambdaFunction) => boolean) => visibleItems.some(pred); + return [ + { + text: 'By runtime (current page)', + items: [ + { + id: 'nodejs', + text: 'Node.js', + itemType: 'checkbox' as const, + checked: allSelected(predicates.nodejs), + disabled: !has(predicates.nodejs), + }, + { + id: 'python', + text: 'Python', + itemType: 'checkbox' as const, + checked: allSelected(predicates.python), + disabled: !has(predicates.python), + }, + { + id: 'java', + text: 'Java', + itemType: 'checkbox' as const, + checked: allSelected(predicates.java), + disabled: !has(predicates.java), + }, + { + id: 'go', + text: 'Go', + itemType: 'checkbox' as const, + checked: allSelected(predicates.go), + disabled: !has(predicates.go), + }, + ], + }, + { + text: 'By runtime (across pages)', + items: [ + { id: 'all-nodejs', text: 'All Node.js' }, + { id: 'all-python', text: 'All Python' }, + ], + }, + { + text: 'By package type', + items: [ + { + id: 'zip', + text: 'Zip', + itemType: 'checkbox' as const, + checked: allSelected(predicates.zip), + disabled: !has(predicates.zip), + }, + { + id: 'image', + text: 'Image', + itemType: 'checkbox' as const, + checked: allSelected(predicates.image), + disabled: !has(predicates.image), + }, + ], + }, + ]; + }, + onSelectionControllerItemClick: (detail, visibleItems, hookActions, allItems) => { + // "Across pages" items — select from allItems + const allPagePredicates: Record boolean> = { + 'all-nodejs': predicates.nodejs, + 'all-python': predicates.python, + }; + const allPagePred = allPagePredicates[detail.id]; + if (allPagePred) { + hookActions.setSelectedItems((allItems as LambdaFunction[]).filter(allPagePred)); + return; + } + + // "Current page" checkbox items — toggle from visibleItems + const pred = predicates[detail.id]; + if (!pred) { + return; + } + const current = (collectionProps.selectedItems ?? []) as LambdaFunction[]; + const matching = visibleItems.filter(pred) as LambdaFunction[]; + if (detail.checked) { + const existing = new Set(current.map(s => s.name)); + hookActions.setSelectedItems([...current, ...matching.filter(m => !existing.has(m.name))]); + } else { + hookActions.setSelectedItems(current.filter(s => !matching.some(m => m.name === s.name))); + } + }, + }, + } + ); + + const selectedItems = collectionProps.selectedItems ?? []; + const singleSelected = selectedItems.length === 1; + const hasSelection = selectedItems.length > 0; + + return ( + + {...collectionProps} + header={ +
+ {}} + disabled={!hasSelection} + > + Actions + + + + } + > + Functions +
+ } + columnDefinitions={columnDefinitions} + items={items} + stickyHeader={true} + ariaLabels={{ + selectionGroupLabel: 'Function selection', + allItemsSelectionLabel: ({ selectedItems }) => + `${selectedItems.length} ${selectedItems.length === 1 ? 'function' : 'functions'} selected`, + itemSelectionLabel: ({ selectedItems }, item) => + `${item.name} is ${selectedItems.indexOf(item) < 0 ? 'not ' : ''}selected`, + tableLabel: 'Functions', + selectionControllerLabel: 'Function selection options', + }} + selectionType="multi" + pagination={} + filter={ + + } + columnDisplay={preferences.contentDisplay} + preferences={ + setPreferences(detail)} + preferences={preferences} + pageSizePreference={{ + title: 'Select page size', + options: [ + { value: 10, label: '10 Functions' }, + { value: 20, label: '20 Functions' }, + { value: 50, label: '50 Functions' }, + ], + }} + contentDisplayPreference={{ ...contentDisplayPreference, ...contentDisplayPreferenceI18nStrings }} + wrapLinesPreference={{ label: 'Wrap lines', description: 'Wrap lines description' }} + /> + } + /> + ); +} diff --git a/scripts/install-peer-dependency.js b/scripts/install-peer-dependency.js new file mode 100644 index 0000000000..c0f614b2ca --- /dev/null +++ b/scripts/install-peer-dependency.js @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Can be used in postinstall script like so: +// "postinstall": "node ./scripts/install-peer-dependency.js collection-hooks:property-filter-token-groups" +// where "collection-hooks" is the package to fetch and "property-filter-token-groups" is the branch name in GitHub. + +import { execSync } from 'child_process'; +import process from 'node:process'; +import os from 'os'; +import path from 'path'; + +const getModules = packageName => { + switch (packageName) { + case 'components': + return ['components', 'design-tokens', 'collection-hooks']; + case 'theming-core': + return ['theming-build', 'theming-runtime']; + case 'test-utils': + return ['test-utils-core', 'test-utils-converter']; + default: + return [packageName]; + } +}; + +const getArtifactPath = moduleName => { + switch (moduleName) { + case 'components': + return '/lib/components/*'; + case 'design-tokens': + return '/lib/design-tokens/*'; + case 'board-components': + return '/lib/components/*'; + case 'theming-build': + return '/lib/node/*'; + case 'theming-runtime': + return '/lib/browser/*'; + case 'test-utils-core': + return '/lib/core/*'; + case 'test-utils-converter': + return '/lib/converter/*'; + default: + return '/lib/*'; + } +}; + +const args = process.argv.slice(2); +if (args.length < 1) { + console.error('Usage: install-peer-dependency.js :'); + process.exit(1); +} +const [packageName, targetBranch] = args[0].split(':'); +const targetRepository = `https://github.com/cloudscape-design/${packageName}.git`; +const nodeModulesPath = path.join(process.cwd(), 'node_modules', '@cloudscape-design'); +const tempDir = path.join(os.tmpdir(), `temp-${packageName}`); + +// Clone the repository and checkout the branch +console.log(`Cloning ${packageName}:${targetBranch}...`); +execCommand(`rm -rf ${tempDir}`); +execCommand(`git clone ${targetRepository} ${tempDir}`); +process.chdir(tempDir); +execCommand(`git checkout ${targetBranch}`); + +// Install dependencies and build +console.log(`Installing dependencies and building ${packageName}...`); +execCommand('npm install'); +execCommand('npm run build'); + +// Remove existing peer dependency in node_modules +for (const moduleName of getModules(packageName)) { + const modulePath = path.join(nodeModulesPath, moduleName); + const artifactPath = getArtifactPath(moduleName); + + console.log(`Removing existing ${moduleName} from node_modules...`, modulePath); + execCommand(`rm -rf ${modulePath}`); + + // Copy built peer dependency to node_modules + console.log(`Copying built ${moduleName} to node_modules...`, modulePath, `${tempDir}${artifactPath}`); + execCommand(`mkdir -p ${modulePath}`); + execCommand(`cp -R ${tempDir}${artifactPath} ${modulePath}`); +} + +// Clean up +console.log('Cleaning up...'); +execCommand(`rm -rf ${tempDir}`); + +console.log(`${packageName} has been successfully installed from branch ${targetBranch}!`); + +function execCommand(command, options = {}) { + try { + execSync(command, { stdio: 'inherit', ...options }); + } catch (error) { + console.error(`Error executing command: ${command}`); + console.error(`Error message: ${error.message}`); + console.error(`Stdout: ${error.stdout && error.stdout.toString()}`); + console.error(`Stderr: ${error.stderr && error.stderr.toString()}`); + throw error; + } +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index f0cd3d90cf..a98fba3d35 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -26651,6 +26651,36 @@ The event \`detail\` contains the new state for \`selectedItems\`.", "detailType": "TableProps.SelectionChangeDetail", "name": "onSelectionChange", }, + { + "cancelable": false, + "description": "Called when a user clicks a selection controller item in the dropdown. + +The event \`detail\` contains the \`id\` of the clicked item. For checkbox items, +it also includes the new \`checked\` state after the toggle. + +The table does not automatically change \`selectedItems\` when a selection +controller item is clicked. Use this event handler to implement your own +selection logic (for example, selecting all items that match a filter condition) +and update \`selectedItems\` accordingly.", + "detailInlineType": { + "name": "TableProps.SelectionControllerItemClickDetail", + "properties": [ + { + "name": "checked", + "optional": true, + "type": "boolean", + }, + { + "name": "id", + "optional": false, + "type": "string", + }, + ], + "type": "object", + }, + "detailType": "TableProps.SelectionControllerItemClickDetail", + "name": "onSelectionControllerItemClick", + }, { "cancelable": false, "description": "Called when either the column to sort by or the direction of sorting changes upon user interaction. @@ -26930,6 +26960,11 @@ in tables with data grouping. "optional": true, "type": "string", }, + { + "name": "selectionControllerLabel", + "optional": true, + "type": "string", + }, { "name": "selectionGroupLabel", "optional": true, @@ -27473,6 +27508,40 @@ the table items array is empty.", "optional": true, "type": "ReadonlyArray", }, + { + "description": "Specifies the items displayed in the selection controller dropdown. + +The selection controller adds a small dropdown trigger next to the "select all" +checkbox in the table header. It provides quick access to predefined selection +actions, such as selecting items by attribute (for example, by status or type). + +Each entry in the array is either a \`SelectionControllerItem\` or a \`SelectionControllerItemGroup\`. + +SelectionControllerItem has the following properties: +* \`id\` (string) - A unique identifier for the selection action. +* \`text\` (string) - The display label for the selection action. +* \`itemType\` (optional, \`'checkbox'\`) - Set to \`'checkbox'\` to render the item as a toggleable checkbox. +* \`checked\` (optional, boolean) - Whether the checkbox item is checked. Only applicable when \`itemType\` is \`'checkbox'\`. +* \`disabled\` (optional, boolean) - When \`true\`, the item is rendered in a disabled state and cannot be activated. +* \`disabledReason\` (optional, string) - A reason displayed when hovering over a disabled item. +* \`secondaryText\` (optional, string) - Secondary descriptive text displayed below the item text. +* \`ariaLabel\` (optional, string) - An accessible label for the item. +* \`iconName\` (optional, string) - An icon name displayed before the item text. +* \`iconSvg\` (optional, ReactNode) - An icon SVG displayed before the item text. + +SelectionControllerItemGroup has the following properties: +* \`text\` (optional, string) - A display label for the group header. +* \`items\` (SelectionControllerItem[]) - The items within this group. +* \`disabled\` (optional, boolean) - When \`true\`, the group is rendered in a disabled state. + +The selection controller is only rendered when \`selectionType\` is \`"multi"\` and +this property contains at least one item. It is not rendered when \`selectionType\` +is \`"single"\`, when \`expandableRows\` with group selection is configured, or when +this property is \`undefined\` or an empty array.", + "name": "selectionControllerItems", + "optional": true, + "type": "ReadonlyArray", + }, { "description": "Specifies the selection type (\`'single' | 'multi'\`).", "inlineType": { @@ -27719,6 +27788,17 @@ multiple lines instead of being truncated with an ellipsis.", "isDefault": false, "name": "preferences", }, + { + "description": "Displays a notification banner between the table header and the table body. +Use this slot to render contextual messages related to the current selection state, +such as cross-page selection prompts (for example, "12 items selected on this page. +Select 85 items across the table.") or confirmation messages after selecting all items. + +The content is rendered inside the table container, below the filter and above the +column headers. It is only visible when this property is not \`undefined\`.", + "isDefault": false, + "name": "selectionNotification", + }, ], "releaseStatus": "stable", } diff --git a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap index 748e7c16f0..d98cd076ae 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/test-utils-wrappers.test.tsx.snap @@ -7335,4 +7335,4 @@ export default function wrapper(root: string = 'body') { return new ElementWrapper(root); } " -`; \ No newline at end of file +`; diff --git a/src/table/__tests__/selection-controller.test.tsx b/src/table/__tests__/selection-controller.test.tsx new file mode 100644 index 0000000000..93c8d67a53 --- /dev/null +++ b/src/table/__tests__/selection-controller.test.tsx @@ -0,0 +1,282 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import Table, { TableProps } from '../../../lib/components/table'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +interface Item { + id: number; + name: string; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { header: 'id', cell: item => item.id }, + { header: 'name', cell: item => item.name }, +]; + +const items: Item[] = [ + { id: 1, name: 'Apples' }, + { id: 2, name: 'Oranges' }, + { id: 3, name: 'Bananas' }, +]; + +const selectionControllerItems: TableProps['selectionControllerItems'] = [ + { id: 'all', text: 'All' }, + { id: 'none', text: 'None' }, + { id: 'with-desc', text: 'With description', secondaryText: 'A helpful description' }, + { id: 'disabled-item', text: 'Disabled', disabled: true }, +]; + +function renderTable(props: Partial) { + const allProps: TableProps = { + items, + columnDefinitions, + ...props, + }; + const { container } = render(); + const wrapper = createWrapper(container).findTable()!; + return { wrapper }; +} + +function findSelectionControllerDropdown(container: ReturnType['wrapper']) { + // The selection controller is an InternalButtonDropdown rendered inside the header selection cell + // Use the root element to find it via the ButtonDropdownWrapper selector + const rootElement = container.getElement().closest('body') ?? container.getElement(); + return createWrapper(rootElement as HTMLElement).findButtonDropdown(); +} + +describe('Selection Controller - Rendering conditions', () => { + test('renders selection controller when selectionType is multi and items are provided', () => { + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: jest.fn(), + selectionControllerItems, + }); + expect(findSelectionControllerDropdown(wrapper)).toBeTruthy(); + }); + + test('does not render selection controller when selectionType is single', () => { + const { wrapper } = renderTable({ + selectionType: 'single', + selectedItems: [], + onSelectionChange: jest.fn(), + selectionControllerItems, + }); + expect(findSelectionControllerDropdown(wrapper)).toBeFalsy(); + }); + + test('does not render selection controller when selectionControllerItems is undefined', () => { + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: jest.fn(), + }); + expect(findSelectionControllerDropdown(wrapper)).toBeFalsy(); + }); + + test('does not render selection controller when selectionControllerItems is empty', () => { + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: jest.fn(), + selectionControllerItems: [], + }); + expect(findSelectionControllerDropdown(wrapper)).toBeFalsy(); + }); + + test('does not render selection controller when selectionType is not set', () => { + const { wrapper } = renderTable({ + selectionControllerItems, + }); + expect(findSelectionControllerDropdown(wrapper)).toBeFalsy(); + }); +}); + +describe('Selection Controller - Dropdown menu', () => { + test('opens dropdown and shows items on click', () => { + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: jest.fn(), + selectionControllerItems, + }); + const dropdown = findSelectionControllerDropdown(wrapper)!; + dropdown.openDropdown(); + const menuItems = dropdown.findItems(); + expect(menuItems).toHaveLength(4); + }); + + test('displays item text in menu items', () => { + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: jest.fn(), + selectionControllerItems, + }); + const dropdown = findSelectionControllerDropdown(wrapper)!; + dropdown.openDropdown(); + const menuItems = dropdown.findItems(); + expect(menuItems[0].getElement().textContent).toContain('All'); + expect(menuItems[1].getElement().textContent).toContain('None'); + expect(menuItems[2].getElement().textContent).toContain('With description'); + }); + + test('displays item description as secondary text', () => { + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: jest.fn(), + selectionControllerItems, + }); + const dropdown = findSelectionControllerDropdown(wrapper)!; + dropdown.openDropdown(); + const menuItems = dropdown.findItems(); + expect(menuItems[2].getElement().textContent).toContain('A helpful description'); + }); + + test('fires onSelectionControllerItemClick with correct id on item click', () => { + const onItemClick = jest.fn(); + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: jest.fn(), + selectionControllerItems, + onSelectionControllerItemClick: onItemClick, + }); + const dropdown = findSelectionControllerDropdown(wrapper)!; + dropdown.openDropdown(); + dropdown.findItemById('all')!.click(); + expect(onItemClick).toHaveBeenCalledTimes(1); + expect(onItemClick).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ id: 'all' }) }) + ); + }); + + test('fires onSelectionControllerItemClick with correct id for different items', () => { + const onItemClick = jest.fn(); + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: jest.fn(), + selectionControllerItems, + onSelectionControllerItemClick: onItemClick, + }); + const dropdown = findSelectionControllerDropdown(wrapper)!; + dropdown.openDropdown(); + dropdown.findItemById('none')!.click(); + expect(onItemClick).toHaveBeenCalledWith( + expect.objectContaining({ detail: expect.objectContaining({ id: 'none' }) }) + ); + }); +}); + +describe('Selection Controller - Disabled items', () => { + test('does not fire onSelectionControllerItemClick for disabled items', () => { + const onItemClick = jest.fn(); + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: jest.fn(), + selectionControllerItems, + onSelectionControllerItemClick: onItemClick, + }); + const dropdown = findSelectionControllerDropdown(wrapper)!; + dropdown.openDropdown(); + const disabledItem = dropdown.findItemById('disabled-item'); + expect(disabledItem).toBeTruthy(); + disabledItem!.click(); + expect(onItemClick).not.toHaveBeenCalled(); + }); + + test('renders disabled items in disabled state', () => { + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: jest.fn(), + selectionControllerItems, + }); + const dropdown = findSelectionControllerDropdown(wrapper)!; + dropdown.openDropdown(); + const disabledItems = dropdown.findItems({ disabled: true }); + expect(disabledItems).toHaveLength(1); + }); +}); + +describe('Selection Controller - Loading state', () => { + test('disables trigger when loading is true', () => { + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: jest.fn(), + selectionControllerItems, + loading: true, + loadingText: 'Loading', + }); + const dropdown = findSelectionControllerDropdown(wrapper)!; + const trigger = dropdown.findNativeButton(); + expect(trigger.getElement()).toBeDisabled(); + }); +}); + +describe('Selection Controller - Aria labels', () => { + test('applies selectionControllerLabel as aria-label on trigger', () => { + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange: jest.fn(), + selectionControllerItems, + ariaLabels: { + selectionGroupLabel: 'group', + allItemsSelectionLabel: () => 'select all', + itemSelectionLabel: () => 'select item', + selectionControllerLabel: 'Selection options', + }, + }); + const dropdown = findSelectionControllerDropdown(wrapper)!; + const trigger = dropdown.findNativeButton(); + expect(trigger.getElement()).toHaveAttribute('aria-label', 'Selection options'); + }); +}); + +describe('Selection Controller - Does not modify selectedItems', () => { + test('selectedItems remains unchanged after selection controller item click', () => { + const initialSelected = [items[0]]; + const onSelectionChange = jest.fn(); + const onItemClick = jest.fn(); + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: initialSelected, + onSelectionChange, + selectionControllerItems, + onSelectionControllerItemClick: onItemClick, + }); + const dropdown = findSelectionControllerDropdown(wrapper)!; + dropdown.openDropdown(); + dropdown.findItemById('none')!.click(); + // The table should NOT have called onSelectionChange — only onSelectionControllerItemClick + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(onItemClick).toHaveBeenCalledTimes(1); + }); +}); + +describe('Selection Controller - Coexistence with select-all checkbox', () => { + test('select-all checkbox still works when controller is present', () => { + const onSelectionChange = jest.fn(); + const { wrapper } = renderTable({ + selectionType: 'multi', + selectedItems: [], + onSelectionChange, + selectionControllerItems, + }); + // The select-all trigger should still exist + const selectAll = wrapper.findSelectAllTrigger(); + expect(selectAll).toBeTruthy(); + // Click the select-all checkbox + selectAll!.click(); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/table/interfaces.tsx b/src/table/interfaces.tsx index 1628cf5492..9f5bdcce18 100644 --- a/src/table/interfaces.tsx +++ b/src/table/interfaces.tsx @@ -429,6 +429,54 @@ export interface TableProps extends BaseComponentProps { * Renders loader counter that is appended to the loader content in all loader states. */ renderLoaderCounter?: (detail: TableProps.RenderLoaderCounterDetail) => React.ReactNode; + + /** + * Specifies the items displayed in the selection controller dropdown. + * + * The selection controller adds a small dropdown trigger next to the "select all" + * checkbox in the table header. It provides quick access to predefined selection + * actions, such as selecting items by attribute (for example, by status or type). + * + * Each entry in the array is either a `SelectionControllerItem` or a `SelectionControllerItemGroup`. + * + * SelectionControllerItem has the following properties: + * * `id` (string) - A unique identifier for the selection action. + * * `text` (string) - The display label for the selection action. + * * `itemType` (optional, `'checkbox'`) - Set to `'checkbox'` to render the item as a toggleable checkbox. + * * `checked` (optional, boolean) - Whether the checkbox item is checked. Only applicable when `itemType` is `'checkbox'`. + * * `disabled` (optional, boolean) - When `true`, the item is rendered in a disabled state and cannot be activated. + * * `disabledReason` (optional, string) - A reason displayed when hovering over a disabled item. + * * `secondaryText` (optional, string) - Secondary descriptive text displayed below the item text. + * * `ariaLabel` (optional, string) - An accessible label for the item. + * * `iconName` (optional, string) - An icon name displayed before the item text. + * * `iconSvg` (optional, ReactNode) - An icon SVG displayed before the item text. + * + * SelectionControllerItemGroup has the following properties: + * * `text` (optional, string) - A display label for the group header. + * * `items` (SelectionControllerItem[]) - The items within this group. + * * `disabled` (optional, boolean) - When `true`, the group is rendered in a disabled state. + * + * The selection controller is only rendered when `selectionType` is `"multi"` and + * this property contains at least one item. It is not rendered when `selectionType` + * is `"single"`, when `expandableRows` with group selection is configured, or when + * this property is `undefined` or an empty array. + */ + selectionControllerItems?: ReadonlyArray< + TableProps.SelectionControllerItem | TableProps.SelectionControllerItemGroup + >; + + /** + * Called when a user clicks a selection controller item in the dropdown. + * + * The event `detail` contains the `id` of the clicked item. For checkbox items, + * it also includes the new `checked` state after the toggle. + * + * The table does not automatically change `selectedItems` when a selection + * controller item is clicked. Use this event handler to implement your own + * selection logic (for example, selecting all items that match a filter condition) + * and update `selectedItems` accordingly. + */ + onSelectionControllerItemClick?: NonCancelableEventHandler; } export namespace TableProps { @@ -547,6 +595,7 @@ export namespace TableProps { successfulEditLabel?: (column: ColumnDefinition) => string; expandButtonLabel?: (item: T) => string; collapseButtonLabel?: (item: T) => string; + selectionControllerLabel?: string; } export interface SortingState { isDescending?: boolean; @@ -655,6 +704,41 @@ export namespace TableProps { export interface RenderLoaderEmptyDetail { item: T; } + + export interface SelectionControllerItem { + /** Unique identifier for the selection action. */ + id: string; + /** Display label for the selection action. */ + text: string; + /** Whether this item is the currently active selection criteria. */ + checked?: boolean; + /** When true, the item is rendered in a disabled state and cannot be activated. */ + disabled?: boolean; + /** Optional reason displayed when hovering over a disabled item. */ + disabledReason?: string; + /** Optional secondary descriptive text displayed below the item text. */ + secondaryText?: string; + /** Optional accessible label for the item. */ + ariaLabel?: string; + /** Optional icon name displayed before the item text. */ + iconName?: string; + /** Optional icon SVG displayed before the item text. */ + iconSvg?: React.ReactNode; + } + + export interface SelectionControllerItemGroup { + /** Optional display label for the group header. */ + text?: string; + /** The items within this group. */ + items: ReadonlyArray; + /** When true, the group is rendered in a disabled state. */ + disabled?: boolean; + } + + export interface SelectionControllerItemClickDetail { + /** The `id` of the activated item. */ + id: string; + } } export type TableRow = TableDataRow | TableLoaderRow; diff --git a/src/table/internal.tsx b/src/table/internal.tsx index cf2e4db610..aa70d10ff2 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -73,6 +73,7 @@ import styles from './styles.css.js'; const GRID_NAVIGATION_PAGE_SIZE = 10; const SELECTION_COLUMN_WIDTH = 54; +const SELECTION_COLUMN_WIDTH_WITH_CONTROLLER = 72; const selectionColumnId = Symbol('selection-column-id'); type InternalTableProps = SomeRequired< @@ -147,6 +148,8 @@ const InternalTable = React.forwardRef( renderLoaderEmpty, renderLoaderCounter, cellVerticalAlign, + selectionControllerItems, + onSelectionControllerItemClick, __funnelSubStepProps, ...rest }: InternalTableProps, @@ -179,6 +182,12 @@ const InternalTable = React.forwardRef( const { allRows } = useProgressiveLoadingProps({ getLoadingStatus, expandableRows }); const selectionType = expandableRows.hasGroupSelection ? ('group' as const) : externalSelectionType; + const showSelectionController = + externalSelectionType === 'multi' && + !expandableRows.hasGroupSelection && + !!selectionControllerItems && + selectionControllerItems.length > 0; + const [containerWidth, wrapperMeasureRef] = useContainerQuery(rect => rect.borderBoxWidth); const wrapperMeasureRefObject = useRef(null); const wrapperMeasureMergedRef = useMergeRefs(wrapperMeasureRef, wrapperMeasureRefObject); @@ -362,8 +371,15 @@ const InternalTable = React.forwardRef( const visibleColumnWidthsWithSelection: ColumnWidthDefinition[] = []; const visibleColumnIdsWithSelection: PropertyKey[] = []; + const selectionColumnWidth = showSelectionController + ? SELECTION_COLUMN_WIDTH_WITH_CONTROLLER + : SELECTION_COLUMN_WIDTH; if (hasSelection) { - visibleColumnWidthsWithSelection.push({ id: selectionColumnId, width: SELECTION_COLUMN_WIDTH }); + visibleColumnWidthsWithSelection.push({ + id: selectionColumnId, + width: selectionColumnWidth, + minWidth: selectionColumnWidth, + }); visibleColumnIdsWithSelection.push(selectionColumnId); } for (let columnIndex = 0; columnIndex < visibleColumnDefinitions.length; columnIndex++) { @@ -422,6 +438,13 @@ const InternalTable = React.forwardRef( tableRole, isExpandable, setLastUserAction, + selectionControllerItems: showSelectionController ? selectionControllerItems : undefined, + onSelectionControllerItemClick: showSelectionController + ? (detail: TableProps.SelectionControllerItemClickDetail) => + fireNonCancelableEvent(onSelectionControllerItemClick, detail) + : undefined, + selectionControllerAriaLabel: ariaLabels?.selectionControllerLabel, + loading, }; usePreventStickyClickScroll(wrapperRefObject); @@ -641,6 +664,7 @@ const InternalTable = React.forwardRef( )} @@ -742,10 +767,12 @@ const InternalTable = React.forwardRef( ) : null} {visibleColumnDefinitions.map((column, colIndex) => ( diff --git a/src/table/selection/selection-cell.tsx b/src/table/selection/selection-cell.tsx index f2b894af81..f12f113a8b 100644 --- a/src/table/selection/selection-cell.tsx +++ b/src/table/selection/selection-cell.tsx @@ -7,22 +7,32 @@ import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-tool import ScreenreaderOnly from '../../internal/components/screenreader-only'; import { TableTdElement, TableTdElementProps } from '../body-cell/td-element'; import { TableThElement, TableThElementProps } from '../header-cell/th-element'; +import { TableProps } from '../interfaces'; import { Divider } from '../resizer'; import { ItemSelectionProps } from './interfaces'; import { SelectionControl, SelectionControlProps } from './selection-control'; +import SelectionControllerDropdown from './selection-controller-dropdown'; import styles from '../styles.css.js'; +import selectionStyles from './styles.css.js'; interface TableHeaderSelectionCellProps extends Omit { focusedComponent?: null | string; singleSelectionHeaderAriaLabel?: string; getSelectAllProps?: () => ItemSelectionProps; onFocusMove: ((sourceElement: HTMLElement, fromIndex: number, direction: -1 | 1) => void) | undefined; + selectionControllerItems?: ReadonlyArray< + TableProps.SelectionControllerItem | TableProps.SelectionControllerItemGroup + >; + onSelectionControllerItemClick?: (detail: TableProps.SelectionControllerItemClickDetail) => void; + selectionControllerAriaLabel?: string; + loading?: boolean; } interface TableBodySelectionCellProps extends Omit { selectionControlProps?: SelectionControlProps; + hasSelectionController?: boolean; } export function TableHeaderSelectionCell({ @@ -30,9 +40,14 @@ export function TableHeaderSelectionCell({ singleSelectionHeaderAriaLabel, getSelectAllProps, onFocusMove, + selectionControllerItems, + onSelectionControllerItemClick, + selectionControllerAriaLabel, + loading, ...props }: TableHeaderSelectionCellProps) { const selectAllProps = getSelectAllProps ? getSelectAllProps() : undefined; + const showController = !!selectAllProps && !!selectionControllerItems && selectionControllerItems.length > 0; return ( {selectAllProps ? ( - { - onFocusMove!(event.target as HTMLElement, -1, +1); - }} - focusedComponent={focusedComponent} - {...selectAllProps} - {...(props.sticky ? { tabIndex: -1 } : {})} - /> + showController ? ( +
+ { + onFocusMove!(event.target as HTMLElement, -1, +1); + }} + focusedComponent={focusedComponent} + {...selectAllProps} + {...(props.sticky ? { tabIndex: -1 } : {})} + /> + +
+ ) : ( + { + onFocusMove!(event.target as HTMLElement, -1, +1); + }} + focusedComponent={focusedComponent} + {...selectAllProps} + {...(props.sticky ? { tabIndex: -1 } : {})} + /> + ) ) : ( {singleSelectionHeaderAriaLabel} )} @@ -61,11 +96,21 @@ export function TableHeaderSelectionCell({ ); } -export function TableBodySelectionCell({ selectionControlProps, ...props }: TableBodySelectionCellProps) { +export function TableBodySelectionCell({ + selectionControlProps, + hasSelectionController, + ...props +}: TableBodySelectionCellProps) { return ( {selectionControlProps ? ( - + hasSelectionController ? ( +
+ +
+ ) : ( + + ) ) : null}
); diff --git a/src/table/selection/selection-controller-dropdown.tsx b/src/table/selection/selection-controller-dropdown.tsx new file mode 100644 index 0000000000..27c1585a9f --- /dev/null +++ b/src/table/selection/selection-controller-dropdown.tsx @@ -0,0 +1,112 @@ +// 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 { ButtonDropdownProps } from '../../button-dropdown/interfaces'; +import InternalButtonDropdown from '../../button-dropdown/internal'; +import InternalIcon from '../../icon/internal'; +import { TableProps } from '../interfaces'; + +import styles from './styles.css.js'; + +type SelectionControllerItems = ReadonlyArray< + TableProps.SelectionControllerItem | TableProps.SelectionControllerItemGroup +>; + +export interface SelectionControllerDropdownProps { + items: SelectionControllerItems; + onItemClick: (detail: TableProps.SelectionControllerItemClickDetail) => void; + ariaLabel?: string; + disabled?: boolean; + sticky?: boolean; +} + +function hasKey(obj: T, key: keyof any): key is keyof T { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +function isItemGroup( + item: TableProps.SelectionControllerItem | TableProps.SelectionControllerItemGroup +): item is TableProps.SelectionControllerItemGroup { + return hasKey(item, 'items') && Array.isArray((item as TableProps.SelectionControllerItemGroup).items); +} + +function mapItem( + item: TableProps.SelectionControllerItem +): ButtonDropdownProps.Item | ButtonDropdownProps.CheckboxItem { + if (item.checked !== undefined) { + return { + id: item.id, + text: item.text, + itemType: 'checkbox', + checked: item.checked, + disabled: item.disabled, + disabledReason: item.disabledReason, + secondaryText: item.secondaryText, + ariaLabel: item.ariaLabel, + iconName: item.iconName as any, + iconSvg: item.iconSvg, + }; + } + return { + id: item.id, + text: item.text, + disabled: item.disabled, + disabledReason: item.disabledReason, + secondaryText: item.secondaryText, + ariaLabel: item.ariaLabel, + iconName: item.iconName as any, + iconSvg: item.iconSvg, + }; +} + +function mapItems(items: SelectionControllerItems): ButtonDropdownProps.Items { + return items.map(item => { + if (isItemGroup(item)) { + return { + text: item.text, + disabled: item.disabled, + items: item.items.map(mapItem), + } as ButtonDropdownProps.ItemGroup; + } + return mapItem(item); + }); +} + +export default function SelectionControllerDropdown({ + items, + onItemClick, + ariaLabel, + disabled = false, + sticky = false, +}: SelectionControllerDropdownProps) { + const mappedItems = mapItems(items); + + return ( + onItemClick({ id: detail.id })} + ariaLabel={ariaLabel} + disabled={disabled} + variant="inline-icon" + expandToViewport={true} + expandableGroups={false} + customTriggerBuilder={({ triggerRef, testUtilsClass, ariaExpanded, onClick, disabled: triggerDisabled }) => ( + + )} + /> + ); +} diff --git a/src/table/selection/styles.scss b/src/table/selection/styles.scss index 0e5218042f..3be85e21dd 100644 --- a/src/table/selection/styles.scss +++ b/src/table/selection/styles.scss @@ -32,3 +32,85 @@ .stud { visibility: hidden; } + +.selection-controller-trigger { + @include styles.styles-reset; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-inline: none; + border-block: none; + border-start-start-radius: awsui.$border-radius-button; + border-start-end-radius: awsui.$border-radius-button; + border-end-start-radius: awsui.$border-radius-button; + border-end-end-radius: awsui.$border-radius-button; + padding-block: awsui.$space-xxs; + padding-inline: awsui.$space-xxs; + color: awsui.$color-text-interactive-default; + background: transparent; + position: relative; + z-index: 1; + + &:hover { + color: awsui.$color-text-interactive-hover; + background: awsui.$color-background-input-default; + } + + &:focus-visible { + @include styles.focus-highlight(awsui.$space-button-focus-outline-gutter); + } + + &:disabled { + color: awsui.$color-text-interactive-disabled; + cursor: default; + &:hover { + background: transparent; + } + } +} + +.selection-controller-wrapper { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: awsui.$space-xxs; + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + block-size: 100%; + inline-size: 100%; + box-sizing: border-box; + + // Override the checkbox label's absolute positioning when inside the wrapper. + // The label (which has .root and .label) is a direct child of this wrapper + // because SelectionControl renders a fragment. + > .label { + position: relative; + inline-size: auto; + block-size: auto; + inset-block-start: auto; + inset-inline-start: auto; + padding-block-end: 0; + border-inline-end: none; + } + + // Hide the invisible spacing stud — not needed when the wrapper controls layout. + > .stud { + display: none; + } +} + +// Wrapper for body row selection controls when the selection controller is present. +// Constrains the absolutely-positioned label so the checkbox stays in its normal +// position and the extra column width appears as space on the inline-end side. +.body-selection-controller-wrapper { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + block-size: 100%; + // Use the standard selection column width so the label centers within it, + // leaving extra column space on the right. + inline-size: awsui.$size-table-selection-horizontal; +} diff --git a/src/table/styles.scss b/src/table/styles.scss index 186090c9bd..8fcf6e2a4e 100644 --- a/src/table/styles.scss +++ b/src/table/styles.scss @@ -132,7 +132,6 @@ filter search icon. */ .selection-control { box-sizing: border-box; - max-inline-size: awsui.$size-table-selection-horizontal; min-inline-size: awsui.$size-table-selection-horizontal; position: relative; inline-size: awsui.$size-table-selection-horizontal; diff --git a/src/table/thead.tsx b/src/table/thead.tsx index 10024c014e..4a51a50509 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -45,6 +45,12 @@ export interface TheadProps { tableRole: TableRole; isExpandable?: boolean; setLastUserAction: (name: string) => void; + selectionControllerItems?: ReadonlyArray< + TableProps.SelectionControllerItem | TableProps.SelectionControllerItemGroup + >; + onSelectionControllerItemClick?: (detail: TableProps.SelectionControllerItemClickDetail) => void; + selectionControllerAriaLabel?: string; + loading?: boolean; } const Thead = React.forwardRef( @@ -77,6 +83,10 @@ const Thead = React.forwardRef( resizerTooltipText, isExpandable, setLastUserAction, + selectionControllerItems, + onSelectionControllerItemClick, + selectionControllerAriaLabel, + loading, }: TheadProps, outerRef: React.Ref ) => { @@ -112,9 +122,14 @@ const Thead = React.forwardRef( {...commonCellProps} focusedComponent={focusedComponent} columnId={selectionColumnId} + resizableStyle={getColumnStyles(sticky, selectionColumnId)} getSelectAllProps={getSelectAllProps} onFocusMove={onFocusMove} singleSelectionHeaderAriaLabel={singleSelectionHeaderAriaLabel} + selectionControllerItems={selectionControllerItems} + onSelectionControllerItemClick={onSelectionControllerItemClick} + selectionControllerAriaLabel={selectionControllerAriaLabel} + loading={loading} /> ) : null}