diff --git a/pages/table/multi-column-sort.page.tsx b/pages/table/multi-column-sort.page.tsx new file mode 100644 index 0000000000..c7af7f0891 --- /dev/null +++ b/pages/table/multi-column-sort.page.tsx @@ -0,0 +1,232 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import { Checkbox, Pagination, SpaceBetween, Table, TableProps } from '~components'; + +import { useAppContext } from '../app/app-context'; +import { SimplePage } from '../app/templates'; + +interface Item { + id: string; + name: string; + type: string; + state: string; + cpu: number; +} + +const allItems: Item[] = [ + { id: '1', name: 'alpha-server', type: 't3.micro', state: 'running', cpu: 12 }, + { id: '2', name: 'beta-server', type: 'm5.large', state: 'stopped', cpu: 0 }, + { id: '3', name: 'gamma-worker', type: 'c5.xlarge', state: 'running', cpu: 87 }, + { id: '4', name: 'delta-proxy', type: 't3.micro', state: 'running', cpu: 45 }, + { id: '5', name: 'epsilon-db', type: 'r5.large', state: 'running', cpu: 23 }, + { id: '6', name: 'zeta-cache', type: 'r5.large', state: 'stopped', cpu: 0 }, + { id: '7', name: 'eta-gateway', type: 't3.micro', state: 'running', cpu: 56 }, + { id: '8', name: 'theta-batch', type: 'c5.xlarge', state: 'running', cpu: 91 }, +]; + +const shortHeaders: Record = { + name: 'Name', + type: 'Type', + state: 'State', + cpu: 'CPU %', +}; + +const longHeaders: Record = { + name: 'Fully qualified instance name and identifier', + type: 'Compute instance type and family classification', + state: 'Current lifecycle and operational state', + cpu: 'Average CPU utilization over the last hour (%)', +}; + +const groupDefinitions: TableProps.GroupDefinition[] = [ + { id: 'identity', header: 'Identity' }, + { id: 'status', header: 'Status' }, +]; + +const groupedColumnDisplay: TableProps.ColumnDisplayProperties[] = [ + { + type: 'group', + id: 'identity', + visible: true, + children: [ + { id: 'name', visible: true }, + { id: 'type', visible: true }, + ], + }, + { + type: 'group', + id: 'status', + visible: true, + children: [ + { id: 'state', visible: true }, + { id: 'cpu', visible: true }, + ], + }, +]; + +const PAGE_SIZE = 4; + +export default function MultiColumnSortPage() { + const { urlParams, setUrlParams } = useAppContext< + 'multiSort' | 'selection' | 'grouped' | 'pagination' | 'longNames' | 'resizable' + >(); + + // `multiSort` defaults to on; the rest default to off. + const multiSortEnabled = urlParams.multiSort !== 'false' && urlParams.multiSort !== false; + const selectionEnabled = urlParams.selection === true || urlParams.selection === 'true'; + const groupedEnabled = urlParams.grouped === true || urlParams.grouped === 'true'; + const paginationEnabled = urlParams.pagination === true || urlParams.pagination === 'true'; + const longNamesEnabled = urlParams.longNames === true || urlParams.longNames === 'true'; + const resizableEnabled = urlParams.resizable === true || urlParams.resizable === 'true'; + + const [sortingColumns, setSortingColumns] = useState>>([ + { sortingColumn: { sortingField: 'state' }, isDescending: false }, + { sortingColumn: { sortingField: 'cpu' }, isDescending: true }, + ]); + // Single-column sort state, used when multi-column sort is toggled off. + const [sortingColumn, setSortingColumn] = useState>({ sortingField: 'state' }); + const [sortingDescending, setSortingDescending] = useState(false); + const [selectedItems, setSelectedItems] = useState>([]); + const [currentPageIndex, setCurrentPageIndex] = useState(1); + + const headers = longNamesEnabled ? longHeaders : shortHeaders; + const getSortLabel = + (label: string) => + ({ sorted, descending, disabled, sortIndex }: TableProps.LabelData): string => { + if (disabled) { + return `${label}, sorting disabled`; + } + if (!sorted) { + return `${label}, not sorted`; + } + const direction = descending ? 'descending' : 'ascending'; + return sortIndex ? `${label}, sorted ${direction}, sort position ${sortIndex}` : `${label}, sorted ${direction}`; + }; + const columnDefinitions: TableProps.ColumnDefinition[] = [ + { + id: 'name', + header: headers.name, + cell: item => item.name, + sortingField: 'name', + ariaLabel: getSortLabel(headers.name), + }, + { + id: 'type', + header: headers.type, + cell: item => item.type, + sortingField: 'type', + ariaLabel: getSortLabel(headers.type), + }, + { + id: 'state', + header: headers.state, + cell: item => item.state, + sortingField: 'state', + ariaLabel: getSortLabel(headers.state), + }, + { + id: 'cpu', + header: headers.cpu, + cell: item => `${item.cpu}%`, + sortingField: 'cpu', + ariaLabel: getSortLabel(headers.cpu), + }, + ]; + + const compareField = (a: Item, b: Item, field: keyof Item, descending: boolean) => { + const aVal = a[field]; + const bVal = b[field]; + const cmp = + typeof aVal === 'number' && typeof bVal === 'number' ? aVal - bVal : String(aVal).localeCompare(String(bVal)); + return descending ? -cmp : cmp; + }; + const sorted = multiSortEnabled + ? [...allItems].sort((a, b) => { + for (const entry of sortingColumns) { + const cmp = compareField(a, b, entry.sortingColumn.sortingField as keyof Item, !!entry.isDescending); + if (cmp !== 0) { + return cmp; + } + } + return 0; + }) + : [...allItems].sort((a, b) => compareField(a, b, sortingColumn.sortingField as keyof Item, sortingDescending)); + + const pagesCount = Math.ceil(sorted.length / PAGE_SIZE); + const pageItems = paginationEnabled + ? sorted.slice((currentPageIndex - 1) * PAGE_SIZE, currentPageIndex * PAGE_SIZE) + : sorted; + + return ( + + setUrlParams({ multiSort: detail.checked })}> + Multi-column sort + + setUrlParams({ selection: detail.checked })}> + Selection + + setUrlParams({ grouped: detail.checked })}> + Grouped columns + + setUrlParams({ pagination: detail.checked })}> + Pagination + + setUrlParams({ longNames: detail.checked })}> + Long column names + + setUrlParams({ resizable: detail.checked })}> + Resizable columns + + + } + > + setSelectedItems(detail.selectedItems)} + sortingColumn={multiSortEnabled ? undefined : sortingColumn} + sortingDescending={multiSortEnabled ? undefined : sortingDescending} + onSortingChange={ + multiSortEnabled + ? undefined + : ({ detail }) => { + setSortingColumn(detail.sortingColumn); + setSortingDescending(!!detail.isDescending); + } + } + multiColumnSort={ + multiSortEnabled + ? { sortingColumns, onChange: ({ detail }) => setSortingColumns(detail.sortingColumns) } + : undefined + } + pagination={ + paginationEnabled ? ( + setCurrentPageIndex(detail.currentPageIndex)} + /> + ) : undefined + } + ariaLabels={{ + tableLabel: 'Multi-column sort demo', + sortMenuTriggerLabel: 'Sort options', + selectionGroupLabel: 'Item selection', + allItemsSelectionLabel: () => 'Select all', + itemSelectionLabel: (_data, item) => `Select ${item.name}`, + }} + /> + + ); +} diff --git a/pages/table/multi-column-sort.permutations.page.tsx b/pages/table/multi-column-sort.permutations.page.tsx new file mode 100644 index 0000000000..9b9c41c01b --- /dev/null +++ b/pages/table/multi-column-sort.permutations.page.tsx @@ -0,0 +1,90 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import Table, { TableProps } from '~components/table'; + +import createPermutations from '../utils/permutations'; +import PermutationsView from '../utils/permutations-view'; +import ScreenshotArea from '../utils/screenshot-area'; + +interface Item { + name: string; + type: string; + count: number; +} + +const items: Item[] = [ + { name: 'alpha', type: 't3.micro', count: 12 }, + { name: 'beta', type: 'm5.large', count: 3 }, +]; + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'name', header: 'Name', cell: item => item.name, sortingField: 'name' }, + { id: 'type', header: 'Type', cell: item => item.type, sortingField: 'type' }, + { id: 'count', header: 'Count', cell: item => item.count, sortingField: 'count' }, +]; + +const noop = () => {}; + +const i18nStrings: TableProps.I18nStrings = { + sortDropdown: { + sortAscending: 'Sort ascending', + sortDescending: 'Sort descending', + multiColumnSortGroup: 'Multi-column sort (Shift + Click)', + addToSortAscending: 'Add to sort (ascending)', + addToSortDescending: 'Add to sort (descending)', + removeFromSort: 'Remove from sort', + }, + clearSort: 'Clear sort', +}; + +const ariaLabels: TableProps.AriaLabels = { + tableLabel: 'Multi-column sort permutations', + sortMenuTriggerLabel: 'Sort options', + selectionGroupLabel: 'Item selection', + allItemsSelectionLabel: () => 'Select all', + itemSelectionLabel: (_data, item) => `Select ${item.name}`, +}; + +const permutations = createPermutations>([ + { + selectionType: [undefined, 'multi'], + multiColumnSort: [ + // No active sort: kebab present, no priority badge, aria-sort="none". + { sortingColumns: [], onChange: noop }, + // Single column: sorted icon, no priority badge (priority adds no info). + { sortingColumns: [{ sortingColumn: { sortingField: 'name' }, isDescending: false }], onChange: noop }, + // Two columns: primary ascending (badge 1, declares aria-sort) + secondary descending (badge 2, suppressed). + { + sortingColumns: [ + { sortingColumn: { sortingField: 'name' }, isDescending: false }, + { sortingColumn: { sortingField: 'type' }, isDescending: true }, + ], + onChange: noop, + }, + ], + items: [items], + columnDefinitions: [columnDefinitions], + }, +]); + +export default function () { + return ( + <> +

Table multi-column sort permutations

+ + ( +
+ )} + /> + + + ); +} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index a6b649ff84..8e56c2f2ec 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -28050,6 +28050,11 @@ in tables with data grouping. "optional": true, "type": "string", }, + { + "name": "sortMenuTriggerLabel", + "optional": true, + "type": "string", + }, { "inlineType": { "name": "(column: TableProps.ColumnDefinition) => string", @@ -28153,7 +28158,8 @@ To target individual cells use \`columnDefinitions.verticalAlign\`, that takes p Note that when the \`resizableColumns\` property is set to \`true\` this property is ignored. * \`ariaLabel\` (LabelData => string) - An optional function that's called to provide an \`aria-label\` for the cell header. It receives the current sorting state of this column, the direction it's sorted in, and an indication of - whether the sorting is disabled, as three Boolean values: \`sorted\`, \`descending\` and \`disabled\`. + whether the sorting is disabled, as three Boolean values: \`sorted\`, \`descending\` and \`disabled\`, + plus a number \`sortIndex\` if the column is part of a multi-column sort. We recommend that you use this for sortable columns to provide more meaningful labels based on the current sorting direction. * \`sortingField\` (string) - Enables default column sorting. The value is used in [collection hooks](/get-started/dev-guides/collection-hooks/) @@ -28442,6 +28448,124 @@ Each group definition contains the following: "optional": true, "type": "ReadonlyArray>", }, + { + "description": "An object containing all the localized strings required by the multi-column +sorting UI: + +* \`sortDropdown\` (object): Strings for the per-column sort dropdown menu. + * \`sortAscending\` (string): Label for the "Sort ascending" dropdown menu item. + * \`sortDescending\` (string): Label for the "Sort descending" dropdown menu item. + * \`multiColumnSortGroup\` (string): Label for the "Multi-column sort" dropdown menu group. + * \`addToSortAscending\` (string): Label for the "Add to sort (ascending)" dropdown menu item. + * \`addToSortDescending\` (string): Label for the "Add to sort (descending)" dropdown menu item. + * \`removeFromSort\` (string): Label for the "Remove from sort" dropdown menu item. + * \`addToSortDisabledReason\` (string): Reason shown when the "Add to sort" menu items are disabled. + * \`removeFromSortDisabledReason\` (string): Reason shown when the "Remove from sort" menu item is disabled. +* \`clearSort\` (string): Label for the "Clear sort" button.", + "i18nTag": true, + "inlineType": { + "name": "TableProps.I18nStrings", + "properties": [ + { + "name": "clearSort", + "optional": true, + "type": "string", + }, + { + "name": "liveAnnouncementSortCleared", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "(data: { columnLabel: string; isDescending: boolean; }) => string", + "parameters": [ + { + "name": "data", + "type": "{ columnLabel: string; isDescending: boolean; }", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "liveAnnouncementSortColumn", + "optional": true, + "type": "((data: { columnLabel: string; isDescending: boolean; }) => string)", + }, + { + "inlineType": { + "name": "(data: { columns: string; }) => string", + "parameters": [ + { + "name": "data", + "type": "{ columns: string; }", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "liveAnnouncementSortOrder", + "optional": true, + "type": "((data: { columns: string; }) => string)", + }, + { + "inlineType": { + "name": "object", + "properties": [ + { + "name": "addToSortAscending", + "optional": true, + "type": "string", + }, + { + "name": "addToSortDescending", + "optional": true, + "type": "string", + }, + { + "name": "addToSortDisabledReason", + "optional": true, + "type": "string", + }, + { + "name": "multiColumnSortGroup", + "optional": true, + "type": "string", + }, + { + "name": "removeFromSort", + "optional": true, + "type": "string", + }, + { + "name": "removeFromSortDisabledReason", + "optional": true, + "type": "string", + }, + { + "name": "sortAscending", + "optional": true, + "type": "string", + }, + { + "name": "sortDescending", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "sortDropdown", + "optional": true, + "type": "{ sortAscending?: string | undefined; sortDescending?: string | undefined; multiColumnSortGroup?: string | undefined; addToSortAscending?: string | undefined; addToSortDescending?: string | undefined; removeFromSort?: string | undefined; addToSortDisabledReason?: string | undefined; removeFromSortDisabledReason?: st...", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "TableProps.I18nStrings", + }, { "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 @@ -28489,6 +28613,47 @@ In skeleton-loading mode this will be used as a label for screenreaders.", "optional": true, "type": "string", }, + { + "description": "Enables multi-column sorting on the table. The object contains: + +* \`sortingColumns\` (ReadonlyArray>) - The current multi-column + sort state. The first entry has the highest sort priority, and subsequent entries act as + tiebreakers. +* \`onChange\` (NonCancelableEventHandler>) - Called + when the user changes the sort state. + +Use either this property or \`sortingColumn\` / \`sortingDescending\` / \`onSortingChange\`, but not both.", + "inlineType": { + "name": "TableProps.MultiColumnSort", + "properties": [ + { + "inlineType": { + "name": "NonCancelableEventHandler>", + "parameters": [ + { + "name": "event", + "type": "NonCancelableCustomEvent", + }, + ], + "returnType": "void", + "type": "function", + }, + "name": "onChange", + "optional": false, + "type": "NonCancelableEventHandler>", + }, + { + "name": "sortingColumns", + "optional": false, + "type": "ReadonlyArray>", + }, + ], + "type": "object", + }, + "name": "multiColumnSort", + "optional": true, + "type": "TableProps.MultiColumnSort", + }, { "description": "Use this function to announce page changes to screen reader users. The function argument takes the following properties: @@ -43668,6 +43833,17 @@ Returns the current value of the input.", ], }, }, + { + "description": "Returns the "Clear sort" button rendered in the table header tools area. +The button is auto-rendered only when multi-column sorting is enabled and at +least one column is sorted. Returns \`null\` otherwise.", + "name": "findClearSort", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ButtonWrapper", + }, + }, { "name": "findCollectionPreferences", "parameters": [], @@ -43749,6 +43925,50 @@ For tables with column grouping this excludes group header cells.", ], }, }, + { + "description": "Returns the per-column sort menu (kebab dropdown) for the column header at the +given index. Only present on tables that opt in to multi-column sorting.", + "name": "findColumnSortMenu", + "parameters": [ + { + "description": "1-based index of the column.", + "flags": { + "isOptional": false, + }, + "name": "columnIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "TableColumnSortMenuWrapper", + }, + }, + { + "description": "Returns the sort priority badge on the column header at the given index. +The badge is only rendered when two or more columns participate in a +multi-column sort. Returns \`null\` otherwise.", + "name": "findColumnSortPriorityBadge", + "parameters": [ + { + "description": "1-based index of the column.", + "flags": { + "isOptional": false, + }, + "name": "columnIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, { "description": "Returns the column that is used for descending sorting.", "name": "findDescSortedColumn", @@ -44098,62 +44318,63 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from { "methods": [ { - "name": "findActiveLink", - "parameters": [], - "returnType": { - "isNullable": true, - "name": "ElementWrapper", - "typeArguments": [ - { - "name": "HTMLAnchorElement", - }, - ], - }, - }, - { - "name": "findHeader", - "parameters": [], - "returnType": { - "isNullable": true, - "name": "ElementWrapper", - "typeArguments": [ - { - "name": "HTMLAnchorElement", + "description": "Returns the "Add to sort (ascending)" menu item. + +Supported options: +* \`disabled\` (boolean) - Use it to find the disabled or non-disabled item.", + "name": "findAddToSortAscendingItem", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, }, - ], - }, - }, - { - "name": "findHeaderLink", - "parameters": [], + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": true, "name": "ElementWrapper", "typeArguments": [ { - "name": "HTMLAnchorElement", + "name": "HTMLElement", }, ], }, }, { - "name": "findItemByIndex", + "description": "Returns the "Add to sort (descending)" menu item. + +Supported options: +* \`disabled\` (boolean) - Use it to find the disabled or non-disabled item.", + "name": "findAddToSortDescendingItem", "parameters": [ { + "defaultValue": "{}", "flags": { "isOptional": false, }, - "name": "index", - "typeName": "number", + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", }, ], "returnType": { "isNullable": true, - "name": "SideNavigationItemWrapper", + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], }, }, { - "name": "findItemsControl", + "description": "Finds the disabled reason tooltip for a dropdown item. Returns null if no disabled item with \`disabledReason\` is highlighted.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findDisabledReason", + }, + "name": "findDisabledReason", "parameters": [], "returnType": { "isNullable": true, @@ -44166,13 +44387,19 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from }, }, { - "name": "findLinkByHref", + "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. + +This utility does not open the dropdown. To find dropdown items, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryById", + }, + "name": "findExpandableCategoryById", "parameters": [ { "flags": { "isOptional": false, }, - "name": "href", + "name": "id", "typeName": "string", }, ], @@ -44181,33 +44408,416 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from "name": "ElementWrapper", "typeArguments": [ { - "name": "HTMLAnchorElement", + "name": "HTMLElement", }, ], }, }, - ], - "name": "SideNavigationWrapper", - }, - { - "methods": [ { - "name": "findDivider", + "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. +Returns null if there is no open dropdown or filtering is not enabled. + +This utility does not open the dropdown. To find the filtering input, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findFilteringInput", + }, + "name": "findFilteringInput", "parameters": [], "returnType": { "isNullable": true, - "name": "ElementWrapper", - "typeArguments": [ - { - "name": "HTMLElement", - }, - ], + "name": "InputWrapper", }, }, { - "name": "findExpandableLinkGroup", - "parameters": [], - "returnType": { + "description": "Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and text is +entered, this contains content rendered by filteringResultsText if there are matching items and the \`noMatch\` +content if there are none. Returns null if there is no open dropdown or the footer is not displayed. + +This utility does not open the dropdown. To find the footer region, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findFooterRegion", + }, + "name": "findFooterRegion", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Finds the highlighted item in the open dropdown. Returns null if there is no open dropdown. + +This utility does not open the dropdown. To find dropdown items, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findHighlightedItem", + }, + "name": "findHighlightedItem", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Finds an item in the open dropdown by item id. Returns null if there is no open dropdown. + +This utility does not open the dropdown. To find dropdown items, call \`openDropdown()\` first. + +Supported options: +* \`disabled\` (boolean) - Use it to find the disabled or non-disabled item.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findItemById", + }, + "name": "findItemById", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "id", + "typeName": "string", + }, + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Finds \`checked\` value of item in the open dropdown by item id. Returns null if there is no open dropdown or the item is not a checkbox item. + +This utility does not open the dropdown. To find dropdown items, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findItemCheckedById", + }, + "name": "findItemCheckedById", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "id", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "string", + }, + }, + { + "description": "Finds all the items in the open dropdown. Returns empty array if there is no open dropdown. + +This utility does not open the dropdown. To find dropdown items, call \`openDropdown()\` first. + +Supported options: +* \`disabled\` (boolean) - Use it to find all disabled or non-disabled items.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findItems", + }, + "name": "findItems", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", + }, + ], + "returnType": { + "isNullable": false, + "name": "Array", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findMainAction", + }, + "name": "findMainAction", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ButtonWrapper", + }, + }, + { + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findNativeButton", + }, + "name": "findNativeButton", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLButtonElement", + }, + ], + }, + }, + { + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findOpenDropdown", + }, + "name": "findOpenDropdown", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the "Remove from sort" menu item. + +Supported options: +* \`disabled\` (boolean) - Use it to find the disabled or non-disabled item.", + "name": "findRemoveFromSortItem", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the "Sort ascending" menu item. + +Supported options: +* \`disabled\` (boolean) - Use it to find the disabled or non-disabled item.", + "name": "findSortAscendingItem", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "description": "Returns the "Sort descending" menu item. + +Supported options: +* \`disabled\` (boolean) - Use it to find the disabled or non-disabled item.", + "name": "findSortDescendingItem", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findTriggerButton", + }, + "name": "findTriggerButton", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ButtonWrapper", + }, + }, + { + "inheritedFrom": { + "name": "ButtonDropdownWrapper.openDropdown", + }, + "name": "openDropdown", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "void", + }, + }, + ], + "name": "TableColumnSortMenuWrapper", + }, + { + "methods": [ + { + "name": "findActiveLink", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLAnchorElement", + }, + ], + }, + }, + { + "name": "findHeader", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLAnchorElement", + }, + ], + }, + }, + { + "name": "findHeaderLink", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLAnchorElement", + }, + ], + }, + }, + { + "name": "findItemByIndex", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "index", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": true, + "name": "SideNavigationItemWrapper", + }, + }, + { + "name": "findItemsControl", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "name": "findLinkByHref", + "parameters": [ + { + "flags": { + "isOptional": false, + }, + "name": "href", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLAnchorElement", + }, + ], + }, + }, + ], + "name": "SideNavigationWrapper", + }, + { + "methods": [ + { + "name": "findDivider", + "parameters": [], + "returnType": { + "isNullable": true, + "name": "ElementWrapper", + "typeArguments": [ + { + "name": "HTMLElement", + }, + ], + }, + }, + { + "name": "findExpandableLinkGroup", + "parameters": [], + "returnType": { "isNullable": true, "name": "ExpandableSectionWrapper", }, @@ -53151,6 +53761,17 @@ In this case, use findContentEditableElement() instead.", "name": "ElementWrapper", }, }, + { + "description": "Returns the "Clear sort" button rendered in the table header tools area. +The button is auto-rendered only when multi-column sorting is enabled and at +least one column is sorted. Returns \`null\` otherwise.", + "name": "findClearSort", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ButtonWrapper", + }, + }, { "name": "findCollectionPreferences", "parameters": [], @@ -53222,6 +53843,45 @@ For tables with column grouping this excludes group header cells.", "name": "ElementWrapper", }, }, + { + "description": "Returns the per-column sort menu (kebab dropdown) for the column header at the +given index. Only present on tables that opt in to multi-column sorting.", + "name": "findColumnSortMenu", + "parameters": [ + { + "description": "1-based index of the column.", + "flags": { + "isOptional": false, + }, + "name": "columnIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "TableColumnSortMenuWrapper", + }, + }, + { + "description": "Returns the sort priority badge on the column header at the given index. +The badge is only rendered when two or more columns participate in a +multi-column sort. Returns \`null\` otherwise.", + "name": "findColumnSortPriorityBadge", + "parameters": [ + { + "description": "1-based index of the column.", + "flags": { + "isOptional": false, + }, + "name": "columnIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, { "description": "Returns the column that is used for descending sorting.", "name": "findDescSortedColumn", @@ -53244,11 +53904,187 @@ For tables with column grouping this excludes group header cells.", "typeName": "number", }, { - "description": "1-based index of the column of the cell to select.", + "description": "1-based index of the column of the cell to select.", + "flags": { + "isOptional": false, + }, + "name": "columnIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findEditingCell", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findEditingCellCancelButton", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findEditingCellSaveButton", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Alias for findEmptySlot method for compatibility with previous versions", + "name": "findEmptyRegion", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findEmptySlot", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns the expandable row toggle button.", + "name": "findExpandToggle", + "parameters": [ + { + "description": "1-based index of the row.", + "flags": { + "isOptional": false, + }, + "name": "rowIndex", + "typeName": "number", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findFilterSlot", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findFooterSlot", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Alias for findHeaderSlot method for compatibility with previous versions", + "name": "findHeaderRegion", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findHeaderSlot", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "description": "Returns items loader of the specific item (matched by item's track ID).", + "name": "findItemsLoaderByItemId", + "parameters": [ + { + "description": "the (expandable) item ID provided with \`trackBy\` property. + +Note: when used with collection-hooks the \`trackBy\` is set automatically from \`expandableRows.getId\`.", + "flags": { + "isOptional": false, + }, + "name": "itemId", + "typeName": "string", + }, + ], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findLoadingText", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findPagination", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "PaginationWrapper", + }, + }, + { + "name": "findPropertyFilter", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "PropertyFilterWrapper", + }, + }, + { + "description": "Returns items loader of the root table level.", + "name": "findRootItemsLoader", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "ElementWrapper", + }, + }, + { + "name": "findRows", + "parameters": [], + "returnType": { + "isNullable": false, + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], + }, + }, + { + "description": "Returns a row selection area for a given index.", + "name": "findRowSelectionArea", + "parameters": [ + { + "description": "1-based index of the row selection area to return.", "flags": { "isOptional": false, }, - "name": "columnIndex", + "name": "rowIndex", "typeName": "number", }, ], @@ -53258,7 +54094,7 @@ For tables with column grouping this excludes group header cells.", }, }, { - "name": "findEditingCell", + "name": "findSelectAllTrigger", "parameters": [], "returnType": { "isNullable": false, @@ -53266,32 +54102,79 @@ For tables with column grouping this excludes group header cells.", }, }, { - "name": "findEditingCellCancelButton", + "name": "findSelectedRows", "parameters": [], "returnType": { "isNullable": false, - "name": "ElementWrapper", + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], }, }, { - "name": "findEditingCellSaveButton", + "name": "findTextFilter", "parameters": [], + "returnType": { + "isNullable": false, + "name": "TextFilterWrapper", + }, + }, + ], + "name": "TableWrapper", + }, + { + "methods": [ + { + "description": "Returns the "Add to sort (ascending)" menu item. + +Supported options: +* \`disabled\` (boolean) - Use it to find the disabled or non-disabled item.", + "name": "findAddToSortAscendingItem", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "ElementWrapper", }, }, { - "description": "Alias for findEmptySlot method for compatibility with previous versions", - "name": "findEmptyRegion", - "parameters": [], + "description": "Returns the "Add to sort (descending)" menu item. + +Supported options: +* \`disabled\` (boolean) - Use it to find the disabled or non-disabled item.", + "name": "findAddToSortDescendingItem", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "ElementWrapper", }, }, { - "name": "findEmptySlot", + "description": "Finds the disabled reason tooltip for a dropdown item. Returns null if no disabled item with \`disabledReason\` is highlighted.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findDisabledReason", + }, + "name": "findDisabledReason", "parameters": [], "returnType": { "isNullable": false, @@ -53299,16 +54182,20 @@ For tables with column grouping this excludes group header cells.", }, }, { - "description": "Returns the expandable row toggle button.", - "name": "findExpandToggle", + "description": "Finds an expandable category in the open dropdown by category id. Returns null if there is no open dropdown. + +This utility does not open the dropdown. To find dropdown items, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findExpandableCategoryById", + }, + "name": "findExpandableCategoryById", "parameters": [ { - "description": "1-based index of the row.", "flags": { "isOptional": false, }, - "name": "rowIndex", - "typeName": "number", + "name": "id", + "typeName": "string", }, ], "returnType": { @@ -53317,24 +54204,30 @@ For tables with column grouping this excludes group header cells.", }, }, { - "name": "findFilterSlot", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", + "description": "Finds the filtering input rendered inside the open dropdown when filtering is enabled. +Returns null if there is no open dropdown or filtering is not enabled. + +This utility does not open the dropdown. To find the filtering input, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findFilteringInput", }, - }, - { - "name": "findFooterSlot", + "name": "findFilteringInput", "parameters": [], "returnType": { "isNullable": false, - "name": "ElementWrapper", + "name": "InputWrapper", }, }, { - "description": "Alias for findHeaderSlot method for compatibility with previous versions", - "name": "findHeaderRegion", + "description": "Finds the footer region rendered at the bottom of the open dropdown. When filtering is enabled and text is +entered, this contains content rendered by filteringResultsText if there are matching items and the \`noMatch\` +content if there are none. Returns null if there is no open dropdown or the footer is not displayed. + +This utility does not open the dropdown. To find the footer region, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findFooterRegion", + }, + "name": "findFooterRegion", "parameters": [], "returnType": { "isNullable": false, @@ -53342,7 +54235,13 @@ For tables with column grouping this excludes group header cells.", }, }, { - "name": "findHeaderSlot", + "description": "Finds the highlighted item in the open dropdown. Returns null if there is no open dropdown. + +This utility does not open the dropdown. To find dropdown items, call \`openDropdown()\` first.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findHighlightedItem", + }, + "name": "findHighlightedItem", "parameters": [], "returnType": { "isNullable": false, @@ -53350,19 +54249,32 @@ For tables with column grouping this excludes group header cells.", }, }, { - "description": "Returns items loader of the specific item (matched by item's track ID).", - "name": "findItemsLoaderByItemId", + "description": "Finds an item in the open dropdown by item id. Returns null if there is no open dropdown. + +This utility does not open the dropdown. To find dropdown items, call \`openDropdown()\` first. + +Supported options: +* \`disabled\` (boolean) - Use it to find the disabled or non-disabled item.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findItemById", + }, + "name": "findItemById", "parameters": [ { - "description": "the (expandable) item ID provided with \`trackBy\` property. - -Note: when used with collection-hooks the \`trackBy\` is set automatically from \`expandableRows.getId\`.", "flags": { "isOptional": false, }, - "name": "itemId", + "name": "id", "typeName": "string", }, + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", + }, ], "returnType": { "isNullable": false, @@ -53370,32 +54282,52 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from }, }, { - "name": "findLoadingText", - "parameters": [], - "returnType": { - "isNullable": false, - "name": "ElementWrapper", + "description": "Finds all the items in the open dropdown. Returns empty array if there is no open dropdown. + +This utility does not open the dropdown. To find dropdown items, call \`openDropdown()\` first. + +Supported options: +* \`disabled\` (boolean) - Use it to find all disabled or non-disabled items.", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findItems", }, - }, - { - "name": "findPagination", - "parameters": [], + "name": "findItems", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, - "name": "PaginationWrapper", + "name": "MultiElementWrapper", + "typeArguments": [ + { + "name": "ElementWrapper", + }, + ], }, }, { - "name": "findPropertyFilter", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findMainAction", + }, + "name": "findMainAction", "parameters": [], "returnType": { "isNullable": false, - "name": "PropertyFilterWrapper", + "name": "ButtonWrapper", }, }, { - "description": "Returns items loader of the root table level.", - "name": "findRootItemsLoader", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findNativeButton", + }, + "name": "findNativeButton", "parameters": [], "returnType": { "isNullable": false, @@ -53403,29 +54335,30 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from }, }, { - "name": "findRows", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findOpenDropdown", + }, + "name": "findOpenDropdown", "parameters": [], "returnType": { "isNullable": false, - "name": "MultiElementWrapper", - "typeArguments": [ - { - "name": "ElementWrapper", - }, - ], + "name": "ElementWrapper", }, }, { - "description": "Returns a row selection area for a given index.", - "name": "findRowSelectionArea", + "description": "Returns the "Remove from sort" menu item. + +Supported options: +* \`disabled\` (boolean) - Use it to find the disabled or non-disabled item.", + "name": "findRemoveFromSortItem", "parameters": [ { - "description": "1-based index of the row selection area to return.", + "defaultValue": "{}", "flags": { "isOptional": false, }, - "name": "rowIndex", - "typeName": "number", + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", }, ], "returnType": { @@ -53434,36 +54367,60 @@ Note: when used with collection-hooks the \`trackBy\` is set automatically from }, }, { - "name": "findSelectAllTrigger", - "parameters": [], + "description": "Returns the "Sort ascending" menu item. + +Supported options: +* \`disabled\` (boolean) - Use it to find the disabled or non-disabled item.", + "name": "findSortAscendingItem", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, "name": "ElementWrapper", }, }, { - "name": "findSelectedRows", - "parameters": [], + "description": "Returns the "Sort descending" menu item. + +Supported options: +* \`disabled\` (boolean) - Use it to find the disabled or non-disabled item.", + "name": "findSortDescendingItem", + "parameters": [ + { + "defaultValue": "{}", + "flags": { + "isOptional": false, + }, + "name": "options", + "typeName": "{ disabled?: boolean | undefined; }", + }, + ], "returnType": { "isNullable": false, - "name": "MultiElementWrapper", - "typeArguments": [ - { - "name": "ElementWrapper", - }, - ], + "name": "ElementWrapper", }, }, { - "name": "findTextFilter", + "inheritedFrom": { + "name": "ButtonDropdownWrapper.findTriggerButton", + }, + "name": "findTriggerButton", "parameters": [], "returnType": { "isNullable": false, - "name": "TextFilterWrapper", + "name": "ButtonWrapper", }, }, ], - "name": "TableWrapper", + "name": "TableColumnSortMenuWrapper", }, { "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..b0220aa79a 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 @@ -661,8 +661,10 @@ exports[`test-utils selectors 1`] = ` "awsui_root_wih1l", "awsui_row-selected_wih1l", "awsui_row_wih1l", + "awsui_sort-priority-badge_1spae", "awsui_table_wih1l", "awsui_thead-active_wih1l", + "awsui_tools-clear-sort_wih1l", "awsui_tools-filtering_wih1l", "awsui_tools-pagination_wih1l", "awsui_tools-preferences_wih1l", diff --git a/src/button-dropdown/__tests__/button-dropdown-compact-trigger.test.tsx b/src/button-dropdown/__tests__/button-dropdown-compact-trigger.test.tsx new file mode 100644 index 0000000000..125ed7fb91 --- /dev/null +++ b/src/button-dropdown/__tests__/button-dropdown-compact-trigger.test.tsx @@ -0,0 +1,27 @@ +// 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 InternalButtonDropdown from '../../../lib/components/button-dropdown/internal'; +import { InternalButtonDropdownProps } from '../../../lib/components/button-dropdown/internal-interfaces'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import styles from '../../../lib/components/button-dropdown/styles.css.js'; + +const items: InternalButtonDropdownProps['items'] = [{ id: 'one', text: 'One' }]; + +function renderDropdown(props: Partial) { + const { container } = render(); + return createWrapper(container).findButtonDropdown()!; +} + +test('applies the compact-trigger class to the trigger button when compactTrigger is set', () => { + const wrapper = renderDropdown({ compactTrigger: true }); + expect(wrapper.findNativeButton().getElement()).toHaveClass(styles['compact-trigger']); +}); + +test('does not apply the compact-trigger class by default', () => { + const wrapper = renderDropdown({}); + expect(wrapper.findNativeButton().getElement()).not.toHaveClass(styles['compact-trigger']); +}); diff --git a/src/button-dropdown/internal-interfaces.ts b/src/button-dropdown/internal-interfaces.ts index 9b75e87c59..840196deb0 100644 --- a/src/button-dropdown/internal-interfaces.ts +++ b/src/button-dropdown/internal-interfaces.ts @@ -141,4 +141,11 @@ export interface InternalButtonDropdownProps * Position of the button dropdown inside a list of elements, for example a button group */ position?: string; + + /** + * Renders the trigger button without vertical padding or borders, matching the compact + * `inline-icon` footprint. Use with `variant="icon"` to get the icon variant's neutral colour + * while keeping an inline footprint (used by the table multi-column sort menu). + */ + compactTrigger?: boolean; } diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index 51d997c21c..9d501ff529 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -77,6 +77,7 @@ const InternalButtonDropdown = React.forwardRef( filteringResultsText, noMatch, i18nStrings, + compactTrigger, ...props }: InternalButtonDropdownProps, ref: React.Ref @@ -361,6 +362,7 @@ const InternalButtonDropdown = React.forwardRef( className={clsx(baseTriggerProps.className, { [styles['full-width']]: canBeFullWidth, [styles.loading]: canBeFullWidth && !!loading, + [styles['compact-trigger']]: compactTrigger, })} badge={triggerHasBadge()} fullWidth={fullWidth} diff --git a/src/button-dropdown/styles.scss b/src/button-dropdown/styles.scss index ff801d17df..b938ab3fa3 100644 --- a/src/button-dropdown/styles.scss +++ b/src/button-dropdown/styles.scss @@ -65,6 +65,15 @@ $dropdown-trigger-icon-offset: 2px; } } +// Removes the trigger's vertical padding and borders so an `icon`-variant trigger matches the +// compact `inline-icon` footprint. The class is doubled to override the button variant styles +// (`.button.variant-icon`), which button-dropdown CSS is loaded after. +.compact-trigger.compact-trigger { + padding-block: 0; + border-block-width: 0; + border-inline-width: 0; +} + .split-trigger-wrapper { display: flex; diff --git a/src/i18n/messages-types.ts b/src/i18n/messages-types.ts index 63da31c470..af668ad823 100644 --- a/src/i18n/messages-types.ts +++ b/src/i18n/messages-types.ts @@ -536,6 +536,24 @@ export interface I18nFormatArgTypes { 'ariaLabels.collapseButtonLabel': never; 'columnDefinitions.editConfig.errorIconAriaLabel': never; 'columnDefinitions.editConfig.editIconAriaLabel': never; + 'i18nStrings.sortDropdown.sortAscending': never; + 'i18nStrings.sortDropdown.sortDescending': never; + 'i18nStrings.sortDropdown.multiColumnSortGroup': never; + 'i18nStrings.sortDropdown.addToSortAscending': never; + 'i18nStrings.sortDropdown.addToSortDescending': never; + 'i18nStrings.sortDropdown.removeFromSort': never; + 'i18nStrings.sortDropdown.addToSortDisabledReason': never; + 'i18nStrings.sortDropdown.removeFromSortDisabledReason': never; + 'i18nStrings.clearSort': never; + 'i18nStrings.liveAnnouncementSortColumn': { + columnLabel: string | number; + isDescending: string; + }; + 'i18nStrings.liveAnnouncementSortOrder': { + columns: string | number; + }; + 'i18nStrings.liveAnnouncementSortCleared': never; + 'ariaLabels.sortMenuTriggerLabel': never; }; tabs: { 'i18nStrings.scrollLeftAriaLabel': never; diff --git a/src/i18n/messages/all.ar.json b/src/i18n/messages/all.ar.json index 87404538d2..e3199d0e56 100644 --- a/src/i18n/messages/all.ar.json +++ b/src/i18n/messages/all.ar.json @@ -408,7 +408,20 @@ "ariaLabels.expandButtonLabel": "توسيع", "ariaLabels.collapseButtonLabel": "تحجيم", "columnDefinitions.editConfig.errorIconAriaLabel": "خطأ", - "columnDefinitions.editConfig.editIconAriaLabel": "قابل للتعديل" + "columnDefinitions.editConfig.editIconAriaLabel": "قابل للتعديل", + "i18nStrings.sortDropdown.sortAscending": "الترتيب تصاعديًّا", + "i18nStrings.sortDropdown.sortDescending": "الترتيب تنازليًّا", + "i18nStrings.sortDropdown.multiColumnSortGroup": "ترتيب متعدد الأعمدة (Shift + Click)", + "i18nStrings.sortDropdown.addToSortAscending": "إضافة إلى الترتيب (تصاعديًا)", + "i18nStrings.sortDropdown.addToSortDescending": "إضافة إلى الترتيب (تنازليًّا)", + "i18nStrings.sortDropdown.removeFromSort": "إزالة من الترتيب", + "i18nStrings.clearSort": "مسح الترتيب", + "ariaLabels.sortMenuTriggerLabel": "خيارات الترتيب", + "i18nStrings.sortDropdown.addToSortDisabledReason": "تم بالفعل ترتيب هذا العمود", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "لم يتم ترتيب هذا العمود", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} تنازلي} other {{columnLabel} تصاعدي}}", + "i18nStrings.liveAnnouncementSortOrder": "تم فرز الجدول حسب {columns}", + "i18nStrings.liveAnnouncementSortCleared": "تم إلغاء الفرز" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "التمرير إلى اليسار", diff --git a/src/i18n/messages/all.de.json b/src/i18n/messages/all.de.json index b6c5549e90..970ddc82c5 100644 --- a/src/i18n/messages/all.de.json +++ b/src/i18n/messages/all.de.json @@ -408,7 +408,20 @@ "ariaLabels.expandButtonLabel": "Erweitern", "ariaLabels.collapseButtonLabel": "Minimieren", "columnDefinitions.editConfig.errorIconAriaLabel": "Fehler", - "columnDefinitions.editConfig.editIconAriaLabel": "bearbeitbar" + "columnDefinitions.editConfig.editIconAriaLabel": "bearbeitbar", + "i18nStrings.sortDropdown.sortAscending": "Aufsteigend sortieren", + "i18nStrings.sortDropdown.sortDescending": "Absteigend sortieren", + "i18nStrings.sortDropdown.multiColumnSortGroup": "Sortieren nach mehreren Spalten (Umschalttaste + Klick)", + "i18nStrings.sortDropdown.addToSortAscending": "Zur Sortierung hinzufügen (aufsteigend)", + "i18nStrings.sortDropdown.addToSortDescending": "Zur Sortierung hinzufügen (absteigend)", + "i18nStrings.sortDropdown.removeFromSort": "Aus Sortierung entfernen", + "i18nStrings.clearSort": "Sortierung löschen", + "ariaLabels.sortMenuTriggerLabel": "Sortieroptionen", + "i18nStrings.sortDropdown.addToSortDisabledReason": "Diese Spalte ist bereits sortiert", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "Diese Spalte ist nicht sortiert", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} absteigend} other {{columnLabel} aufsteigend}}", + "i18nStrings.liveAnnouncementSortOrder": "Tabelle sortiert nach {columns}", + "i18nStrings.liveAnnouncementSortCleared": "Sortierung gelöscht" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "Nach links scrollen", diff --git a/src/i18n/messages/all.en-GB.json b/src/i18n/messages/all.en-GB.json index dd45937e1b..a42ff8cf17 100644 --- a/src/i18n/messages/all.en-GB.json +++ b/src/i18n/messages/all.en-GB.json @@ -408,7 +408,15 @@ "ariaLabels.expandButtonLabel": "Expand", "ariaLabels.collapseButtonLabel": "Collapse", "columnDefinitions.editConfig.errorIconAriaLabel": "Error", - "columnDefinitions.editConfig.editIconAriaLabel": "editable" + "columnDefinitions.editConfig.editIconAriaLabel": "editable", + "i18nStrings.sortDropdown.sortAscending": "Sort ascending", + "i18nStrings.sortDropdown.sortDescending": "Sort descending", + "i18nStrings.sortDropdown.multiColumnSortGroup": "Multi-column sort (Shift + Click)", + "i18nStrings.sortDropdown.addToSortAscending": "Add to sort (ascending)", + "i18nStrings.sortDropdown.addToSortDescending": "Add to sort (descending)", + "i18nStrings.sortDropdown.removeFromSort": "Remove from sort", + "i18nStrings.clearSort": "Clear sort", + "ariaLabels.sortMenuTriggerLabel": "Sort options" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "Scroll left", @@ -495,4 +503,4 @@ "i18nStrings.nextButtonLoadingAnnouncement": "Loading next step", "i18nStrings.submitButtonLoadingAnnouncement": "Submitting form" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.en.json b/src/i18n/messages/all.en.json index e075e74f0d..d92b2435df 100644 --- a/src/i18n/messages/all.en.json +++ b/src/i18n/messages/all.en.json @@ -409,7 +409,20 @@ "ariaLabels.expandButtonLabel": "Expand", "ariaLabels.collapseButtonLabel": "Collapse", "columnDefinitions.editConfig.errorIconAriaLabel": "Error", - "columnDefinitions.editConfig.editIconAriaLabel": "editable" + "columnDefinitions.editConfig.editIconAriaLabel": "editable", + "i18nStrings.sortDropdown.sortAscending": "Sort ascending", + "i18nStrings.sortDropdown.sortDescending": "Sort descending", + "i18nStrings.sortDropdown.multiColumnSortGroup": "Multi-column sort (Shift + Click)", + "i18nStrings.sortDropdown.addToSortAscending": "Add to sort (ascending)", + "i18nStrings.sortDropdown.addToSortDescending": "Add to sort (descending)", + "i18nStrings.sortDropdown.removeFromSort": "Remove from sort", + "i18nStrings.sortDropdown.addToSortDisabledReason": "This column is already sorted", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "This column is not sorted", + "i18nStrings.clearSort": "Clear sort", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} descending} other {{columnLabel} ascending}}", + "i18nStrings.liveAnnouncementSortOrder": "Table sorted by {columns}", + "i18nStrings.liveAnnouncementSortCleared": "Sorting cleared", + "ariaLabels.sortMenuTriggerLabel": "Sort options" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "Scroll left", @@ -496,4 +509,4 @@ "i18nStrings.nextButtonLoadingAnnouncement": "Loading next step", "i18nStrings.submitButtonLoadingAnnouncement": "Submitting form" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/all.es.json b/src/i18n/messages/all.es.json index 1c881aa4b2..6fd22941b0 100644 --- a/src/i18n/messages/all.es.json +++ b/src/i18n/messages/all.es.json @@ -408,7 +408,20 @@ "ariaLabels.expandButtonLabel": "Ampliar", "ariaLabels.collapseButtonLabel": "Contraer", "columnDefinitions.editConfig.errorIconAriaLabel": "Error", - "columnDefinitions.editConfig.editIconAriaLabel": "editable" + "columnDefinitions.editConfig.editIconAriaLabel": "editable", + "i18nStrings.sortDropdown.sortAscending": "Ordenar de forma ascendente", + "i18nStrings.sortDropdown.sortDescending": "Ordenar de forma descendente", + "i18nStrings.sortDropdown.multiColumnSortGroup": "Ordenar por varias columnas (Mayús y clic)", + "i18nStrings.sortDropdown.addToSortAscending": "Agregar a la ordenación (ascendente)", + "i18nStrings.sortDropdown.addToSortDescending": "Agregar a la ordenación (descendente)", + "i18nStrings.sortDropdown.removeFromSort": "Eliminar de la ordenación", + "i18nStrings.clearSort": "Borrar ordenación", + "ariaLabels.sortMenuTriggerLabel": "Opciones de ordenación", + "i18nStrings.sortDropdown.addToSortDisabledReason": "Esta columna ya está ordenada", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "Esta columna no está ordenada", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} en orden descendente} other {{columnLabel} en orden ascendente}}", + "i18nStrings.liveAnnouncementSortOrder": "Tabla ordenada por {columns}", + "i18nStrings.liveAnnouncementSortCleared": "Clasificación eliminada" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "Desplácese hacia la izquierda", diff --git a/src/i18n/messages/all.fr.json b/src/i18n/messages/all.fr.json index c68eff6ad8..35dff89b01 100644 --- a/src/i18n/messages/all.fr.json +++ b/src/i18n/messages/all.fr.json @@ -408,7 +408,20 @@ "ariaLabels.expandButtonLabel": "Élargir", "ariaLabels.collapseButtonLabel": "Réduire", "columnDefinitions.editConfig.errorIconAriaLabel": "Erreur", - "columnDefinitions.editConfig.editIconAriaLabel": "modifiable" + "columnDefinitions.editConfig.editIconAriaLabel": "modifiable", + "i18nStrings.sortDropdown.sortAscending": "Trier par ordre croissant", + "i18nStrings.sortDropdown.sortDescending": "Trier par ordre décroissant", + "i18nStrings.sortDropdown.multiColumnSortGroup": "Tri sur plusieurs colonnes (Maj + clic)", + "i18nStrings.sortDropdown.addToSortAscending": "Ajouter au tri (par ordre croissant)", + "i18nStrings.sortDropdown.addToSortDescending": "Ajouter au tri (par ordre décroissant)", + "i18nStrings.sortDropdown.removeFromSort": "Supprimer du tri", + "i18nStrings.clearSort": "Effacer le tri", + "ariaLabels.sortMenuTriggerLabel": "Options de tri", + "i18nStrings.sortDropdown.addToSortDisabledReason": "Cette colonne est déjà triée", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "Cette colonne n’est pas triée", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} par ordre décroissant} other {{columnLabel} par ordre croissant}}", + "i18nStrings.liveAnnouncementSortOrder": "Tableau trié selon {columns}", + "i18nStrings.liveAnnouncementSortCleared": "Tri supprimé" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "Faire défiler vers la gauche", diff --git a/src/i18n/messages/all.id.json b/src/i18n/messages/all.id.json index 85e6c1272f..c66f185e69 100644 --- a/src/i18n/messages/all.id.json +++ b/src/i18n/messages/all.id.json @@ -408,7 +408,20 @@ "ariaLabels.expandButtonLabel": "Luaskan", "ariaLabels.collapseButtonLabel": "Ciutkan", "columnDefinitions.editConfig.errorIconAriaLabel": "Kesalahan", - "columnDefinitions.editConfig.editIconAriaLabel": "dapat diedit" + "columnDefinitions.editConfig.editIconAriaLabel": "dapat diedit", + "i18nStrings.sortDropdown.sortAscending": "Urutkan naik", + "i18nStrings.sortDropdown.sortDescending": "Urutkan turun", + "i18nStrings.sortDropdown.multiColumnSortGroup": "Urutan multikolom (Shift + Klik)", + "i18nStrings.sortDropdown.addToSortAscending": "Tambahkan untuk mengurutkan (naik)", + "i18nStrings.sortDropdown.addToSortDescending": "Tambahkan untuk mengurutkan (turun)", + "i18nStrings.sortDropdown.removeFromSort": "Hapus dari urutan", + "i18nStrings.clearSort": "Hapus urutan", + "ariaLabels.sortMenuTriggerLabel": "Opsi urutan", + "i18nStrings.sortDropdown.addToSortDisabledReason": "Kolom ini sudah diurutkan", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "Kolom ini tidak diurutkan", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} turun} other {{columnLabel} naik}}", + "i18nStrings.liveAnnouncementSortOrder": "Tabel diurutkan berdasarkan {columns}", + "i18nStrings.liveAnnouncementSortCleared": "Pengurutan dibersihkan" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "Gulir ke kiri", diff --git a/src/i18n/messages/all.it.json b/src/i18n/messages/all.it.json index aee93f3a9d..9ef04c5fa3 100644 --- a/src/i18n/messages/all.it.json +++ b/src/i18n/messages/all.it.json @@ -408,7 +408,20 @@ "ariaLabels.expandButtonLabel": "Espandi", "ariaLabels.collapseButtonLabel": "Comprimi", "columnDefinitions.editConfig.errorIconAriaLabel": "Errore", - "columnDefinitions.editConfig.editIconAriaLabel": "modificabile" + "columnDefinitions.editConfig.editIconAriaLabel": "modificabile", + "i18nStrings.sortDropdown.sortAscending": "Ordina in ordine crescente", + "i18nStrings.sortDropdown.sortDescending": "Ordina in ordine decrescente", + "i18nStrings.sortDropdown.multiColumnSortGroup": "Ordinamento a più colonne (MAIUSC + clic)", + "i18nStrings.sortDropdown.addToSortAscending": "Aggiungi all’ordinamento (crescente)", + "i18nStrings.sortDropdown.addToSortDescending": "Aggiungi all’ordinamento (decrescente)", + "i18nStrings.sortDropdown.removeFromSort": "Rimuovi dall’ordinamento", + "i18nStrings.clearSort": "Cancella l’ordinamento", + "ariaLabels.sortMenuTriggerLabel": "Opzioni di ordinamento", + "i18nStrings.sortDropdown.addToSortDisabledReason": "Questa colonna è già ordinata", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "Questa colonna non è ordinata", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} decrescente} other {{columnLabel} crescente}}", + "i18nStrings.liveAnnouncementSortOrder": "Tabella ordinata per {columns}", + "i18nStrings.liveAnnouncementSortCleared": "Ordinamento cancellato" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "Scorri a sinistra", diff --git a/src/i18n/messages/all.ja.json b/src/i18n/messages/all.ja.json index f4bb9b7308..392517cea8 100644 --- a/src/i18n/messages/all.ja.json +++ b/src/i18n/messages/all.ja.json @@ -408,7 +408,20 @@ "ariaLabels.expandButtonLabel": "展開する", "ariaLabels.collapseButtonLabel": "折りたたむ", "columnDefinitions.editConfig.errorIconAriaLabel": "エラー", - "columnDefinitions.editConfig.editIconAriaLabel": "編集可能" + "columnDefinitions.editConfig.editIconAriaLabel": "編集可能", + "i18nStrings.sortDropdown.sortAscending": "昇順にソート", + "i18nStrings.sortDropdown.sortDescending": "降順にソート", + "i18nStrings.sortDropdown.multiColumnSortGroup": "複数列ソート (Shift + Click)", + "i18nStrings.sortDropdown.addToSortAscending": "ソートに追加 (昇順)", + "i18nStrings.sortDropdown.addToSortDescending": "ソートに追加 (降順)", + "i18nStrings.sortDropdown.removeFromSort": "ソートから削除", + "i18nStrings.clearSort": "ソートをクリア", + "ariaLabels.sortMenuTriggerLabel": "ソートオプション", + "i18nStrings.sortDropdown.addToSortDisabledReason": "この列は既にソートされています", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "この列はソートされていません", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} 降順} other {{columnLabel} 昇順}}", + "i18nStrings.liveAnnouncementSortOrder": "表は {columns}でソートされています", + "i18nStrings.liveAnnouncementSortCleared": "ソートがクリアされました" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "左にスクロール", diff --git a/src/i18n/messages/all.ko.json b/src/i18n/messages/all.ko.json index d5917edce4..ddb7405a44 100644 --- a/src/i18n/messages/all.ko.json +++ b/src/i18n/messages/all.ko.json @@ -408,7 +408,20 @@ "ariaLabels.expandButtonLabel": "확장", "ariaLabels.collapseButtonLabel": "축소", "columnDefinitions.editConfig.errorIconAriaLabel": "오류", - "columnDefinitions.editConfig.editIconAriaLabel": "편집 가능" + "columnDefinitions.editConfig.editIconAriaLabel": "편집 가능", + "i18nStrings.sortDropdown.sortAscending": "오름차순 정렬", + "i18nStrings.sortDropdown.sortDescending": "내림차순 정렬", + "i18nStrings.sortDropdown.multiColumnSortGroup": "복수 열 정렬(Shift 키를 누른 채 클릭)", + "i18nStrings.sortDropdown.addToSortAscending": "정렬에 추가(오름차순)", + "i18nStrings.sortDropdown.addToSortDescending": "정렬에 추가 (내림차순)", + "i18nStrings.sortDropdown.removeFromSort": "정렬에서 제거", + "i18nStrings.clearSort": "정렬 지우기", + "ariaLabels.sortMenuTriggerLabel": "정렬 옵션", + "i18nStrings.sortDropdown.addToSortDisabledReason": "이 열은 이미 정렬되었습니다.", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "이 열은 정렬되지 않았습니다.", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} 내림차순} other {{columnLabel} 오름차순}}", + "i18nStrings.liveAnnouncementSortOrder": "{columns} 기준 테이블 정렬", + "i18nStrings.liveAnnouncementSortCleared": "정렬이 지워졌습니다." }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "왼쪽으로 스크롤", diff --git a/src/i18n/messages/all.pt-BR.json b/src/i18n/messages/all.pt-BR.json index 00b4764fdb..6fbf498f49 100644 --- a/src/i18n/messages/all.pt-BR.json +++ b/src/i18n/messages/all.pt-BR.json @@ -408,7 +408,20 @@ "ariaLabels.expandButtonLabel": "Expandir", "ariaLabels.collapseButtonLabel": "Recolher", "columnDefinitions.editConfig.errorIconAriaLabel": "Erro", - "columnDefinitions.editConfig.editIconAriaLabel": "editável" + "columnDefinitions.editConfig.editIconAriaLabel": "editável", + "i18nStrings.sortDropdown.sortAscending": "Classificar em ordem crescente", + "i18nStrings.sortDropdown.sortDescending": "Classificar em ordem decrescente", + "i18nStrings.sortDropdown.multiColumnSortGroup": "Classificação em várias colunas (Shift + Click)", + "i18nStrings.sortDropdown.addToSortAscending": "Adicionar à classificação (ascendente)", + "i18nStrings.sortDropdown.addToSortDescending": "Adicionar à classificação (decrescente)", + "i18nStrings.sortDropdown.removeFromSort": "Remover da classificação", + "i18nStrings.clearSort": "Limpar classificação", + "ariaLabels.sortMenuTriggerLabel": "Opções de classificação", + "i18nStrings.sortDropdown.addToSortDisabledReason": "Esta coluna já está classificada", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "Esta coluna não está classificada", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} ordem decrescente} other {{columnLabel} ordem crescente}}", + "i18nStrings.liveAnnouncementSortOrder": "Tabela ordenada por {columns}", + "i18nStrings.liveAnnouncementSortCleared": "Classificação cancelada" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "Rolar para a esquerda", diff --git a/src/i18n/messages/all.tr.json b/src/i18n/messages/all.tr.json index 97b3e9093a..8f6c390b4a 100644 --- a/src/i18n/messages/all.tr.json +++ b/src/i18n/messages/all.tr.json @@ -408,7 +408,20 @@ "ariaLabels.expandButtonLabel": "Genişlet", "ariaLabels.collapseButtonLabel": "Daralt", "columnDefinitions.editConfig.errorIconAriaLabel": "Hata", - "columnDefinitions.editConfig.editIconAriaLabel": "düzenlenebilir" + "columnDefinitions.editConfig.editIconAriaLabel": "düzenlenebilir", + "i18nStrings.sortDropdown.sortAscending": "Artan sıralama", + "i18nStrings.sortDropdown.sortDescending": "Azalan sıralama", + "i18nStrings.sortDropdown.multiColumnSortGroup": "Çok sütunlu sıralama (Shift + Tıklama)", + "i18nStrings.sortDropdown.addToSortAscending": "Sıralamaya ekle (artan)", + "i18nStrings.sortDropdown.addToSortDescending": "Sıralamaya ekle (azalan)", + "i18nStrings.sortDropdown.removeFromSort": "Sıralamadan kaldır", + "i18nStrings.clearSort": "Sıralamayı temizle", + "ariaLabels.sortMenuTriggerLabel": "Sıralama seçenekleri", + "i18nStrings.sortDropdown.addToSortDisabledReason": "Bu sütun zaten sıralandı", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "Bu sütun sıralanmadı", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} azalan} other {{columnLabel} artan}}", + "i18nStrings.liveAnnouncementSortOrder": "Tablo {columns} ölçütüne göre sıralandı", + "i18nStrings.liveAnnouncementSortCleared": "Sıralama temizlendi" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "Sola kaydır", diff --git a/src/i18n/messages/all.zh-CN.json b/src/i18n/messages/all.zh-CN.json index b44c097dc0..1aab4cd66b 100644 --- a/src/i18n/messages/all.zh-CN.json +++ b/src/i18n/messages/all.zh-CN.json @@ -408,7 +408,20 @@ "ariaLabels.expandButtonLabel": "展开", "ariaLabels.collapseButtonLabel": "折叠", "columnDefinitions.editConfig.errorIconAriaLabel": "错误", - "columnDefinitions.editConfig.editIconAriaLabel": "可编辑" + "columnDefinitions.editConfig.editIconAriaLabel": "可编辑", + "i18nStrings.sortDropdown.sortAscending": "升序排序", + "i18nStrings.sortDropdown.sortDescending": "降序排序", + "i18nStrings.sortDropdown.multiColumnSortGroup": "多列排序(按住 Shift 键点击)", + "i18nStrings.sortDropdown.addToSortAscending": "添加到排序(升序)", + "i18nStrings.sortDropdown.addToSortDescending": "添加到排序(降序)", + "i18nStrings.sortDropdown.removeFromSort": "从排序中移除", + "i18nStrings.clearSort": "清除排序", + "ariaLabels.sortMenuTriggerLabel": "排序选项", + "i18nStrings.sortDropdown.addToSortDisabledReason": "此列已排序", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "此列未排序", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} 降序} other {{columnLabel} 升序}}", + "i18nStrings.liveAnnouncementSortOrder": "表格已按 {columns} 排序", + "i18nStrings.liveAnnouncementSortCleared": "排序已清除" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "向左滚动", diff --git a/src/i18n/messages/all.zh-TW.json b/src/i18n/messages/all.zh-TW.json index 54d9f4c945..a0ce58ca96 100644 --- a/src/i18n/messages/all.zh-TW.json +++ b/src/i18n/messages/all.zh-TW.json @@ -408,7 +408,20 @@ "ariaLabels.expandButtonLabel": "展開", "ariaLabels.collapseButtonLabel": "摺疊", "columnDefinitions.editConfig.errorIconAriaLabel": "錯誤", - "columnDefinitions.editConfig.editIconAriaLabel": "可編輯" + "columnDefinitions.editConfig.editIconAriaLabel": "可編輯", + "i18nStrings.sortDropdown.sortAscending": "遞增排序", + "i18nStrings.sortDropdown.sortDescending": "遞減排序", + "i18nStrings.sortDropdown.multiColumnSortGroup": "多欄排序 (Shift + 按一下)", + "i18nStrings.sortDropdown.addToSortAscending": "新增至排序 (遞增)", + "i18nStrings.sortDropdown.addToSortDescending": "新增至排序 (遞減)", + "i18nStrings.sortDropdown.removeFromSort": "從排序中移除", + "i18nStrings.clearSort": "清除排序", + "ariaLabels.sortMenuTriggerLabel": "排序選項", + "i18nStrings.sortDropdown.addToSortDisabledReason": "此欄已排序", + "i18nStrings.sortDropdown.removeFromSortDisabledReason": "此欄未排序", + "i18nStrings.liveAnnouncementSortColumn": "{isDescending, select, true {{columnLabel} 遞減} other {{columnLabel} 遞增}}", + "i18nStrings.liveAnnouncementSortOrder": "資料表依 {columns} 排序", + "i18nStrings.liveAnnouncementSortCleared": "已清除排序" }, "tabs": { "i18nStrings.scrollLeftAriaLabel": "向左捲動", diff --git a/src/table/__tests__/multi-column-sort-behavior.test.tsx b/src/table/__tests__/multi-column-sort-behavior.test.tsx new file mode 100644 index 0000000000..c9401c5b5a --- /dev/null +++ b/src/table/__tests__/multi-column-sort-behavior.test.tsx @@ -0,0 +1,270 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; +import { KeyCode } from '@cloudscape-design/test-utils-core/utils'; + +import Table from '../../../lib/components/table'; +import { TableProps } from '../../../lib/components/table/interfaces'; +import createWrapper, { TableWrapper } from '../../../lib/components/test-utils/dom'; + +interface Item { + a: string; + b: string; + c: string; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'a', header: 'A', cell: i => i.a, sortingField: 'a' }, + { id: 'b', header: 'B', cell: i => i.b, sortingField: 'b' }, + { id: 'c', header: 'C', cell: i => i.c, sortingField: 'c' }, +]; + +const items: Item[] = [{ a: '1', b: '2', c: '3' }]; + +// 1-based column indices +const COL_A = 1; +const COL_B = 2; +const COL_C = 3; + +type Sort = ReadonlyArray>; + +function renderTable(sortingColumns: Sort, onChange = jest.fn(), extraColumns?: TableProps.ColumnDefinition[]) { + const { container } = render( +
+ ); + return { wrapper: createWrapper(container).findTable()!, onChange }; +} + +// Extracts the emitted sort state as a comparable [{ field, desc }] array. +function emitted(onChange: jest.Mock) { + const detail = onChange.mock.calls[onChange.mock.calls.length - 1][0] + .detail as TableProps.MultiColumnSortChangeDetail; + return detail.sortingColumns.map(s => ({ field: s.sortingColumn.sortingField, desc: !!s.isDescending })); +} + +function clickHeader(wrapper: TableWrapper, colIndex: number, shiftKey = false) { + fireEvent.click(wrapper.findColumnSortingArea(colIndex)!.getElement(), { shiftKey }); +} + +function getHeaderCell(wrapper: TableWrapper, colIndex: number) { + return wrapper.findColumnHeaders()[colIndex - 1]!.getElement(); +} + +describe('header click / keyboard', () => { + test('plain click on an unsorted column replaces the sort (ascending)', () => { + const { wrapper, onChange } = renderTable([]); + clickHeader(wrapper, COL_A); + expect(emitted(onChange)).toEqual([{ field: 'a', desc: false }]); + }); + + test('plain click on a column already in the sort toggles its direction', () => { + const { wrapper, onChange } = renderTable([{ sortingColumn: { sortingField: 'a' }, isDescending: false }]); + clickHeader(wrapper, COL_A); + expect(emitted(onChange)).toEqual([{ field: 'a', desc: true }]); + }); + + test('Shift+click appends a new column to the sort', () => { + const { wrapper, onChange } = renderTable([{ sortingColumn: { sortingField: 'a' }, isDescending: false }]); + clickHeader(wrapper, COL_B, true); + expect(emitted(onChange)).toEqual([ + { field: 'a', desc: false }, + { field: 'b', desc: false }, + ]); + }); + + test('Enter triggers the same behavior as a click', () => { + const { wrapper, onChange } = renderTable([]); + fireEvent.keyPress(wrapper.findColumnSortingArea(COL_A)!.getElement(), { keyCode: KeyCode.enter }); + expect(emitted(onChange)).toEqual([{ field: 'a', desc: false }]); + }); + + test('Shift+Enter appends like Shift+click', () => { + const { wrapper, onChange } = renderTable([{ sortingColumn: { sortingField: 'a' }, isDescending: false }]); + fireEvent.keyPress(wrapper.findColumnSortingArea(COL_B)!.getElement(), { keyCode: KeyCode.enter, shiftKey: true }); + expect(emitted(onChange)).toEqual([ + { field: 'a', desc: false }, + { field: 'b', desc: false }, + ]); + }); + + test('Shift+click on a column already in the sort toggles it in place (does not duplicate)', () => { + const { wrapper, onChange } = renderTable([{ sortingColumn: { sortingField: 'a' }, isDescending: false }]); + clickHeader(wrapper, COL_A, true); + expect(emitted(onChange)).toEqual([{ field: 'a', desc: true }]); + }); + + test('Shift+mousedown prevents default to avoid extending the text selection', () => { + const { wrapper } = renderTable([]); + // fireEvent returns false when the event's default action was prevented. + const notPreventedWithShift = fireEvent.mouseDown(wrapper.findColumnSortingArea(COL_A)!.getElement(), { + shiftKey: true, + }); + expect(notPreventedWithShift).toBe(false); + // A plain mousedown must not prevent default. + const notPreventedPlain = fireEvent.mouseDown(wrapper.findColumnSortingArea(COL_A)!.getElement()); + expect(notPreventedPlain).toBe(true); + }); +}); + +describe('sort menu dropdown actions', () => { + function openAndClick( + wrapper: TableWrapper, + colIndex: number, + click: (menu: ReturnType) => void + ) { + const menu = wrapper.findColumnSortMenu(colIndex)!; + menu.openDropdown(); + click(menu); + } + + test('"Add to sort (descending)" appends the column descending', () => { + const { wrapper, onChange } = renderTable([{ sortingColumn: { sortingField: 'a' }, isDescending: false }]); + openAndClick(wrapper, COL_B, menu => menu!.findAddToSortDescendingItem()!.click()); + expect(emitted(onChange)).toEqual([ + { field: 'a', desc: false }, + { field: 'b', desc: true }, + ]); + }); + + test('"Remove from sort" removes the column and keeps the rest', () => { + const { wrapper, onChange } = renderTable([ + { sortingColumn: { sortingField: 'a' }, isDescending: false }, + { sortingColumn: { sortingField: 'b' }, isDescending: true }, + ]); + openAndClick(wrapper, COL_A, menu => menu!.findRemoveFromSortItem()!.click()); + expect(emitted(onChange)).toEqual([{ field: 'b', desc: true }]); + }); + + test('"Sort descending" on a column not in the sort replaces the whole sort', () => { + const { wrapper, onChange } = renderTable([{ sortingColumn: { sortingField: 'b' }, isDescending: false }]); + openAndClick(wrapper, COL_A, menu => menu!.findSortDescendingItem()!.click()); + expect(emitted(onChange)).toEqual([{ field: 'a', desc: true }]); + }); + + test('"Sort ascending" on a column not in the sort replaces the whole sort', () => { + const { wrapper, onChange } = renderTable([{ sortingColumn: { sortingField: 'b' }, isDescending: true }]); + openAndClick(wrapper, COL_A, menu => menu!.findSortAscendingItem()!.click()); + expect(emitted(onChange)).toEqual([{ field: 'a', desc: false }]); + }); + + test('"Sort ascending" on a column already in the sort sets its direction and keeps the others', () => { + const { wrapper, onChange } = renderTable([ + { sortingColumn: { sortingField: 'a' }, isDescending: true }, + { sortingColumn: { sortingField: 'b' }, isDescending: true }, + ]); + openAndClick(wrapper, COL_A, menu => menu!.findSortAscendingItem()!.click()); + expect(emitted(onChange)).toEqual([ + { field: 'a', desc: false }, + { field: 'b', desc: true }, + ]); + }); + + test('"Add to sort (ascending)" appends the column ascending', () => { + const { wrapper, onChange } = renderTable([{ sortingColumn: { sortingField: 'a' }, isDescending: false }]); + openAndClick(wrapper, COL_B, menu => menu!.findAddToSortAscendingItem()!.click()); + expect(emitted(onChange)).toEqual([ + { field: 'a', desc: false }, + { field: 'b', desc: false }, + ]); + }); +}); + +describe('aria-sort', () => { + test('declares aria-sort only on the primary sorted column; secondaries are suppressed; unsorted are "none"', () => { + const { wrapper } = renderTable([ + { sortingColumn: { sortingField: 'a' }, isDescending: false }, + { sortingColumn: { sortingField: 'b' }, isDescending: true }, + ]); + expect(getHeaderCell(wrapper, COL_A).getAttribute('aria-sort')).toBe('ascending'); + // ARIA permits only one sorted column, so secondary sorted columns omit aria-sort entirely. + expect(getHeaderCell(wrapper, COL_B).getAttribute('aria-sort')).toBeNull(); + expect(getHeaderCell(wrapper, COL_C).getAttribute('aria-sort')).toBe('none'); + }); +}); + +describe('column ariaLabel sortIndex', () => { + test('receives the 1-based priority for sorted columns and undefined for unsorted', () => { + const labelColumns: TableProps.ColumnDefinition[] = [ + { id: 'a', header: 'A', cell: i => i.a, sortingField: 'a', ariaLabel: ({ sortIndex }) => `A idx=${sortIndex}` }, + { id: 'b', header: 'B', cell: i => i.b, sortingField: 'b', ariaLabel: ({ sortIndex }) => `B idx=${sortIndex}` }, + { id: 'c', header: 'C', cell: i => i.c, sortingField: 'c', ariaLabel: ({ sortIndex }) => `C idx=${sortIndex}` }, + ]; + const { wrapper } = renderTable( + [ + { sortingColumn: { sortingField: 'a' }, isDescending: false }, + { sortingColumn: { sortingField: 'b' }, isDescending: true }, + ], + jest.fn(), + labelColumns + ); + expect(wrapper.findColumnSortingArea(COL_A)!.getElement().getAttribute('aria-label')).toBe('A idx=1'); + expect(wrapper.findColumnSortingArea(COL_B)!.getElement().getAttribute('aria-label')).toBe('B idx=2'); + expect(wrapper.findColumnSortingArea(COL_C)!.getElement().getAttribute('aria-label')).toBe('C idx=undefined'); + }); +}); + +describe('sort menu gating', () => { + test('non-sortable columns do not render a sort menu', () => { + const mixed: TableProps.ColumnDefinition[] = [ + { id: 'a', header: 'A', cell: i => i.a, sortingField: 'a' }, + { id: 'b', header: 'B', cell: i => i.b }, // no sortingField -> not sortable + ]; + const { wrapper } = renderTable([], jest.fn(), mixed); + expect(wrapper.findColumnSortMenu(COL_A)).not.toBeNull(); + expect(wrapper.findColumnSortMenu(COL_B)).toBeNull(); + }); +}); + +describe('sort menu item states', () => { + function openMenu(wrapper: TableWrapper, colIndex: number) { + const menu = wrapper.findColumnSortMenu(colIndex)!; + menu.openDropdown(); + return menu; + } + + const ariaChecked = (item: ElementWrapper) => + item.find('[role="menuitemcheckbox"]')!.getElement().getAttribute('aria-checked'); + + test('marks the current direction as checked for a column sorted ascending', () => { + const { wrapper } = renderTable([{ sortingColumn: { sortingField: 'a' }, isDescending: false }]); + const menu = openMenu(wrapper, COL_A); + expect(ariaChecked(menu.findSortAscendingItem()!)).toBe('true'); + expect(ariaChecked(menu.findSortDescendingItem()!)).toBe('false'); + }); + + test('marks descending as checked for a column sorted descending', () => { + const { wrapper } = renderTable([{ sortingColumn: { sortingField: 'a' }, isDescending: true }]); + const menu = openMenu(wrapper, COL_A); + expect(ariaChecked(menu.findSortDescendingItem()!)).toBe('true'); + expect(ariaChecked(menu.findSortAscendingItem()!)).toBe('false'); + }); + + test('leaves both direction items unchecked for a column that is not sorted', () => { + const { wrapper } = renderTable([{ sortingColumn: { sortingField: 'b' }, isDescending: false }]); + const menu = openMenu(wrapper, COL_A); + expect(ariaChecked(menu.findSortAscendingItem()!)).toBe('false'); + expect(ariaChecked(menu.findSortDescendingItem()!)).toBe('false'); + }); + + test('disables the "Add to sort" items (and enables "Remove from sort") when the column is in the sort', () => { + const { wrapper } = renderTable([{ sortingColumn: { sortingField: 'a' }, isDescending: false }]); + const menu = openMenu(wrapper, COL_A); + expect(menu.findAddToSortAscendingItem({ disabled: true })).not.toBeNull(); + expect(menu.findAddToSortDescendingItem({ disabled: true })).not.toBeNull(); + expect(menu.findRemoveFromSortItem({ disabled: true })).toBeNull(); + }); + + test('disables "Remove from sort" (and enables "Add to sort") when the column is not in the sort', () => { + const { wrapper } = renderTable([{ sortingColumn: { sortingField: 'b' }, isDescending: false }]); + const menu = openMenu(wrapper, COL_A); + expect(menu.findRemoveFromSortItem({ disabled: true })).not.toBeNull(); + expect(menu.findAddToSortAscendingItem({ disabled: true })).toBeNull(); + }); +}); diff --git a/src/table/__tests__/multi-column-sort-clear.test.tsx b/src/table/__tests__/multi-column-sort-clear.test.tsx new file mode 100644 index 0000000000..55ba7c2814 --- /dev/null +++ b/src/table/__tests__/multi-column-sort-clear.test.tsx @@ -0,0 +1,121 @@ +// 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 Pagination from '../../../lib/components/pagination'; +import Table from '../../../lib/components/table'; +import { TableProps } from '../../../lib/components/table/interfaces'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +import tableStyles from '../../../lib/components/table/styles.css.js'; + +interface Item { + id: string; + name: string; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'name', header: 'Name', cell: item => item.name, sortingField: 'name' }, + { id: 'id', header: 'Id', cell: item => item.id, sortingField: 'id' }, +]; + +const items: Item[] = [ + { id: '1', name: 'alpha' }, + { id: '2', name: 'beta' }, +]; + +const activeSort: ReadonlyArray> = [ + { sortingColumn: { sortingField: 'name' }, isDescending: false }, +]; + +function renderTable(jsx: React.ReactElement) { + const { container } = render(jsx); + return { wrapper: createWrapper(container).findTable()!, container }; +} + +test('renders a Clear sort button when multiColumnSort is active on at least one column', () => { + const { wrapper } = renderTable( +
{} }} + /> + ); + // The label text itself comes from the i18n provider (no hardcoded fallback in the component), + // so here we only assert the button is rendered. Label text is covered by the i18nStrings test below. + expect(wrapper.findClearSort()).not.toBeNull(); +}); + +test('uses the i18nStrings.clearSort label when provided', () => { + const { wrapper } = renderTable( +
{} }} + i18nStrings={{ clearSort: 'Reset sorting' }} + /> + ); + expect(wrapper.findClearSort()!.getElement()).toHaveTextContent('Reset sorting'); +}); + +test('does not render the Clear sort button when no column is sorted', () => { + const { wrapper } = renderTable( +
{} }} + /> + ); + expect(wrapper.findClearSort()).toBeNull(); +}); + +test('does not render the Clear sort button for single-column (non-multi) sorting', () => { + const { wrapper } = renderTable( +
+ ); + expect(wrapper.findClearSort()).toBeNull(); +}); + +test('clicking Clear sort fires onChange with an empty sorting state', () => { + const onChange = jest.fn(); + const { wrapper } = renderTable( +
+ ); + wrapper.findClearSort()!.click(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].detail).toEqual({ sortingColumns: [] }); +}); + +test('moves focus to the first sortable column header after clearing sort (avoids losing focus to the body)', () => { + const { wrapper } = renderTable( +
{} }} + /> + ); + wrapper.findClearSort()!.click(); + // Focus should land on the first sortable column's sort control, not fall back to document.body. + expect(document.activeElement).toHaveAttribute('data-focus-id', 'sorting-control-name'); +}); + +test('renders the Clear sort button before the pagination slot', () => { + const { container } = renderTable( +
{} }} + pagination={} + /> + ); + const clearSort = container.querySelector(`.${tableStyles['tools-clear-sort']}`)!; + const pagination = container.querySelector(`.${tableStyles['tools-pagination']}`)!; + expect(clearSort).not.toBeNull(); + expect(pagination).not.toBeNull(); + expect(clearSort.compareDocumentPosition(pagination) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); +}); diff --git a/src/table/__tests__/multi-column-sort-live-announcement.test.tsx b/src/table/__tests__/multi-column-sort-live-announcement.test.tsx new file mode 100644 index 0000000000..fab6ed8d41 --- /dev/null +++ b/src/table/__tests__/multi-column-sort-live-announcement.test.tsx @@ -0,0 +1,101 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { render, waitFor } from '@testing-library/react'; + +import { I18nProvider } from '../../../lib/components/i18n'; +import messages from '../../../lib/components/i18n/messages/all.en'; +import Table from '../../../lib/components/table'; +import { TableProps } from '../../../lib/components/table/interfaces'; + +interface Item { + id: string; + name: string; +} + +const items: Item[] = [ + { id: '1', name: 'alpha' }, + { id: '2', name: 'beta' }, +]; + +// ReactNode (non-string) headers, like a translation component, so the announcement can only produce +// these names by reading the rendered DOM text — not `columnDefinitions[].header`. +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'name', header: Rendered name, cell: item => item.name, sortingField: 'name' }, + { id: 'id', header: Rendered id, cell: item => item.id, sortingField: 'id' }, +]; + +const i18nStrings: TableProps.I18nStrings = { + liveAnnouncementSortColumn: ({ columnLabel, isDescending }) => + `${columnLabel} ${isDescending ? 'descending' : 'ascending'}`, + liveAnnouncementSortOrder: ({ columns }) => `Table sorted by ${columns}`, + liveAnnouncementSortCleared: 'Sorting cleared', +}; + +function TableWithSort({ sortingColumns }: { sortingColumns: ReadonlyArray> }) { + return ( +
{} }} + i18nStrings={i18nStrings} + /> + ); +} + +const nameAsc: TableProps.SortingState = { sortingColumn: { sortingField: 'name' }, isDescending: false }; +const idDesc: TableProps.SortingState = { sortingColumn: { sortingField: 'id' }, isDescending: true }; + +test('does not announce the initial sort state on mount', () => { + const { container } = render(); + expect(container.textContent).not.toContain('Table sorted by'); +}); + +test('announces a sort change using the rendered (DOM) header text', async () => { + const { container, rerender } = render(); + rerender(); + await waitFor(() => + expect(container.textContent).toContain('Table sorted by Rendered name ascending, Rendered id descending') + ); +}); + +test('announces when sorting is cleared', async () => { + const { container, rerender } = render(); + rerender(); + await waitFor(() => expect(container.textContent).toContain('Sorting cleared')); +}); + +test('resolves announcement strings from the i18n provider when no i18nStrings functions are given', async () => { + const renderWithProvider = (sortingColumns: ReadonlyArray>) => ( + +
{} }} + /> + + ); + const { container, rerender } = render(renderWithProvider([])); + rerender(renderWithProvider([nameAsc, idDesc])); + await waitFor(() => { + expect(container.textContent).toContain('Rendered name ascending'); + expect(container.textContent).toContain('Rendered id descending'); + }); +}); + +test('falls back to the sorting field when the sorted column has no id', async () => { + const noIdColumns: TableProps.ColumnDefinition[] = [ + { header: Rendered name, cell: item => item.name, sortingField: 'name' }, + ]; + const renderNoId = (sortingColumns: ReadonlyArray>) => ( +
{} }} + i18nStrings={i18nStrings} + /> + ); + const { container, rerender } = render(renderNoId([])); + rerender(renderNoId([nameAsc])); + await waitFor(() => expect(container.textContent).toContain('Table sorted by name ascending')); +}); diff --git a/src/table/__tests__/multi-column-sort-test-utils.test.tsx b/src/table/__tests__/multi-column-sort-test-utils.test.tsx new file mode 100644 index 0000000000..146d949a3f --- /dev/null +++ b/src/table/__tests__/multi-column-sort-test-utils.test.tsx @@ -0,0 +1,119 @@ +// 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 from '../../../lib/components/table'; +import { TableProps } from '../../../lib/components/table/interfaces'; +import createWrapper from '../../../lib/components/test-utils/dom'; + +interface Item { + id: string; + name: string; + state: string; + cpu: number; +} + +const columnDefinitions: TableProps.ColumnDefinition[] = [ + { id: 'name', header: 'Name', cell: item => item.name, sortingField: 'name' }, + { id: 'type', header: 'Type', cell: item => item.id, sortingField: 'type' }, + { id: 'state', header: 'State', cell: item => item.state, sortingField: 'state' }, + { id: 'cpu', header: 'CPU', cell: item => item.cpu, sortingField: 'cpu' }, +]; + +const items: Item[] = [ + { id: '1', name: 'alpha', state: 'running', cpu: 12 }, + { id: '2', name: 'beta', state: 'stopped', cpu: 0 }, +]; + +// Column indices are 1-based: name=1, type=2, state=3, cpu=4. +const STATE_COL = 3; +const CPU_COL = 4; +const NAME_COL = 1; + +function renderTable(jsx: React.ReactElement) { + const { container } = render(jsx); + return createWrapper(container).findTable()!; +} + +function renderMultiSort(sortingColumns: ReadonlyArray>) { + return renderTable( +
{} }} + /> + ); +} + +// state (asc, priority 1) + cpu (desc, priority 2) +const twoColumnSort: ReadonlyArray> = [ + { sortingColumn: { sortingField: 'state' }, isDescending: false }, + { sortingColumn: { sortingField: 'cpu' }, isDescending: true }, +]; + +describe('findColumnSortMenu', () => { + test('returns a sort menu wrapper for a sortable column when multiColumnSort is set', () => { + const wrapper = renderMultiSort(twoColumnSort); + expect(wrapper.findColumnSortMenu(STATE_COL)).not.toBeNull(); + }); + + test('returns null when the table does not opt in to multi-column sorting', () => { + const wrapper = renderTable(
); + expect(wrapper.findColumnSortMenu(STATE_COL)).toBeNull(); + }); + + test('exposes a named finder for every sort menu item once the dropdown is open', () => { + const wrapper = renderMultiSort(twoColumnSort); + const menu = wrapper.findColumnSortMenu(NAME_COL)!; + menu.openDropdown(); + + expect(menu.findSortAscendingItem()).not.toBeNull(); + expect(menu.findSortDescendingItem()).not.toBeNull(); + expect(menu.findAddToSortAscendingItem()).not.toBeNull(); + expect(menu.findAddToSortDescendingItem()).not.toBeNull(); + expect(menu.findRemoveFromSortItem()).not.toBeNull(); + }); + + test('reflects disabled state for a column already in the sort', () => { + const wrapper = renderMultiSort(twoColumnSort); + const menu = wrapper.findColumnSortMenu(STATE_COL)!; + menu.openDropdown(); + + // Sort ascending/descending are always-enabled checkboxes; their checked state is asserted in the behavior test. + expect(menu.findSortAscendingItem({ disabled: false })).not.toBeNull(); + expect(menu.findSortDescendingItem({ disabled: false })).not.toBeNull(); + expect(menu.findAddToSortAscendingItem({ disabled: true })).not.toBeNull(); + expect(menu.findAddToSortDescendingItem({ disabled: true })).not.toBeNull(); + expect(menu.findRemoveFromSortItem({ disabled: false })).not.toBeNull(); + }); + + test('reflects disabled state for a column not in the sort', () => { + const wrapper = renderMultiSort(twoColumnSort); + const menu = wrapper.findColumnSortMenu(NAME_COL)!; + menu.openDropdown(); + + expect(menu.findSortAscendingItem({ disabled: false })).not.toBeNull(); + expect(menu.findSortDescendingItem({ disabled: false })).not.toBeNull(); + expect(menu.findAddToSortAscendingItem({ disabled: false })).not.toBeNull(); + expect(menu.findRemoveFromSortItem({ disabled: true })).not.toBeNull(); + }); +}); + +describe('findColumnSortPriorityBadge', () => { + test('shows the 1-based priority on each sorted column when 2+ columns are sorted', () => { + const wrapper = renderMultiSort(twoColumnSort); + expect(wrapper.findColumnSortPriorityBadge(STATE_COL)!.getElement()).toHaveTextContent('1'); + expect(wrapper.findColumnSortPriorityBadge(CPU_COL)!.getElement()).toHaveTextContent('2'); + }); + + test('returns null for a column that is not part of the sort', () => { + const wrapper = renderMultiSort(twoColumnSort); + expect(wrapper.findColumnSortPriorityBadge(NAME_COL)).toBeNull(); + }); + + test('returns null when only a single column is sorted (priority adds no information)', () => { + const wrapper = renderMultiSort([{ sortingColumn: { sortingField: 'state' }, isDescending: false }]); + expect(wrapper.findColumnSortPriorityBadge(STATE_COL)).toBeNull(); + }); +}); diff --git a/src/table/__tests__/multi-column-sort-utils.test.ts b/src/table/__tests__/multi-column-sort-utils.test.ts new file mode 100644 index 0000000000..e999564bbc --- /dev/null +++ b/src/table/__tests__/multi-column-sort-utils.test.ts @@ -0,0 +1,216 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { TableProps } from '../../../lib/components/table/interfaces'; +import { + appendSort, + buildSortLiveAnnouncement, + getSortIndex, + removeSort, + replaceSort, + setDirection, + toggleDirection, +} from '../../../lib/components/table/multi-column-sort/utils'; + +interface Item { + a: number; + b: number; +} + +const colA: TableProps.ColumnDefinition = { id: 'a', header: 'A', cell: i => i.a, sortingField: 'a' }; +const colB: TableProps.ColumnDefinition = { id: 'b', header: 'B', cell: i => i.b, sortingField: 'b' }; +const comparator = (x: Item, y: Item) => x.a - y.a; +const colComparator: TableProps.ColumnDefinition = { + id: 'c', + header: 'C', + cell: i => i.a, + sortingComparator: comparator, +}; + +type Sort = ReadonlyArray>; + +describe('getSortIndex', () => { + test('returns null when the column is not in the sort', () => { + expect(getSortIndex([], colA)).toBeNull(); + expect(getSortIndex([{ sortingColumn: colB, isDescending: false }], colA)).toBeNull(); + }); + + test('returns the 1-based priority index', () => { + const sort: Sort = [ + { sortingColumn: colB, isDescending: false }, + { sortingColumn: colA, isDescending: true }, + ]; + expect(getSortIndex(sort, colB)).toBe(1); + expect(getSortIndex(sort, colA)).toBe(2); + }); + + test('matches by sortingField even with a different column-definition object', () => { + const sort: Sort = [{ sortingColumn: { sortingField: 'a' }, isDescending: false }]; + expect(getSortIndex(sort, colA)).toBe(1); + }); + + test('matches by sortingComparator reference', () => { + const sort: Sort = [{ sortingColumn: { sortingComparator: comparator }, isDescending: false }]; + expect(getSortIndex(sort, colComparator)).toBe(1); + }); + + test('matches by object identity', () => { + const sort: Sort = [{ sortingColumn: colA, isDescending: false }]; + expect(getSortIndex(sort, colA)).toBe(1); + }); +}); + +describe('replaceSort', () => { + test('returns a single-element array with the given column and direction', () => { + expect(replaceSort(colA, true)).toEqual([{ sortingColumn: colA, isDescending: true }]); + expect(replaceSort(colB, false)).toEqual([{ sortingColumn: colB, isDescending: false }]); + }); +}); + +describe('appendSort', () => { + test('appends at the end, preserving existing entries and order', () => { + const current: Sort = [{ sortingColumn: colA, isDescending: false }]; + expect(appendSort(current, colB, true)).toEqual([ + { sortingColumn: colA, isDescending: false }, + { sortingColumn: colB, isDescending: true }, + ]); + }); + + test('does not mutate the input array', () => { + const current: Sort = [{ sortingColumn: colA, isDescending: false }]; + appendSort(current, colB, false); + expect(current).toHaveLength(1); + }); +}); + +describe('toggleDirection', () => { + test('flips only the matching column, keeping position and other entries', () => { + const current: Sort = [ + { sortingColumn: colA, isDescending: false }, + { sortingColumn: colB, isDescending: true }, + ]; + expect(toggleDirection(current, colA)).toEqual([ + { sortingColumn: colA, isDescending: true }, + { sortingColumn: colB, isDescending: true }, + ]); + }); + + test('is a no-op when the column is not present', () => { + const current: Sort = [{ sortingColumn: colB, isDescending: false }]; + expect(toggleDirection(current, colA)).toEqual(current); + }); +}); + +describe('setDirection', () => { + test('sets the matching column to the explicit direction', () => { + const current: Sort = [{ sortingColumn: colA, isDescending: false }]; + expect(setDirection(current, colA, true)).toEqual([{ sortingColumn: colA, isDescending: true }]); + }); + + test('is idempotent when already at the target direction', () => { + const current: Sort = [{ sortingColumn: colA, isDescending: true }]; + expect(setDirection(current, colA, true)).toEqual(current); + }); +}); + +describe('removeSort', () => { + test('removes the matching column and keeps the rest in order', () => { + const current: Sort = [ + { sortingColumn: colA, isDescending: false }, + { sortingColumn: colB, isDescending: true }, + ]; + expect(removeSort(current, colA)).toEqual([{ sortingColumn: colB, isDescending: true }]); + }); + + test('is a no-op when the column is not present', () => { + const current: Sort = [{ sortingColumn: colB, isDescending: false }]; + expect(removeSort(current, colA)).toEqual(current); + }); + + test('removes by sortingField match', () => { + const current: Sort = [{ sortingColumn: { sortingField: 'a' }, isDescending: false }]; + expect(removeSort(current, colA)).toEqual([]); + }); +}); + +describe('buildSortLiveAnnouncement', () => { + const renderSortColumn = ({ columnLabel, isDescending }: { columnLabel: string; isDescending: boolean }) => + `${columnLabel} ${isDescending ? 'desc' : 'asc'}`; + const renderSortOrder = ({ columns }: { columns: string }) => `sorted by ${columns}`; + const columnDefinitions = [colA, colB, colComparator]; + const base = { columnDefinitions, renderSortColumn, renderSortOrder, sortCleared: 'cleared' }; + + test('returns the cleared string when there is no active sort', () => { + expect(buildSortLiveAnnouncement({ ...base, sortingColumns: [] })).toBe('cleared'); + }); + + test('returns an empty string when there is no sort and no cleared string', () => { + expect(buildSortLiveAnnouncement({ ...base, sortCleared: undefined, sortingColumns: [] })).toBe(''); + }); + + test('returns an empty string when the render functions are missing', () => { + const sortingColumns: Sort = [{ sortingColumn: colA, isDescending: false }]; + expect(buildSortLiveAnnouncement({ columnDefinitions, sortingColumns, renderSortOrder })).toBe(''); + expect(buildSortLiveAnnouncement({ columnDefinitions, sortingColumns, renderSortColumn })).toBe(''); + }); + + test('does not throw when sortingColumns is omitted', () => { + expect(buildSortLiveAnnouncement({ ...base } as Parameters[0])).toBe('cleared'); + }); + + test('joins per-column fragments (default comma join) and wraps them', () => { + const sortingColumns: Sort = [ + { sortingColumn: colA, isDescending: false }, + { sortingColumn: colB, isDescending: true }, + ]; + expect(buildSortLiveAnnouncement({ ...base, sortingColumns })).toBe('sorted by A asc, B desc'); + }); + + test('uses the provided list formatter instead of the default comma join', () => { + const sortingColumns: Sort = [ + { sortingColumn: colA, isDescending: false }, + { sortingColumn: colB, isDescending: true }, + ]; + const formatList = (parts: readonly string[]) => parts.join(' | '); + expect(buildSortLiveAnnouncement({ ...base, sortingColumns, formatList })).toBe('sorted by A asc | B desc'); + }); + + test('prefers resolveColumnLabel over the column-definition label', () => { + const sortingColumns: Sort = [{ sortingColumn: colA, isDescending: false }]; + expect(buildSortLiveAnnouncement({ ...base, sortingColumns, resolveColumnLabel: () => 'Resolved' })).toBe( + 'sorted by Resolved asc' + ); + }); + + test('falls back to the column-definition label when resolveColumnLabel returns undefined', () => { + const sortingColumns: Sort = [{ sortingColumn: colA, isDescending: false }]; + expect(buildSortLiveAnnouncement({ ...base, sortingColumns, resolveColumnLabel: () => undefined })).toBe( + 'sorted by A asc' + ); + }); + + test('falls back to sortingField when the header is not a string', () => { + const nodeHeaderColumn: TableProps.ColumnDefinition = { + id: 'a', + header: 5 as unknown as TableProps.ColumnDefinition['header'], + cell: i => i.a, + sortingField: 'a', + }; + const sortingColumns: Sort = [{ sortingColumn: nodeHeaderColumn, isDescending: true }]; + expect(buildSortLiveAnnouncement({ ...base, columnDefinitions: [nodeHeaderColumn], sortingColumns })).toBe( + 'sorted by a desc' + ); + }); + + test('falls back to the column id for comparator columns with a non-string header', () => { + const nodeHeaderComparator: TableProps.ColumnDefinition = { + id: 'c', + header: 5 as unknown as TableProps.ColumnDefinition['header'], + cell: i => i.a, + sortingComparator: comparator, + }; + const sortingColumns: Sort = [{ sortingColumn: nodeHeaderComparator, isDescending: false }]; + expect(buildSortLiveAnnouncement({ ...base, columnDefinitions: [nodeHeaderComparator], sortingColumns })).toBe( + 'sorted by c asc' + ); + }); +}); diff --git a/src/table/__tests__/warnings.test.tsx b/src/table/__tests__/warnings.test.tsx index dcf095a61d..d3ae425404 100644 --- a/src/table/__tests__/warnings.test.tsx +++ b/src/table/__tests__/warnings.test.tsx @@ -114,3 +114,33 @@ describe('Sticky header validation', () => { expect(warnOnce).toHaveBeenCalledTimes(2); }); }); + +describe('Multi-column sort validation', () => { + const baseColumns = [ + { header: 'id', cell: () => 'id', sortingField: 'id' }, + { header: 'name', cell: () => 'name', sortingField: 'name' }, + ]; + + test('prints a warning when multiColumnSort is combined with single-column sorting props', () => { + renderTable( +
{} }} + sortingColumn={{ sortingField: 'name' }} + onSortingChange={() => {}} + /> + ); + expect(warnOnce).toHaveBeenCalledWith( + 'Table', + expect.stringMatching(/`multiColumnSort` prop is mutually exclusive/) + ); + }); + + test('does not print a warning when only multiColumnSort is provided', () => { + renderTable( +
{} }} /> + ); + expect(warnOnce).not.toHaveBeenCalled(); + }); +}); diff --git a/src/table/clear-sort.tsx b/src/table/clear-sort.tsx new file mode 100644 index 0000000000..4d3d7e4bb7 --- /dev/null +++ b/src/table/clear-sort.tsx @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; + +import InternalButton from '../button/internal'; +import { useInternalI18n } from '../i18n/context'; +import { fireNonCancelableEvent } from '../internal/events'; +import { TableProps } from './interfaces'; + +interface ClearSortButtonProps { + multiColumnSort: TableProps.MultiColumnSort; + i18nStrings?: TableProps.I18nStrings; + onClearSort?: () => void; +} + +export function ClearSortButton({ multiColumnSort, i18nStrings, onClearSort }: ClearSortButtonProps) { + const i18n = useInternalI18n('table'); + return ( + { + fireNonCancelableEvent(multiColumnSort.onChange, { sortingColumns: [] }); + onClearSort?.(); + }} + > + {i18n('i18nStrings.clearSort', i18nStrings?.clearSort) ?? ''} + + ); +} diff --git a/src/table/header-cell/index.tsx b/src/table/header-cell/index.tsx index e08d537b88..de47af6fda 100644 --- a/src/table/header-cell/index.tsx +++ b/src/table/header-cell/index.tsx @@ -9,11 +9,21 @@ import { getAnalyticsMetadataAttribute } from '@cloudscape-design/component-tool import { useInternalI18n } from '../../i18n/context'; import InternalIcon from '../../icon/internal'; +import { fireNonCancelableEvent } from '../../internal/events'; import { KeyCode } from '../../internal/keycode'; import { GeneratedAnalyticsMetadataTableSort } from '../analytics-metadata/interfaces'; import { TableProps } from '../interfaces'; +import { + appendSort, + getSortIndex, + removeSort, + replaceSort, + setDirection, + toggleDirection, +} from '../multi-column-sort/utils'; import { Divider, Resizer } from '../resizer'; import { BaseHeaderCellProps } from './common-props'; +import { SortMenu, SortMenuAction } from './sort-menu'; import { TableThElement } from './th-element'; import { getSortingIconName, getSortingStatus, isSorted } from './utils'; @@ -27,6 +37,9 @@ export interface TableHeaderCellProps extends BaseHeaderCellProps { sortingDisabled?: boolean; stuck?: boolean; onClick(detail: TableProps.SortingState): void; + multiColumnSort?: TableProps.MultiColumnSort; + i18nStrings?: TableProps.I18nStrings; + sortMenuTriggerLabel?: string; updateColumn: (columnId: PropertyKey, newWidth: number) => void; isEditable?: boolean; columnId: PropertyKey; @@ -73,17 +86,77 @@ export function TableHeaderCell({ isLastChildOfGroup, isLast, tableVariant, + multiColumnSort, + i18nStrings, + sortMenuTriggerLabel, }: TableHeaderCellProps) { const i18n = useInternalI18n('table'); const sortable = !!column.sortingComparator || !!column.sortingField; - const sorted = !!activeSortingColumn && isSorted(column, activeSortingColumn); - const sortingStatus = getSortingStatus(sortable, sorted, !!sortingDescending, !!sortingDisabled); const isGrouped = !!columnGroupId || (rowSpan ?? 1) > 1; - const handleClick = () => + + // Multi-column sort context — derived from `multiColumnSort.sortingColumns` when present. + const multiSortIndex = multiColumnSort ? getSortIndex(multiColumnSort.sortingColumns, column) : null; + const multiSortEntry = + multiSortIndex !== null && multiColumnSort ? multiColumnSort.sortingColumns[multiSortIndex - 1] : undefined; + const inMultiSort = multiSortIndex !== null; + const isPrimaryMultiSort = multiSortIndex === 1; + // Priority badges are only meaningful when 2+ columns are sorted; with a single column, the position number adds no information. + const showPriorityBadge = !!multiColumnSort && multiColumnSort.sortingColumns.length >= 2 && multiSortIndex !== null; + + // For multi-sort, derive sorted/descending from the matching entry; otherwise fall back to single-sort props. + const sorted = multiColumnSort ? inMultiSort : !!activeSortingColumn && isSorted(column, activeSortingColumn); + const descending = multiColumnSort ? !!multiSortEntry?.isDescending : !!sortingDescending; + const sortingStatus = getSortingStatus(sortable, sorted, descending, !!sortingDisabled); + // In multi-sort, only the primary column declares aria-sort (ARIA only allows one column sorted at a time). + const suppressAriaSort = !!multiColumnSort && inMultiSort && !isPrimaryMultiSort; + const handleClick = (shiftKey = false) => { + if (multiColumnSort) { + const current = multiColumnSort.sortingColumns; + let next: ReadonlyArray>; + if (shiftKey && !inMultiSort) { + next = appendSort(current, column, false); + } else if (inMultiSort) { + next = toggleDirection(current, column); + } else { + next = replaceSort(column, false); + } + fireNonCancelableEvent(multiColumnSort.onChange, { sortingColumns: next }); + return; + } onClick({ sortingColumn: column, isDescending: sorted ? !sortingDescending : false, }); + }; + + const handleSortMenuAction = (action: SortMenuAction) => { + // The sort menu is only rendered when `multiColumnSort` is set (see render below), so this guard + // exists purely to narrow the type for the accesses that follow and is unreachable at runtime. + /* istanbul ignore next */ + if (!multiColumnSort) { + return; + } + const current = multiColumnSort.sortingColumns; + let next: ReadonlyArray>; + switch (action) { + case 'sort-ascending': + next = inMultiSort ? setDirection(current, column, false) : replaceSort(column, false); + break; + case 'sort-descending': + next = inMultiSort ? setDirection(current, column, true) : replaceSort(column, true); + break; + case 'add-to-sort-ascending': + next = appendSort(current, column, false); + break; + case 'add-to-sort-descending': + next = appendSort(current, column, true); + break; + case 'remove-from-sort': + next = removeSort(current, column); + break; + } + fireNonCancelableEvent(multiColumnSort.onChange, { sortingColumns: next }); + }; // Elements with role="button" do not have the default behavior of