diff --git a/build-tools/utils/pluralize.js b/build-tools/utils/pluralize.js index 0a6439d9c0..1158a96922 100644 --- a/build-tools/utils/pluralize.js +++ b/build-tools/utils/pluralize.js @@ -23,6 +23,7 @@ const pluralizationMap = { CollectionPreferences: 'CollectionPreferences', ColumnLayout: 'ColumnLayouts', Container: 'Containers', + ContentDisplayPreference: 'ContentDisplayPreferences', ContentLayout: 'ContentLayouts', CopyToClipboard: 'CopyToClipboards', DateInput: 'DateInputs', diff --git a/pages/content-display-preference/simple.page.tsx b/pages/content-display-preference/simple.page.tsx new file mode 100644 index 0000000000..0f62bb57ff --- /dev/null +++ b/pages/content-display-preference/simple.page.tsx @@ -0,0 +1,93 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; + +import Box from '~components/box'; +import ContentDisplayPreference, { ContentDisplayPreferenceProps } from '~components/content-display-preference'; +import SpaceBetween from '~components/space-between'; +import StatusIndicator from '~components/status-indicator'; + +import ScreenshotArea from '../utils/screenshot-area'; + +const options: ContentDisplayPreferenceProps['options'] = [ + { id: 'id1', label: 'Distribution ID', alwaysVisible: true }, + { id: 'id2', label: 'Domain name' }, + { id: 'id3', label: 'Price class' }, + { id: 'id4', label: 'Origin' }, + { id: 'id5', label: 'Status' }, + { id: 'id6', label: 'State' }, +]; + +const MAX_VISIBLE = 3; + +const i18nStrings: ContentDisplayPreferenceProps.I18nStrings = { + columnFilteringPlaceholder: 'Filter columns', + columnFilteringAriaLabel: 'Filter columns', + columnFilteringClearFilterText: 'Clear filter', + columnFilteringNoMatchText: 'No columns match', + columnFilteringCountText: count => `${count} ${count === 1 ? 'match' : 'matches'}`, +}; + +export default function ContentDisplayPreferencePage() { + const [value, setValue] = useState([ + { id: 'id1', visible: true }, + { id: 'id2', visible: true }, + { id: 'id3', visible: true }, + { id: 'id4', visible: false }, + { id: 'id5', visible: false }, + { id: 'id6', visible: false }, + ]); + + const visibleCount = (value ?? []).filter(item => item.visible).length; + const limitReached = visibleCount >= MAX_VISIBLE; + + return ( + + +

Standalone ContentDisplayPreference

+ + + Reacts to changes immediately (no confirm step). This example rejects turning on more than {MAX_VISIBLE}{' '} + columns. + + {limitReached ? ( + + Maximum of {MAX_VISIBLE} visible columns reached. Hide a column to enable another. + + ) : ( + + {visibleCount} of {MAX_VISIBLE} visible columns selected. + + )} +
+ `Picked up item at position ${position} of ${total}`} + liveAnnouncementDndItemReordered={(initial, current, total) => + `Moving item to position ${current} of ${total} (was ${initial})` + } + liveAnnouncementDndItemCommitted={(initial, final, total) => + `Item moved from position ${initial} to position ${final} of ${total}` + } + liveAnnouncementDndDiscarded="Reordering canceled" + onChange={event => { + const nextVisible = event.detail.value.filter(item => item.visible).length; + // Only accept the change if it does not exceed the maximum number of visible columns. + if (nextVisible <= MAX_VISIBLE) { + setValue(event.detail.value); + } + }} + /> +
+ {JSON.stringify(value)} +
+
+
+ ); +} diff --git a/src/__tests__/required-props-for-components.ts b/src/__tests__/required-props-for-components.ts index 3430c62d2d..12c054f1a4 100644 --- a/src/__tests__/required-props-for-components.ts +++ b/src/__tests__/required-props-for-components.ts @@ -10,6 +10,7 @@ const defaultProps: Record> = { autosuggest: { options: [], enteredPrefix: '' }, 'anchor-navigation': { anchors: [] }, 'code-editor': { i18nStrings }, + 'content-display-preference': { options: [] }, wizard: { steps: [], i18nStrings: { diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index b54c1b56ab..9904f6efdf 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -10551,6 +10551,270 @@ Refer to the [style](/components/container/?tabId=style) tab for more details.", } `; +exports[`Components definition for content-display-preference matches the snapshot: content-display-preference 1`] = ` +{ + "dashCaseName": "content-display-preference", + "events": [ + { + "cancelable": false, + "description": "Called whenever the user reorders items or toggles their visibility. + +Unlike \`CollectionPreferences\`, the standalone component emits changes immediately, allowing you to +react to selection before the user confirms (for example, to enforce a maximum number of visible columns). + +The event \`detail\` contains the following: +- \`value\` (ReadonlyArray) - The updated ordered list of items and their visibility.", + "detailInlineType": { + "name": "ContentDisplayPreferenceProps.ChangeDetail", + "properties": [ + { + "name": "value", + "optional": false, + "type": "ReadonlyArray", + }, + ], + "type": "object", + }, + "detailType": "ContentDisplayPreferenceProps.ChangeDetail", + "name": "onChange", + }, + ], + "functions": [], + "name": "ContentDisplayPreference", + "properties": [ + { + "deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).", + "description": "Adds the specified classes to the root element of the component.", + "name": "className", + "optional": true, + "type": "string", + }, + { + "description": "Specifies the description displayed below the title.", + "i18nTag": true, + "name": "description", + "optional": true, + "type": "string", + }, + { + "name": "dragHandleAriaDescription", + "optional": true, + "type": "string", + }, + { + "name": "dragHandleAriaLabel", + "optional": true, + "type": "string", + }, + { + "description": "Adds a columns filter to the control.", + "name": "enableColumnFiltering", + "optional": true, + "type": "boolean", + }, + { + "description": "Specifies an array of column group definitions for multi-level content display. Each group contains: +- \`id\` (string) - A unique identifier for the group. +- \`label\` (string) - The text displayed as the group label.", + "name": "groups", + "optional": true, + "type": "ReadonlyArray", + }, + { + "description": "An object containing all the localized strings required by the component.", + "i18nTag": true, + "inlineType": { + "name": "CollectionPreferencesProps.ContentDisplayPreferenceI18nStrings", + "properties": [ + { + "name": "columnFilteringAriaLabel", + "optional": true, + "type": "string", + }, + { + "name": "columnFilteringClearFilterText", + "optional": true, + "type": "string", + }, + { + "inlineType": { + "name": "(count: number) => string", + "parameters": [ + { + "name": "count", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "columnFilteringCountText", + "optional": true, + "type": "((count: number) => string)", + }, + { + "name": "columnFilteringNoMatchText", + "optional": true, + "type": "string", + }, + { + "name": "columnFilteringPlaceholder", + "optional": true, + "type": "string", + }, + ], + "type": "object", + }, + "name": "i18nStrings", + "optional": true, + "type": "CollectionPreferencesProps.ContentDisplayPreferenceI18nStrings", + }, + { + "deprecatedTag": "The usage of the \`id\` attribute is reserved for internal use cases. For testing and other use cases, +use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes). If you must +use the \`id\` attribute, consider setting it on a parent element instead.", + "description": "Adds the specified ID to the root element of the component.", + "name": "id", + "optional": true, + "type": "string", + }, + { + "name": "liveAnnouncementDndDiscarded", + "optional": true, + "type": "string", + }, + { + "description": "Adds a label for a group item to be announced by screen readers during drag and drop operations.", + "inlineType": { + "name": "(label: string, count: number) => string", + "parameters": [ + { + "name": "label", + "type": "string", + }, + { + "name": "count", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "liveAnnouncementDndGroupLabel", + "optional": true, + "type": "((label: string, count: number) => string)", + }, + { + "inlineType": { + "name": "(initialPosition: number, finalPosition: number, total: number) => string", + "parameters": [ + { + "name": "initialPosition", + "type": "number", + }, + { + "name": "finalPosition", + "type": "number", + }, + { + "name": "total", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "liveAnnouncementDndItemCommitted", + "optional": true, + "type": "((initialPosition: number, finalPosition: number, total: number) => string)", + }, + { + "inlineType": { + "name": "(initialPosition: number, currentPosition: number, total: number) => string", + "parameters": [ + { + "name": "initialPosition", + "type": "number", + }, + { + "name": "currentPosition", + "type": "number", + }, + { + "name": "total", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "liveAnnouncementDndItemReordered", + "optional": true, + "type": "((initialPosition: number, currentPosition: number, total: number) => string)", + }, + { + "inlineType": { + "name": "(position: number, total: number) => string", + "parameters": [ + { + "name": "position", + "type": "number", + }, + { + "name": "total", + "type": "number", + }, + ], + "returnType": "string", + "type": "function", + }, + "name": "liveAnnouncementDndStarted", + "optional": true, + "type": "((position: number, total: number) => string)", + }, + { + "description": "Specifies an array of options for reordering and visible content selection. + +Each option contains the following: +- \`id\` (string) - Corresponds to a table column \`id\`. +- \`label\` (string) - Specifies a short description of the content. +- \`alwaysVisible\` (boolean) - (Optional) Determines whether the visibility is always on and therefore cannot be toggled. This is set to \`false\` by default.", + "name": "options", + "optional": false, + "type": "ReadonlyArray", + }, + { + "description": "Specifies the text displayed at the top of the preference.", + "i18nTag": true, + "name": "title", + "optional": true, + "type": "string", + }, + { + "description": "The current, ordered list of content items and their visibility. The order of the elements influences the display. + +Each content display item is one of the following: +- \`ContentDisplayColumn\` - Represents a single column. + - \`type\` ('column') - (Optional) Identifies the entry as a column. Defaults to \`'column'\` when omitted. + - \`id\` (string) - The column identifier. + - \`visible\` (boolean) - Whether the column is visible. +- \`ContentDisplayGroup\` - Represents a column group. + - \`type\` ('group') - Identifies the entry as a group. + - \`id\` (string) - The group identifier. + - \`visible\` (boolean) - Whether the group is visible. + - \`children\` (ReadonlyArray) - The columns or nested groups within this group. + +When not provided, all options are shown as visible in their original order.", + "name": "value", + "optional": true, + "type": "ReadonlyArray", + }, + ], + "regions": [], + "releaseStatus": "stable", +} +`; + exports[`Components definition for content-layout matches the snapshot: content-layout 1`] = ` { "dashCaseName": "content-layout", 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..4486a859c2 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 @@ -200,6 +200,9 @@ exports[`test-utils selectors 1`] = ` "awsui_media_14iqq", "awsui_root_14iqq", ], + "content-display-preference": [ + "awsui_root_1bte3", + ], "content-layout": [ "awsui_breadcrumbs_64tge", "awsui_content_5gtk3", diff --git a/src/content-display-preference/__tests__/content-display-preference.test.tsx b/src/content-display-preference/__tests__/content-display-preference.test.tsx new file mode 100644 index 0000000000..3e8d4b10df --- /dev/null +++ b/src/content-display-preference/__tests__/content-display-preference.test.tsx @@ -0,0 +1,279 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import ContentDisplayPreference, { + ContentDisplayPreferenceProps, +} from '../../../lib/components/content-display-preference'; +import TestI18nProvider from '../../../lib/components/i18n/testing'; +import createWrapper, { ContentDisplayPreferenceWrapper } from '../../../lib/components/test-utils/dom'; + +const options: ContentDisplayPreferenceProps['options'] = [ + { id: 'id1', label: 'Item 1', alwaysVisible: true }, + { id: 'id2', label: 'Item 2' }, + { id: 'id3', label: 'Item 3' }, + { id: 'id4', label: 'Item 4' }, +]; + +const i18nMessages = { + 'collection-preferences': { + 'contentDisplayPreference.i18nStrings.columnFilteringPlaceholder': 'Filter columns', + 'contentDisplayPreference.i18nStrings.columnFilteringAriaLabel': 'Filter columns', + 'contentDisplayPreference.i18nStrings.columnFilteringNoMatchText': 'No matches found', + 'contentDisplayPreference.i18nStrings.columnFilteringClearFilterText': 'Clear filter', + 'contentDisplayPreference.i18nStrings.columnFilteringCountText': + '{count, select, zero {0 matches} one {1 match} other {{count} matches}}', + 'contentDisplayPreference.dragHandleAriaLabel': 'Drag handle', + 'contentDisplayPreference.liveAnnouncementDndStarted': 'Picked up item at position {position} of {total}', + 'contentDisplayPreference.liveAnnouncementDndItemReordered': + '{isInitialPosition, select, true {Moving item back to position {currentPosition} of {total}} false {Moving item to position {currentPosition} of {total}} other {}}', + 'contentDisplayPreference.liveAnnouncementDndItemCommitted': + '{isInitialPosition, select, true {Item moved back to its original position {initialPosition} of {total}} false {Item moved from position {initialPosition} to position {finalPosition} of {total}} other {}}', + 'contentDisplayPreference.liveAnnouncementDndDiscarded': 'Reordering canceled', + }, +}; + +/** + * Renders the standalone component with local state so that the controlled `value` + * updates when the user interacts, mirroring real usage. + */ +function StatefulContentDisplayPreference({ + onChange, + ...props +}: Partial & { onChange?: ContentDisplayPreferenceProps['onChange'] }) { + const [value, setValue] = useState(props.value); + return ( + { + setValue(event.detail.value); + onChange?.(event); + }} + /> + ); +} + +function renderCDP( + props: Partial & { onChange?: ContentDisplayPreferenceProps['onChange'] } = {}, + withI18nProvider = false +): ContentDisplayPreferenceWrapper { + const node = ; + render(withI18nProvider ? {node} : node); + return createWrapper().findContentDisplayPreference()!; +} + +function pressKey(element: HTMLElement, key: string) { + fireEvent.keyDown(element, { key, code: key }); +} + +describe('ContentDisplayPreference (standalone)', () => { + describe('Rendering', () => { + it('renders the title as an h3', () => { + const titleElement = renderCDP().findTitle().getElement(); + expect(titleElement).toHaveTextContent('Content display title'); + expect(titleElement.tagName).toBe('H3'); + }); + + it('renders the description', () => { + expect(renderCDP().findDescription().getElement()).toHaveTextContent('Content display description'); + }); + + it('wraps content in a group role with aria-labelledby and aria-describedby', () => { + const wrapper = renderCDP(); + const titleId = wrapper.findTitle().getElement().id; + const descriptionId = wrapper.findDescription().getElement().id; + const group = wrapper.getElement().querySelector('[role="group"]')!; + expect(group).not.toBeNull(); + expect(group.getAttribute('aria-labelledby')).toBe(titleId); + expect(group.getAttribute('aria-describedby')).toBe(descriptionId); + }); + + it('renders all options with all of them visible by default (uncontrolled value)', () => { + const wrapper = renderCDP(); + const optionWrappers = wrapper.findOptions(); + expect(optionWrappers).toHaveLength(4); + for (const optionWrapper of optionWrappers) { + expect(optionWrapper.findVisibilityToggle().findNativeInput().getElement()).toBeChecked(); + } + }); + + it('renders the provided value ordering and visibility', () => { + const wrapper = renderCDP({ + value: [ + { id: 'id1', visible: true }, + { id: 'id3', visible: false }, + { id: 'id2', visible: true }, + { id: 'id4', visible: false }, + ], + }); + const labels = wrapper.findOptions().map(option => option.findLabel().getElement().textContent); + expect(labels).toEqual(['Item 1', 'Item 3', 'Item 2', 'Item 4']); + const statuses = wrapper + .findOptions() + .map(option => option.findVisibilityToggle().findNativeInput().getElement().hasAttribute('checked')); + // id1 visible, id3 hidden, id2 visible, id4 hidden + expect(wrapper.findOptionByIndex(2)!.findVisibilityToggle().findNativeInput().getElement()).not.toBeChecked(); + expect(statuses.length).toBe(4); + }); + }); + + describe('Reacting to changes immediately', () => { + it('fires onChange with the updated value when toggling visibility', () => { + const onChange = jest.fn(); + const wrapper = renderCDP({ onChange }); + wrapper.findOptionByIndex(2)!.findVisibilityToggle().findNativeInput().click(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + value: [ + { id: 'id1', visible: true }, + { id: 'id2', visible: false }, + { id: 'id3', visible: true }, + { id: 'id4', visible: true }, + ], + }, + }) + ); + // The controlled value is reflected back in the UI without a confirm step. + expect(wrapper.findOptionByIndex(2)!.findVisibilityToggle().findNativeInput().getElement()).not.toBeChecked(); + }); + + it('lets a consumer enforce a maximum number of visible columns', () => { + const maxVisible = 2; + function LimitedContentDisplay() { + const [value, setValue] = useState([ + { id: 'id1', visible: true }, + { id: 'id2', visible: true }, + { id: 'id3', visible: false }, + { id: 'id4', visible: false }, + ]); + return ( + { + const visibleCount = event.detail.value.filter(item => item.visible).length; + if (visibleCount <= maxVisible) { + setValue(event.detail.value); + } + }} + /> + ); + } + render(); + const wrapper = createWrapper().findContentDisplayPreference()!; + // Attempting to enable a 3rd column is rejected by the consumer -> stays hidden. + wrapper.findOptionByIndex(3)!.findVisibilityToggle().findNativeInput().click(); + expect(wrapper.findOptionByIndex(3)!.findVisibilityToggle().findNativeInput().getElement()).not.toBeChecked(); + }); + + it('keeps alwaysVisible options checked and disabled', () => { + const onChange = jest.fn(); + const wrapper = renderCDP({ onChange }); + const toggle = wrapper.findOptionByIndex(1)!.findVisibilityToggle().findNativeInput(); + expect(toggle.getElement()).toBeChecked(); + expect(toggle.getElement()).toBeDisabled(); + toggle.click(); + expect(toggle.getElement()).toBeChecked(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('fires onChange with the reordered value when moving an item with the keyboard', async () => { + const onChange = jest.fn(); + const wrapper = renderCDP({ onChange }, true); + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + const dragHandle = wrapper.findOptionByIndex(2)!.findDragHandle().getElement(); + pressKey(dragHandle, 'Space'); + await settle(); + pressKey(dragHandle, 'ArrowDown'); + await settle(); + pressKey(dragHandle, 'Space'); + await settle(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + value: [ + { id: 'id1', visible: true }, + { id: 'id3', visible: true }, + { id: 'id2', visible: true }, + { id: 'id4', visible: true }, + ], + }, + }) + ); + }); + }); + + describe('Column filtering', () => { + it('does not render the text filter by default', () => { + expect(renderCDP().findTextFilter()).toBeNull(); + }); + + it('filters options when enableColumnFiltering is set', () => { + const wrapper = renderCDP({ enableColumnFiltering: true }, true); + const filter = wrapper.findTextFilter(); + expect(filter).not.toBeNull(); + filter!.findInput().setInputValue('Item 1'); + expect(wrapper.findOptions()).toHaveLength(1); + filter!.findInput().findClearButton()?.click(); + expect(wrapper.findOptions()).toHaveLength(4); + }); + }); + + describe('Groups', () => { + it('renders grouped options', () => { + const wrapper = renderCDP({ + options: [ + { id: 'id1', label: 'Item 1' }, + { id: 'id2', label: 'Item 2' }, + { id: 'id3', label: 'Item 3' }, + ], + groups: [{ id: 'g1', label: 'Group 1' }], + value: [ + { id: 'id1', visible: true }, + { + type: 'group', + id: 'g1', + visible: true, + children: [ + { id: 'id2', visible: true }, + { id: 'id3', visible: false }, + ], + }, + ], + }); + expect(wrapper.findOptions({ group: true })).toHaveLength(1); + }); + }); + + describe('Base component API', () => { + it('applies className, id and data attributes to the root and is findable', () => { + const { container } = render( + {}} + /> + ); + const root = container.querySelector('#my-id')!; + expect(root).toHaveClass('custom-class'); + expect(root).toHaveAttribute('data-testid', 'cdp'); + expect(createWrapper(container).findContentDisplayPreference()).not.toBeNull(); + }); + }); +}); diff --git a/src/content-display-preference/index.tsx b/src/content-display-preference/index.tsx new file mode 100644 index 0000000000..504b1f9892 --- /dev/null +++ b/src/content-display-preference/index.tsx @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +'use client'; +import React from 'react'; + +import useBaseComponent from '../internal/hooks/use-base-component'; +import { applyDisplayName } from '../internal/utils/apply-display-name'; +import { ContentDisplayPreferenceProps } from './interfaces'; +import InternalContentDisplayPreference from './internal'; + +export { ContentDisplayPreferenceProps }; + +export default function ContentDisplayPreference(props: ContentDisplayPreferenceProps) { + const baseComponentProps = useBaseComponent('ContentDisplayPreference', { + props: { enableColumnFiltering: props.enableColumnFiltering }, + }); + return ; +} + +applyDisplayName(ContentDisplayPreference, 'ContentDisplayPreference'); diff --git a/src/content-display-preference/interfaces.ts b/src/content-display-preference/interfaces.ts new file mode 100644 index 0000000000..ffbefbd0f1 --- /dev/null +++ b/src/content-display-preference/interfaces.ts @@ -0,0 +1,87 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { CollectionPreferencesProps } from '../collection-preferences/interfaces'; +import { BaseComponentProps } from '../types/base-component'; +import { NonCancelableEventHandler } from '../types/events'; +import { DndAreaI18nStrings } from '../types/sortable-area'; + +export interface ContentDisplayPreferenceProps extends BaseComponentProps, DndAreaI18nStrings { + /** + * Specifies the text displayed at the top of the preference. + * @i18n + */ + title?: string; + /** + * Specifies the description displayed below the title. + * @i18n + */ + description?: string; + /** + * Specifies an array of options for reordering and visible content selection. + * + * Each option contains the following: + * - `id` (string) - Corresponds to a table column `id`. + * - `label` (string) - Specifies a short description of the content. + * - `alwaysVisible` (boolean) - (Optional) Determines whether the visibility is always on and therefore cannot be toggled. This is set to `false` by default. + */ + options: ReadonlyArray; + /** + * Specifies an array of column group definitions for multi-level content display. Each group contains: + * - `id` (string) - A unique identifier for the group. + * - `label` (string) - The text displayed as the group label. + */ + groups?: ReadonlyArray; + /** + * The current, ordered list of content items and their visibility. The order of the elements influences the display. + * + * Each content display item is one of the following: + * - `ContentDisplayColumn` - Represents a single column. + * - `type` ('column') - (Optional) Identifies the entry as a column. Defaults to `'column'` when omitted. + * - `id` (string) - The column identifier. + * - `visible` (boolean) - Whether the column is visible. + * - `ContentDisplayGroup` - Represents a column group. + * - `type` ('group') - Identifies the entry as a group. + * - `id` (string) - The group identifier. + * - `visible` (boolean) - Whether the group is visible. + * - `children` (ReadonlyArray) - The columns or nested groups within this group. + * + * When not provided, all options are shown as visible in their original order. + */ + value?: ReadonlyArray; + /** + * Adds a columns filter to the control. + */ + enableColumnFiltering?: boolean; + /** + * An object containing all the localized strings required by the component. + * @i18n + */ + i18nStrings?: ContentDisplayPreferenceProps.I18nStrings; + /** + * Adds a label for a group item to be announced by screen readers during drag and drop operations. + */ + liveAnnouncementDndGroupLabel?: (label: string, count: number) => string; + /** + * Called whenever the user reorders items or toggles their visibility. + * + * Unlike `CollectionPreferences`, the standalone component emits changes immediately, allowing you to + * react to selection before the user confirms (for example, to enforce a maximum number of visible columns). + * + * The event `detail` contains the following: + * - `value` (ReadonlyArray) - The updated ordered list of items and their visibility. + */ + onChange?: NonCancelableEventHandler; +} + +export namespace ContentDisplayPreferenceProps { + export type Option = CollectionPreferencesProps.ContentDisplayOption; + export type OptionGroup = CollectionPreferencesProps.ContentDisplayOptionGroup; + export type ContentDisplayItem = CollectionPreferencesProps.ContentDisplayItem; + export type ContentDisplayColumn = CollectionPreferencesProps.ContentDisplayColumn; + export type ContentDisplayGroup = CollectionPreferencesProps.ContentDisplayGroup; + export type I18nStrings = CollectionPreferencesProps.ContentDisplayPreferenceI18nStrings; + + export interface ChangeDetail { + value: ReadonlyArray; + } +} diff --git a/src/content-display-preference/internal.tsx b/src/content-display-preference/internal.tsx new file mode 100644 index 0000000000..a4d30efbb8 --- /dev/null +++ b/src/content-display-preference/internal.tsx @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import clsx from 'clsx'; + +import ContentDisplayPreferenceCore from '../collection-preferences/content-display'; +import { getBaseProps } from '../internal/base-component'; +import { fireNonCancelableEvent } from '../internal/events'; +import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; +import { ContentDisplayPreferenceProps } from './interfaces'; + +import styles from './styles.css.js'; + +type InternalContentDisplayPreferenceProps = ContentDisplayPreferenceProps & InternalBaseComponentProps; + +export default function InternalContentDisplayPreference({ + options, + groups, + value, + title, + description, + enableColumnFiltering, + i18nStrings, + liveAnnouncementDndStarted, + liveAnnouncementDndItemReordered, + liveAnnouncementDndItemCommitted, + liveAnnouncementDndDiscarded, + liveAnnouncementDndGroupLabel, + dragHandleAriaLabel, + dragHandleAriaDescription, + onChange, + __internalRootRef, + ...rest +}: InternalContentDisplayPreferenceProps) { + const baseProps = getBaseProps(rest); + return ( +
+ fireNonCancelableEvent(onChange, { value: newValue })} + /> +
+ ); +} diff --git a/src/content-display-preference/styles.scss b/src/content-display-preference/styles.scss new file mode 100644 index 0000000000..cba2f6237f --- /dev/null +++ b/src/content-display-preference/styles.scss @@ -0,0 +1,11 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +// The standalone ContentDisplayPreference renders the shared content-display control, +// which brings its own layout. This root only exists to carry the component metadata, +// so it is removed from the layout box tree. +.root { + display: contents; +} diff --git a/src/test-utils/dom/content-display-preference/index.ts b/src/test-utils/dom/content-display-preference/index.ts new file mode 100644 index 0000000000..d55501601d --- /dev/null +++ b/src/test-utils/dom/content-display-preference/index.ts @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { + ContentDisplayOptionWrapper, + default as CollectionContentDisplayPreferenceWrapper, +} from '../collection-preferences/content-display-preference'; + +import styles from '../../../content-display-preference/styles.selectors.js'; + +export { ContentDisplayOptionWrapper }; + +/** + * Test utils wrapper for the standalone `ContentDisplayPreference` component. + * + * It reuses the finders of the content display control used inside `CollectionPreferences` + * (title, description, options, drag handles, visibility toggles, column filter, no-match), + * anchored on the standalone component root. + */ +export default class ContentDisplayPreferenceWrapper extends CollectionContentDisplayPreferenceWrapper { + static rootSelector: string = styles.root; +}