diff --git a/pages/attribute-editor/buttons.page.tsx b/pages/attribute-editor/buttons.page.tsx deleted file mode 100644 index 1bb50f2073..0000000000 --- a/pages/attribute-editor/buttons.page.tsx +++ /dev/null @@ -1,156 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; - -import { Box, ButtonDropdown, ButtonDropdownProps, Input, InputProps, Link } from '~components'; -import AttributeEditor, { AttributeEditorProps } from '~components/attribute-editor'; - -interface Tag { - key?: string; - value?: string; -} - -interface ControlProps extends InputProps { - index: number; - setItems: React.Dispatch>; - prop: keyof Tag; -} - -const labelProps = { - addButtonText: 'Add new item', - removeButtonText: 'Remove', - empty: 'No tags associated to the resource', - i18nStrings: { itemRemovedAriaLive: 'An item was removed.' }, -} as AttributeEditorProps; - -const tagLimit = 50; - -const Control = React.memo( - React.forwardRef(({ value, index, setItems, prop }, ref) => { - return ( - { - setItems(items => { - const updatedItems = [...items]; - updatedItems[index] = { ...updatedItems[index], [prop]: detail.value }; - return updatedItems; - }); - }} - /> - ); - }) -); - -export default function AttributeEditorPage() { - const [items, setItems] = useState([ - { key: 'bla', value: 'foo' }, - { key: 'bar', value: 'yam' }, - ]); - const ref = useRef(null); - - const definition: AttributeEditorProps.FieldDefinition[] = useMemo( - () => [ - { - label: 'Key label', - info: Info, - control: ({ key = '' }, itemIndex) => ( - (keyInputRefs.current[itemIndex] = ref)} - /> - ), - }, - { - label: 'Value label', - info: Info, - control: ({ value = '' }, itemIndex) => ( - - ), - }, - ], - [] - ); - - const buttonRefs = useRef>([]); - const keyInputRefs = useRef>([]); - const focusEventRef = useRef<() => void>(); - - useLayoutEffect(() => { - focusEventRef.current?.apply(undefined); - focusEventRef.current = undefined; - }); - - const onAddButtonClick = useCallback(() => { - setItems(items => { - const newItems = [...items, {}]; - focusEventRef.current = () => { - keyInputRefs.current[newItems.length - 1]?.focus(); - }; - return newItems; - }); - }, []); - - const onRemoveButtonClick = useCallback((itemIndex: number) => { - setItems(items => { - const newItems = items.slice(); - newItems.splice(itemIndex, 1); - - if (newItems.length === 0) { - ref.current?.focusAddButton(); - } - if (itemIndex === items.length - 1) { - buttonRefs.current[items.length - 2]?.focus(); - } - - return newItems; - }); - }, []); - const moveRow = useCallback((itemIndex: number, direction: string) => { - const newIndex = direction === 'up' ? itemIndex - 1 : itemIndex + 1; - setItems(items => { - const newItems = items.slice(); - newItems.splice(newIndex, 0, newItems.splice(itemIndex, 1)[0]); - buttonRefs.current[newIndex]?.focusDropdownTrigger(); - return newItems; - }); - }, []); - - const additionalInfo = useMemo(() => `You can add ${tagLimit - items.length} more tags.`, [items.length]); - - return ( - -

Attribute Editor - Custom row actions

- - ref={ref} - {...labelProps} - additionalInfo={additionalInfo} - items={items} - definition={definition} - onAddButtonClick={onAddButtonClick} - customRowActions={({ itemIndex }) => ( - { - buttonRefs.current[itemIndex] = ref; - }} - items={[ - { text: 'Move up', id: 'up', disabled: itemIndex === 0 }, - { text: 'Move down', id: 'down', disabled: itemIndex === items.length - 1 }, - ]} - ariaLabel={`More actions for row ${itemIndex + 1}`} - mainAction={{ - text: 'Delete row', - ariaLabel: `Delete row ${itemIndex + 1}`, - onClick: () => onRemoveButtonClick(itemIndex), - }} - onItemClick={e => moveRow(itemIndex, e.detail.id)} - /> - )} - /> -
- ); -} diff --git a/pages/attribute-editor/form-field-label.page.tsx b/pages/attribute-editor/form-field-label.page.tsx index cb4b8f7d9c..63b44eed34 100644 --- a/pages/attribute-editor/form-field-label.page.tsx +++ b/pages/attribute-editor/form-field-label.page.tsx @@ -12,7 +12,7 @@ interface Tag { interface ControlProps extends InputProps { index: number; - setItems: React.Dispatch>; + setItems?: any; prop: keyof Tag; } @@ -29,7 +29,7 @@ const Control = React.memo(({ value, index, setItems, prop }: ControlProps) => { ariaLabel="Secondary owner username" ariaLabelledby="" onChange={({ detail }) => { - setItems(items => { + setItems((items: any) => { const updatedItems = [...items]; updatedItems[index] = { ...updatedItems[index], [prop]: detail.value }; return updatedItems; diff --git a/pages/attribute-editor/permutations.page.tsx b/pages/attribute-editor/permutations.page.tsx index ef51b8b666..bd1f6773f9 100644 --- a/pages/attribute-editor/permutations.page.tsx +++ b/pages/attribute-editor/permutations.page.tsx @@ -118,25 +118,6 @@ export const permutations = createPermutations>([ addButtonText: ['Add item'], removeButtonText: ['Remove item'], }, - { - definition: [definition4], - gridLayout: [ - [ - { rows: [[2, 1, 3, 1]], breakpoint: 'l' }, - { - rows: [ - [2, 1], - [3, 1], - ], - }, - ], - [{ rows: [[2, 1, 3, 1]], removeButton: { width: 'auto' } }], - [{ rows: [[2, 1, 3, 1]], removeButton: { ownRow: true } }], - ], - items: [defaultItems], - addButtonText: ['Add item (grid)'], - removeButtonText: ['Remove item (grid)'], - }, { definition: [validationDefinitions], i18nStrings: [{ errorIconAriaLabel: 'Error', warningIconAriaLabel: 'Warning' }], diff --git a/pages/attribute-editor/simple-grid.page.tsx b/pages/attribute-editor/simple-grid.page.tsx deleted file mode 100644 index 5df6710353..0000000000 --- a/pages/attribute-editor/simple-grid.page.tsx +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React, { useCallback, useMemo, useState } from 'react'; - -import { Box, Button, Input, InputProps, Link } from '~components'; -import AttributeEditor, { AttributeEditorProps } from '~components/attribute-editor'; - -interface Tag { - key?: string; - value?: string; -} - -interface ControlProps extends InputProps { - index: number; - setItems: React.Dispatch>; - prop: keyof Tag; -} - -const labelProps = { - addButtonText: 'Add new item', - removeButtonText: 'Remove', - empty: 'No tags associated to the resource', - i18nStrings: { itemRemovedAriaLive: 'An item was removed.' }, -} as AttributeEditorProps; - -const tagLimit = 50; - -const Control = React.memo(({ value, index, setItems, prop }: ControlProps) => { - return ( - { - setItems((items: Tag[]) => { - const updatedItems = [...items]; - updatedItems[index] = { ...updatedItems[index], [prop]: detail.value }; - return updatedItems; - }); - }} - /> - ); -}); - -export default function AttributeEditorPage() { - const [items, setItems] = useState([ - { key: 'bla', value: 'foo' }, - { key: 'bar', value: 'yam' }, - ]); - - const definition: AttributeEditorProps.FieldDefinition[] = useMemo( - () => [ - { - label: 'Key label', - info: Info, - control: ({ key = '' }, itemIndex) => , - errorText: (item: Tag) => (item.key && item.key.match(/^AWS/i) ? 'Key cannot start with "AWS"' : null), - warningText: (item: Tag) => (item.key && item.key.includes(' ') ? 'Key has empty character' : null), - }, - { - label: 'Value label', - info: Info, - control: ({ value = '' }, itemIndex) => ( - - ), - errorText: (item: Tag) => - item.value && item.value.length > 5 ? ( - - Value {item.value} is longer than 5 characters, Info - - ) : null, - warningText: (item: Tag) => - item.value && item.value.includes('*') ? ( - - Value {item.value} includes wildcard, Info - - ) : null, - }, - ], - [] - ); - - const onAddButtonClick = useCallback(() => { - setItems(items => [...items, {}]); - }, []); - - const onRemoveButtonClick = useCallback(({ detail: { itemIndex } }: { detail: { itemIndex: number } }) => { - setItems(items => { - const newItems = items.slice(); - newItems.splice(itemIndex, 1); - return newItems; - }); - }, []); - - const additionalInfo = useMemo(() => `You can add ${tagLimit - items.length} more tags.`, [items.length]); - - return ( - -

Attribute Editor - Grid

-

Non-responsive 2:3:auto layout

- - {...labelProps} - additionalInfo={additionalInfo} - items={items} - definition={definition} - onAddButtonClick={onAddButtonClick} - onRemoveButtonClick={onRemoveButtonClick} - gridLayout={[{ rows: [[2, 3]], removeButton: { width: 'auto' } }]} - /> -

Non-responsive 4:1 - 2:2 layout

- - {...labelProps} - additionalInfo={additionalInfo} - items={items} - definition={[...definition, ...definition]} - onAddButtonClick={onAddButtonClick} - onRemoveButtonClick={onRemoveButtonClick} - gridLayout={[ - { - rows: [ - [4, 1], - [2, 2], - ], - }, - ]} - /> -

Responsive layout

- - {...labelProps} - additionalInfo={additionalInfo} - items={items} - definition={[...definition, ...definition]} - customRowActions={({ breakpoint, item, itemIndex }) => { - const clickHandler = () => { - onRemoveButtonClick({ detail: { itemIndex } }); - }; - const ariaLabel = `Remove ${item.key}`; - if (breakpoint === 'xl') { - return - ); - }} - onAddButtonClick={onAddButtonClick} - onRemoveButtonClick={onRemoveButtonClick} - gridLayout={[ - { - breakpoint: 'xl', - rows: [[4, 1, 2, 2]], - removeButton: { - width: 'auto', - }, - }, - { - breakpoint: 'l', - rows: [[4, 1, 2, 2]], - removeButton: { - ownRow: true, - }, - }, - { - breakpoint: 's', - rows: [ - [3, 1], - [2, 2], - ], - }, - { - rows: [[1], [1], [1], [1]], - }, - ]} - /> -
- ); -} diff --git a/pages/attribute-editor/simple.page.tsx b/pages/attribute-editor/simple.page.tsx index 15a455b2e2..c54960cd55 100644 --- a/pages/attribute-editor/simple.page.tsx +++ b/pages/attribute-editor/simple.page.tsx @@ -12,7 +12,7 @@ interface Tag { interface ControlProps extends InputProps { index: number; - setItems: React.Dispatch>; + setItems?: any; prop: keyof Tag; } @@ -30,7 +30,7 @@ const Control = React.memo(({ value, index, setItems, prop }: ControlProps) => { { - setItems(items => { + setItems((items: any) => { const updatedItems = [...items]; updatedItems[index] = { ...updatedItems[index], [prop]: detail.value }; return updatedItems; diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 0fed0e3e02..a3d0bb3fbf 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -1467,26 +1467,8 @@ with expandable sections.", "optional": true, "type": "string", }, - { - "description": "Specifies a custom action trigger for each row, in place of the remove button. -Only button and button dropdown components are supported. -If you provide this, \`removeButtonText\`, \`removeButtonAriaLabel\`, -and \`onRemoveButtonClick\` will be ignored. -The trigger must be given the provided \`ref\` in order for \`focusRemoveButton\` -to work. -The function receives the following properties: -- \`item\`: The item being rendered in the current row. -- \`itemIndex\` (\`number\`): The index of the item. -- \`ref\` (\`ReactRef\`): A React ref that should be passed to the rendered button. -- \`breakpoint\` (\`Breakpoint\`): The current breakpoint, for responsive behavior. -- \`ownRow\` (\`boolean\`): Whether the button is rendered on its own row.", - "name": "customRowActions", - "optional": true, - "type": "(props: AttributeEditorProps.RowActionsProps) => React.ReactNode", - }, { "description": "Defines the editor configuration. Each object in the array represents one form field in the row. -If more than 6 attributes are specified, a \`gridLayout\` must be provided. * \`label\` (ReactNode) - Text label for the form field. * \`info\` (ReactNode) - Info link for the form field. * \`errorText\` ((item, itemIndex) => ReactNode) - Error message text to display as a control validation message. @@ -1495,6 +1477,8 @@ If more than 6 attributes are specified, a \`gridLayout\` must be provided. It renders the form field in a warning state if the returned value is not \`null\` or \`undefined\`. * \`constraintText\` ((item, itemIndex) => ReactNode) - Text to display as a constraint message below the field. * \`control\` ((item, itemIndex) => ReactNode) - A control to use as the input for the field. + +A maximum of four fields are supported. ", "name": "definition", "optional": false, @@ -1506,23 +1490,6 @@ If more than 6 attributes are specified, a \`gridLayout\` must be provided. "optional": true, "type": "boolean", }, - { - "description": "Optionally specifies the layout of the attributes. By default, all attributes will be -equally spaced and wrapped into multiple rows on smaller viewports. -A \`gridLayout\` is an array of breakpoint definitions. Each definition consists of: -- \`rows\` (\`number[][]\`): the rows in which to display the attributes. Each row consists of a list of numbers indicating - the relative width of each attribute. For example, \`[[1, 1, 1, 1]]\` is a single row of four evenly-spaced attributes, - or \`[[1, 2], [1, 1, 1]]\` splits five attributes onto two rows. -- \`breakpoint\` (\`string\`): optionally specifies that the given entry should only be used when at least that much width is available. -- \`removeButton\`: optionally configures the remove (or row action) button placement. If this is not provided, the button will be - placed at the end of a single row, or below if multiple rows are present. The \`removeButton\` property supports contains two properties: - - \`ownRow\` (\`boolean\`): forces the remove button onto its own row. - - \`width\` (\`number | 'auto'\`): a number indicating the relative width (equivalent to a \`rows\` entry), or 'auto' to fit to the button width. -", - "name": "gridLayout", - "optional": true, - "type": "ReadonlyArray", - }, { "description": "An object containing all the necessary localized strings required by the component.", "inlineType": { @@ -3666,25 +3633,9 @@ modifier keys (that is, CTRL, ALT, SHIFT, META), and the item has an \`href\` se ], "functions": [ { - "description": "Focuses the underlying native button. If a main action is defined this will focus that button.", + "description": "Focuses the underlying native button.", "name": "focus", - "parameters": [ - { - "name": "options", - "type": "FocusOptions", - }, - ], - "returnType": "void", - }, - { - "description": "Focuses the underlying native button for the dropdown.", - "name": "focusDropdownTrigger", - "parameters": [ - { - "name": "options", - "type": "FocusOptions", - }, - ], + "parameters": [], "returnType": "void", }, ], 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 153b69189e..d2de67dea0 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 @@ -60,9 +60,9 @@ exports[`test-utils selectors 1`] = ` "awsui_additional-info_n4qlp", "awsui_empty_n4qlp", "awsui_field_n4qlp", - "awsui_remove-button-container_n4qlp", "awsui_remove-button_n4qlp", "awsui_root_n4qlp", + "awsui_row-control_n4qlp", "awsui_row_n4qlp", ], "autosuggest": [ diff --git a/src/attribute-editor/__tests__/attribute-editor.test.tsx b/src/attribute-editor/__tests__/attribute-editor.test.tsx index da5b600d36..5ee07cb392 100644 --- a/src/attribute-editor/__tests__/attribute-editor.test.tsx +++ b/src/attribute-editor/__tests__/attribute-editor.test.tsx @@ -3,10 +3,7 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; -import { useContainerQuery } from '@cloudscape-design/component-toolkit'; - import AttributeEditor, { AttributeEditorProps } from '../../../lib/components/attribute-editor'; -import ButtonDropdown from '../../../lib/components/button-dropdown'; import TestI18nProvider from '../../../lib/components/i18n/testing'; import Input from '../../../lib/components/input'; import createWrapper, { AttributeEditorWrapper } from '../../../lib/components/test-utils/dom'; @@ -15,22 +12,6 @@ import styles from '../../../lib/components/attribute-editor/styles.css.js'; import buttonStyles from '../../../lib/components/button/styles.css.js'; import liveRegionStyles from '../../../lib/components/live-region/test-classes/styles.css.js'; -let containerQueryBreakpoint = 'm'; -jest.mock('@cloudscape-design/component-toolkit', () => ({ - ...jest.requireActual('@cloudscape-design/component-toolkit'), - useContainerQuery: jest.fn(), -})); - -beforeEach(() => { - jest.spyOn(console, 'warn').mockImplementation(); - (useContainerQuery as jest.Mock).mockImplementation(() => [containerQueryBreakpoint, () => {}]); -}); - -afterEach(() => { - jest.restoreAllMocks(); - jest.resetAllMocks(); -}); - interface Item { key: string; value: string; @@ -564,7 +545,12 @@ describe('Attribute Editor', () => { }, ], }); - const [labelId, inputId] = wrapper.findRow(1)!.getElement().getAttribute('aria-labelledby')!.split(' '); + const [labelId, inputId] = wrapper + .findRow(1)! + .find('[role="group"]')! + .getElement() + .getAttribute('aria-labelledby')! + .split(' '); const label = wrapper.getElement().querySelector(`#${labelId}`)!.textContent + ' ' + @@ -584,115 +570,4 @@ describe('Attribute Editor', () => { expect(wrapper.findRow(1)!.findRemoveButton()!.getElement()).toHaveTextContent('Custom remove'); }); }); - - describe('custom buttons', () => { - test('allows a custom row action', () => { - const { container } = render( - } /> - ); - const wrapper = createWrapper(container).findAttributeEditor()!; - expect(wrapper.findRow(1)!.findCustomAction()!.findButtonDropdown()).toBeTruthy(); - }); - test('passes expected arguments to custom row action', () => { - const actionRenderer = jest.fn(); - render(); - expect(actionRenderer).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - item: defaultProps.items![0], - itemIndex: 0, - ref: expect.any(Function), - breakpoint: 'm', - }) - ); - expect(actionRenderer).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - item: defaultProps.items![1], - itemIndex: 1, - ref: expect.any(Function), - breakpoint: 'm', - }) - ); - expect(actionRenderer).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ - item: defaultProps.items![2], - itemIndex: 2, - ref: expect.any(Function), - breakpoint: 'm', - }) - ); - }); - test('does not render standard button if custom row action defined', () => { - const { container } = render( - } /> - ); - const wrapper = createWrapper(container).findAttributeEditor()!; - expect(wrapper.findRow(1)!.findRemoveButton()).toBeFalsy(); - }); - test('renders standard button if custom row action returns undefined, nothing if null', () => { - const { container } = render( - - itemIndex === 1 ? undefined : itemIndex === 2 ? null : - } - /> - ); - const wrapper = createWrapper(container).findAttributeEditor()!; - expect(wrapper.findRow(1)!.findCustomAction()!.findButtonDropdown()).toBeTruthy(); - expect(wrapper.findRow(2)!.findCustomAction()!.findButtonDropdown()).toBeFalsy(); - expect(wrapper.findRow(3)!.findCustomAction()!.findButtonDropdown()).toBeFalsy(); - - expect(wrapper.findRow(1)!.findRemoveButton()).toBeFalsy(); - expect(wrapper.findRow(2)!.findRemoveButton()).toBeTruthy(); - expect(wrapper.findRow(3)!.findRemoveButton()).toBeFalsy(); - }); - }); - - describe('responsiveness', () => { - test('should pass resolved breakpoints from the gridLayout to useContainerQuery - 1', () => { - render(); - expect(useContainerQuery).toHaveBeenCalledWith(expect.any(Function), ['default']); - }); - test('should pass resolved breakpoints from the gridLayout to useContainerQuery - 2', () => { - render( - - ); - expect(useContainerQuery).toHaveBeenCalledWith(expect.any(Function), ['default,xl,s']); - }); - }); - - describe('warnings', () => { - test('should warn if no layout supplied for >6 attributes', () => { - render(); - expect(console.warn).toHaveBeenCalledWith( - 'AttributeEditor', - '`gridLayout` is required for more than 6 attributes. Cannot render.' - ); - }); - test('should warn if no grid layout found for current breakpoint', () => { - containerQueryBreakpoint = 'm'; - render(); - expect(console.warn).toHaveBeenCalledWith( - 'AttributeEditor', - 'No `gridLayout` entry found for breakpoint m. Cannot render.' - ); - }); - test('should warn if grid layout does not match definition', () => { - render(); - expect(console.warn).toHaveBeenCalledWith( - 'AttributeEditor', - 'Incorrect number of columns in layout (1) for definition (3). Cannot render.' - ); - }); - }); }); diff --git a/src/attribute-editor/__tests__/grid-defaults.test.ts b/src/attribute-editor/__tests__/grid-defaults.test.ts deleted file mode 100644 index d204bf4a6d..0000000000 --- a/src/attribute-editor/__tests__/grid-defaults.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { gridDefaults } from '../grid-defaults'; - -describe('grid-defaults', () => { - describe.each(Object.entries(gridDefaults))('Has right number of entries for %i items', (attributes, layouts) => { - test.each(layouts)('breakpoint: $breakpoint', layout => { - const totalItems = layout.rows.reduce((acc, row) => acc + row.length, 0); - expect(totalItems).toEqual(parseInt(attributes, 10)); - }); - }); -}); diff --git a/src/attribute-editor/__tests__/utils.test.ts b/src/attribute-editor/__tests__/utils.test.ts deleted file mode 100644 index 5e12a06fef..0000000000 --- a/src/attribute-editor/__tests__/utils.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { AttributeEditorProps } from '../interfaces'; -import { - getGridTemplateColumns, - getItemGridColumns, - getRemoveButtonGridColumns, - isRemoveButtonOnSameLine, -} from '../utils'; - -describe('utils', () => { - const sampleLayout: AttributeEditorProps.GridLayout = { - rows: [ - [1, 2], - [2, 1], - ], - removeButton: { - width: 1, - ownRow: false, - }, - }; - - describe('getItemGridColumns', () => { - it('should return correct grid columns for first item', () => { - const result = getItemGridColumns(sampleLayout, 0); - expect(result).toEqual({ gridColumnStart: 1, gridColumnEnd: 2 }); - }); - - it('should return correct grid columns for second item', () => { - const result = getItemGridColumns(sampleLayout, 1); - expect(result).toEqual({ gridColumnStart: 2, gridColumnEnd: 4 }); - }); - - it('should return correct grid columns for third item (second row)', () => { - const result = getItemGridColumns(sampleLayout, 2); - expect(result).toEqual({ gridColumnStart: 1, gridColumnEnd: 3 }); - }); - }); - - describe('getRemoveButtonGridColumns', () => { - it('should return correct columns when button is on single line', () => { - const singleLineLayout: AttributeEditorProps.GridLayout = { - rows: [[1]], - removeButton: { width: 1 }, - }; - const result = getRemoveButtonGridColumns(singleLineLayout, 2); - expect(result).toEqual({ gridColumnStart: 2, gridColumnEnd: 3 }); - }); - - it('should return full width when button is on own row', () => { - const multiLineLayout: AttributeEditorProps.GridLayout = { - rows: [[1, 2]], - removeButton: { width: 1, ownRow: true }, - }; - const result = getRemoveButtonGridColumns(multiLineLayout, 2); - expect(result).toEqual({ gridColumnStart: 1, gridColumnEnd: 4 }); - }); - }); - - describe('isRemoveButtonOnSameLine', () => { - it('should return true for single row layout without ownRow', () => { - const layout: AttributeEditorProps.GridLayout = { - rows: [[1]], - removeButton: { width: 1 }, - }; - expect(isRemoveButtonOnSameLine(layout)).toBe(true); - }); - - it('should return false for single row layout with ownRow', () => { - const layout: AttributeEditorProps.GridLayout = { - rows: [[1]], - removeButton: { width: 1, ownRow: true }, - }; - expect(isRemoveButtonOnSameLine(layout)).toBe(false); - }); - - it('should return false for multi-row layout', () => { - const layout: AttributeEditorProps.GridLayout = { - rows: [[1], [1]], - removeButton: { width: 1 }, - }; - expect(isRemoveButtonOnSameLine(layout)).toBe(false); - }); - }); - - describe('getGridTemplateColumns', () => { - it('should generate correct template for single row with remove button', () => { - const layout: AttributeEditorProps.GridLayout = { - rows: [[1, 1]], - removeButton: { width: 1 }, - }; - expect(getGridTemplateColumns(layout)).toBe('repeat(2, 1fr) 1fr'); - }); - - it('should handle auto width remove button', () => { - const layout: AttributeEditorProps.GridLayout = { - rows: [[1]], - removeButton: { width: 'auto' }, - }; - expect(getGridTemplateColumns(layout)).toBe('repeat(1, 1fr) max-content'); - }); - - it('should not add remove button column when button is on own row', () => { - const layout: AttributeEditorProps.GridLayout = { - rows: [[1, 1]], - removeButton: { width: 1, ownRow: true }, - }; - expect(getGridTemplateColumns(layout)).toBe('repeat(2, 1fr) '); - }); - }); -}); diff --git a/src/attribute-editor/grid-defaults.ts b/src/attribute-editor/grid-defaults.ts deleted file mode 100644 index 494018cd23..0000000000 --- a/src/attribute-editor/grid-defaults.ts +++ /dev/null @@ -1,125 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { AttributeEditorProps } from './interfaces'; - -export const gridDefaults: Record = { - 1: [ - { - breakpoint: 'xxs', - rows: [[3]], - }, - { - rows: [[1]], - removeButton: { - ownRow: true, - }, - }, - ], - 2: [ - { - breakpoint: 'xs', - rows: [[3, 3]], - removeButton: { - width: 2, - }, - }, - { - breakpoint: 'xxs', - rows: [[1, 1]], - removeButton: { - ownRow: true, - }, - }, - { - rows: [[1], [1]], - }, - ], - 3: [ - { - breakpoint: 'xs', - rows: [[3, 3, 3]], - removeButton: { - width: 3, - }, - }, - { - breakpoint: 'xxs', - rows: [[1, 1], [1]], - removeButton: { - ownRow: true, - }, - }, - { - rows: [[1], [1], [1]], - }, - ], - 4: [ - { - breakpoint: 'xs', - rows: [[3, 3, 3, 3]], - removeButton: { - width: 4, - }, - }, - { - breakpoint: 'xxs', - rows: [ - [1, 1], - [1, 1], - ], - }, - { - rows: [[1], [1], [1], [1]], - }, - ], - 5: [ - { - breakpoint: 's', - rows: [[3, 3, 3, 3, 3]], - removeButton: { - width: 5, - }, - }, - { - breakpoint: 'xs', - rows: [ - [1, 1, 1], - [1, 1], - ], - }, - { - breakpoint: 'xxs', - rows: [[1, 1], [1, 1], [1]], - }, - { - rows: [[1], [1], [1], [1], [1]], - }, - ], - 6: [ - { - breakpoint: 's', - rows: [[3, 3, 3, 3, 3, 3]], - removeButton: { - width: 6, - }, - }, - { - breakpoint: 'xs', - rows: [ - [1, 1, 1], - [1, 1, 1], - ], - }, - { - breakpoint: 'xxs', - rows: [ - [1, 1], - [1, 1], - [1, 1], - ], - }, - { - rows: [[1], [1], [1], [1], [1], [1]], - }, - ], -}; diff --git a/src/attribute-editor/interfaces.ts b/src/attribute-editor/interfaces.ts index 74fc244306..04188f3f1d 100644 --- a/src/attribute-editor/interfaces.ts +++ b/src/attribute-editor/interfaces.ts @@ -2,9 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import React from 'react'; -import { ButtonDropdownProps } from '../button-dropdown/interfaces'; import { BaseComponentProps } from '../internal/base-component'; -import { Breakpoint as InternalBreakpoint } from '../internal/breakpoints'; import { NonCancelableEventHandler } from '../internal/events'; /* @@ -53,14 +51,6 @@ export namespace AttributeEditorProps { focusAddButton(): void; } - export interface RowActionsProps { - item: T; - itemIndex: number; - ref: React.Ref; - breakpoint: Breakpoint | null; - ownRow: boolean; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface I18nStrings { errorIconAriaLabel?: string; @@ -72,17 +62,6 @@ export namespace AttributeEditorProps { */ removeButtonAriaLabel?: (item: T) => string; } - - export type Breakpoint = InternalBreakpoint; - - export interface GridLayout { - breakpoint?: Breakpoint; - rows: ReadonlyArray>; - removeButton?: { - ownRow?: boolean; - width?: number | 'auto'; - }; - } } export interface AttributeEditorProps extends BaseComponentProps { @@ -139,7 +118,6 @@ export interface AttributeEditorProps extends BaseComponentProps { /** * Defines the editor configuration. Each object in the array represents one form field in the row. - * If more than 6 attributes are specified, a `gridLayout` must be provided. * * * `label` (ReactNode) - Text label for the form field. * * `info` (ReactNode) - Info link for the form field. @@ -149,40 +127,10 @@ export interface AttributeEditorProps extends BaseComponentProps { * It renders the form field in a warning state if the returned value is not `null` or `undefined`. * * `constraintText` ((item, itemIndex) => ReactNode) - Text to display as a constraint message below the field. * * `control` ((item, itemIndex) => ReactNode) - A control to use as the input for the field. - */ - definition: ReadonlyArray>; - - /** - * Optionally specifies the layout of the attributes. By default, all attributes will be - * equally spaced and wrapped into multiple rows on smaller viewports. * - * A `gridLayout` is an array of breakpoint definitions. Each definition consists of: - * - `rows` (`number[][]`): the rows in which to display the attributes. Each row consists of a list of numbers indicating - * the relative width of each attribute. For example, `[[1, 1, 1, 1]]` is a single row of four evenly-spaced attributes, - * or `[[1, 2], [1, 1, 1]]` splits five attributes onto two rows. - * - `breakpoint` (`string`): optionally specifies that the given entry should only be used when at least that much width is available. - * - `removeButton`: optionally configures the remove (or row action) button placement. If this is not provided, the button will be - * placed at the end of a single row, or below if multiple rows are present. The `removeButton` property supports contains two properties: - * - `ownRow` (`boolean`): forces the remove button onto its own row. - * - `width` (`number | 'auto'`): a number indicating the relative width (equivalent to a `rows` entry), or 'auto' to fit to the button width. + * A maximum of four fields are supported. */ - gridLayout?: ReadonlyArray; - - /** - * Specifies a custom action trigger for each row, in place of the remove button. - * Only button and button dropdown components are supported. - * If you provide this, `removeButtonText`, `removeButtonAriaLabel`, - * and `onRemoveButtonClick` will be ignored. - * The trigger must be given the provided `ref` in order for `focusRemoveButton` - * to work. - * The function receives the following properties: - * - `item`: The item being rendered in the current row. - * - `itemIndex` (`number`): The index of the item. - * - `ref` (`ReactRef`): A React ref that should be passed to the rendered button. - * - `breakpoint` (`Breakpoint`): The current breakpoint, for responsive behavior. - * - `ownRow` (`boolean`): Whether the button is rendered on its own row. - */ - customRowActions?: (props: AttributeEditorProps.RowActionsProps) => React.ReactNode; + definition: ReadonlyArray>; /** * Called when add button is clicked. diff --git a/src/attribute-editor/internal.tsx b/src/attribute-editor/internal.tsx index 07310d3bce..bfae184c02 100644 --- a/src/attribute-editor/internal.tsx +++ b/src/attribute-editor/internal.tsx @@ -3,10 +3,10 @@ import React, { useImperativeHandle, useRef, useState } from 'react'; import clsx from 'clsx'; +import InternalBox from '../box/internal'; import { ButtonProps } from '../button/interfaces'; import { InternalButton } from '../button/internal'; import { getBaseProps } from '../internal/base-component'; -import { matchBreakpointMapping } from '../internal/breakpoints'; import { useContainerBreakpoints } from '../internal/hooks/container-queries'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { useMergeRefs } from '../internal/hooks/use-merge-refs'; @@ -15,10 +15,8 @@ import { useUniqueId } from '../internal/hooks/use-unique-id'; import { SomeRequired } from '../internal/types'; import InternalLiveRegion from '../live-region/internal'; import { AdditionalInfo } from './additional-info'; -import { gridDefaults } from './grid-defaults'; import { AttributeEditorForwardRefType, AttributeEditorProps } from './interfaces'; import { Row } from './row'; -import { getGridTemplateColumns } from './utils'; import styles from './styles.css.js'; @@ -29,8 +27,7 @@ const InternalAttributeEditor = React.forwardRef( { additionalInfo, disableAddButton, - definition = [{}], - gridLayout, + definition, items, isItemRemovable = () => true, empty, @@ -38,7 +35,6 @@ const InternalAttributeEditor = React.forwardRef( addButtonVariant = 'normal', removeButtonText, removeButtonAriaLabel, - customRowActions, i18nStrings, onAddButtonClick, onRemoveButtonClick, @@ -47,6 +43,7 @@ const InternalAttributeEditor = React.forwardRef( }: InternalAttributeEditorProps, ref: React.Ref ) => { + const [breakpoint, breakpointRef] = useContainerBreakpoints(['default', 'xxs', 'xs']); const removeButtonRefs = useRef>([]); const addButtonRef = useRef(null); const wasNonEmpty = useRef(false); @@ -66,6 +63,8 @@ const InternalAttributeEditor = React.forwardRef( }, })); + const mergedRef = useMergeRefs(breakpointRef, __internalRootRef); + const additionalInfoId = useUniqueId('attribute-editor-info'); const infoAriaDescribedBy = additionalInfo ? additionalInfoId : undefined; @@ -81,98 +80,54 @@ const InternalAttributeEditor = React.forwardRef( // eslint-disable-next-line react-hooks/exhaustive-deps }, [items, i18nStrings?.itemRemovedAriaLive]); - if (!gridLayout) { - gridLayout = gridDefaults[definition.length]; - if (!gridLayout) { - console.warn('AttributeEditor', '`gridLayout` is required for more than 6 attributes. Cannot render.'); - gridLayout = []; - } - } - - const gridLayoutBreakpoints = gridLayout.reduce( - (acc, layout) => ({ - ...acc, - [layout.breakpoint || 'default']: layout, - }), - {} as Record - ); - - const [breakpoint, breakpointRef] = useContainerBreakpoints( - Object.keys(gridLayoutBreakpoints) as AttributeEditorProps.Breakpoint[] - ); - const mergedRef = useMergeRefs(breakpointRef, __internalRootRef); - - const gridLayoutForBreakpoint = matchBreakpointMapping(gridLayoutBreakpoints, breakpoint || 'default'); - - if (!gridLayoutForBreakpoint) { - console.warn('AttributeEditor', `No \`gridLayout\` entry found for breakpoint ${breakpoint}. Cannot render.`); - return
; - } - - const totalColumnsInLayout = gridLayoutForBreakpoint.rows.reduce((total, columns) => total + columns.length, 0); - if (totalColumnsInLayout !== definition.length) { - console.warn( - 'AttributeEditor', - `Incorrect number of columns in layout (${totalColumnsInLayout}) for definition (${definition.length}). Cannot render.` - ); - return
; - } - return ( -
- {isEmpty &&
{empty}
} - {items.map((item, index) => ( - - key={index} - index={index} - breakpoint={breakpoint} - layout={gridLayoutForBreakpoint} - item={item} - definition={definition} - i18nStrings={i18nStrings} - removable={isItemRemovable(item)} - removeButtonText={removeButtonText} - removeButtonRefs={removeButtonRefs.current} - customRowActions={customRowActions} - onRemoveButtonClick={onRemoveButtonClick} - removeButtonAriaLabel={removeButtonAriaLabel} - /> - ))} - -
- - {addButtonText} - - - {!!additionalInfo && {additionalInfo}} -
+
+ + {isEmpty &&
{empty}
} + {items.map((item, index) => ( + + key={index} + index={index} + breakpoint={breakpoint} + item={item} + definition={definition} + i18nStrings={i18nStrings} + removable={isItemRemovable(item)} + removeButtonText={removeButtonText} + removeButtonRefs={removeButtonRefs.current} + onRemoveButtonClick={onRemoveButtonClick} + removeButtonAriaLabel={removeButtonAriaLabel} + /> + ))} +
+ + + {addButtonText} + + + {!!additionalInfo && {additionalInfo}}
); } diff --git a/src/attribute-editor/row.tsx b/src/attribute-editor/row.tsx index 9e401d49ef..bcceaef22f 100644 --- a/src/attribute-editor/row.tsx +++ b/src/attribute-editor/row.tsx @@ -3,21 +3,23 @@ import React, { useCallback } from 'react'; import clsx from 'clsx'; +import InternalBox from '../box/internal'; import { ButtonProps } from '../button/interfaces'; import { InternalButton } from '../button/internal'; +import InternalColumnLayout, { ColumnLayoutBreakpoint } from '../column-layout/internal'; import InternalFormField from '../form-field/internal'; +import InternalGrid from '../grid/internal'; import { useInternalI18n } from '../i18n/context'; -import { Breakpoint } from '../internal/breakpoints'; import { fireNonCancelableEvent, NonCancelableEventHandler } from '../internal/events'; import { useUniqueId } from '../internal/hooks/use-unique-id'; import { AttributeEditorProps } from './interfaces'; -import { getItemGridColumns, getRemoveButtonGridColumns, isRemoveButtonOnSameLine } from './utils'; import styles from './styles.css.js'; +const Divider = () => ; + interface RowProps { - breakpoint: Breakpoint | null; - layout: AttributeEditorProps.GridLayout; + breakpoint: ColumnLayoutBreakpoint | null; item: T; definition: ReadonlyArray>; i18nStrings: AttributeEditorProps.I18nStrings | undefined; @@ -25,7 +27,6 @@ interface RowProps { removable: boolean; removeButtonText?: string; removeButtonRefs: Array; - customRowActions?: (props: AttributeEditorProps.RowActionsProps) => React.ReactNode; onRemoveButtonClick?: NonCancelableEventHandler; removeButtonAriaLabel?: (item: T) => string; } @@ -45,22 +46,24 @@ function render( } } +const GRID_DEFINITION = [{ colspan: { default: 12, xs: 9 } }]; +const REMOVABLE_GRID_DEFINITION = [{ colspan: { default: 12, xs: 9 } }, { colspan: { default: 12, xs: 3 } }]; export const Row = React.memo( ({ breakpoint, item, definition, - layout, i18nStrings = {}, index, removable, removeButtonText, removeButtonRefs, - customRowActions, onRemoveButtonClick, removeButtonAriaLabel, }: RowProps) => { const i18n = useInternalI18n('attribute-editor'); + const isNarrowViewport = breakpoint === 'default' || breakpoint === 'xxs'; + const isWideViewport = !isNarrowViewport; const handleRemoveClick = useCallback(() => { fireNonCancelableEvent(onRemoveButtonClick, { itemIndex: index }); @@ -68,76 +71,81 @@ export const Row = React.memo( const firstControlId = useUniqueId('first-control-id-'); - const buttonRef = (ref: ButtonProps.Ref | null) => { - removeButtonRefs[index] = ref ?? undefined; - }; - - let gridColumnStart = 1; - let gridColumnEnd = 1; - const removeButtonOnSameLine = isRemoveButtonOnSameLine(layout); - - const customActions = customRowActions?.({ - item, - itemIndex: index, - ref: buttonRef, - breakpoint, - ownRow: !removeButtonOnSameLine, - }); - return ( -
- {definition.map(({ info, label, constraintText, errorText, warningText, control }, defIndex) => { - ({ gridColumnStart, gridColumnEnd } = getItemGridColumns(layout, defIndex)); - return ( - +
+ + - {render(item, index, control)} - - ); - })} -
- {removable && - (customActions !== undefined ? ( - customActions - ) : ( - ( + 0} + controlId={defIndex === 0 ? firstControlId : undefined} + > + {render(item, index, control)} + + ))} + + {removable && ( + row.label)} > - {i18n('removeButtonText', removeButtonText)} - - ))} + { + removeButtonRefs[index] = ref ?? undefined; + }} + ariaLabel={(removeButtonAriaLabel ?? i18nStrings.removeButtonAriaLabel)?.(item)} + onClick={handleRemoveClick} + > + {i18n('removeButtonText', removeButtonText)} + + + )} +
- {!removeButtonOnSameLine &&
} -
+ {isNarrowViewport && } + ); } ) as (props: RowProps) => JSX.Element; + +interface ButtonContainer { + index: number; + children: React.ReactNode; + isNarrowViewport: boolean; + hasLabel: boolean; +} + +const ButtonContainer = ({ index, children, isNarrowViewport, hasLabel }: ButtonContainer) => ( +
+ {children} +
+); diff --git a/src/attribute-editor/styles.scss b/src/attribute-editor/styles.scss index 470f5e4420..6a9873b978 100644 --- a/src/attribute-editor/styles.scss +++ b/src/attribute-editor/styles.scss @@ -9,25 +9,16 @@ .root { @include styles.styles-reset; - display: grid; - grid-template-rows: min-content; - gap: awsui.$space-grid-gutter; - align-items: start; + display: block; } .empty { @include styles.font-body-m; color: awsui.$color-text-empty; - grid-column: 1 / -1; } .row { - display: contents; -} - -.divider { - grid-column: 1 / -1; - border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; + /* used in test-utils */ } .row-control { @@ -38,6 +29,30 @@ /* used in test-utils */ } +.add-button { + /* used in test-utils */ +} + +.remove-button { + /* used in test-utils */ +} + +.button-container-haslabel { + // We only support vertical alignment of the remove button for labels with exactly one line. + // The value is calculated as follows: + // padding-top = awsui-form-field-controls: 4px + + // line height (also applies to icon size) awsui-form-field-label: 22px + padding-block-start: calc(#{awsui.$space-xxs} + #{awsui.$line-height-body-m}); +} + +.button-container-nolabel { + padding-block-start: #{awsui.$space-xxs}; +} + +.divider { + border-block-end: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; +} + .additional-info { @include styles.form-control-description; display: block; @@ -50,24 +65,7 @@ } } -.add-row { - grid-column: 1 / -1; -} - -.add-button { - /* used in test-utils */ -} - -.remove-button-container { - display: inline-block; -} -.remove-button-field-padding { - padding-block-start: calc(#{awsui.$space-xxs} + #{awsui.$line-height-body-m}); -} -.remove-button-own-row { - justify-self: end; -} - -.remove-button { - /* used in test-utils */ +.right-align { + display: flex; + justify-content: flex-end; } diff --git a/src/attribute-editor/utils.ts b/src/attribute-editor/utils.ts deleted file mode 100644 index de19164575..0000000000 --- a/src/attribute-editor/utils.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import { AttributeEditorProps } from './interfaces'; - -interface GridColumns { - gridColumnStart: number; - gridColumnEnd: number; -} - -export function getItemGridColumns(layout: AttributeEditorProps.GridLayout, itemIndex: number): GridColumns { - let i = 0; - for (const row of layout.rows) { - let gridColumnStart = 1; - for (const columnWidth of row) { - if (i === itemIndex) { - return { gridColumnStart, gridColumnEnd: gridColumnStart + columnWidth }; - } else { - gridColumnStart += columnWidth; - } - i++; - } - } - return { gridColumnStart: 1, gridColumnEnd: 1 }; -} - -export function getRemoveButtonGridColumns( - layout: AttributeEditorProps.GridLayout, - previousGridColumnEnd: number -): GridColumns { - const maxColumns = layout.rows.reduce( - (max, columns) => - Math.max( - max, - columns.reduce((sum, col) => sum + col, 0) - ), - 0 - ); - if (isRemoveButtonOnSameLine(layout)) { - const removeButtonWidth = typeof layout.removeButton?.width === 'number' ? layout.removeButton?.width : 1; - return { - gridColumnStart: previousGridColumnEnd, - gridColumnEnd: previousGridColumnEnd + removeButtonWidth, - }; - } - return { gridColumnStart: 1, gridColumnEnd: maxColumns + 1 }; -} - -export function isRemoveButtonOnSameLine(layout: AttributeEditorProps.GridLayout) { - return layout.rows.length === 1 && !layout.removeButton?.ownRow; -} - -export function getGridTemplateColumns(layout: AttributeEditorProps.GridLayout) { - const totalColumnUnits = layout.rows.reduce( - (maxCols, row) => - Math.max( - maxCols, - row.reduce((cols, col) => cols + col, 0) - ), - 0 - ); - - const removeButtonColumn = isRemoveButtonOnSameLine(layout) - ? layout.removeButton?.width === 'auto' - ? 'max-content' - : `${layout.removeButton?.width ?? 1}fr` - : ''; - - return `repeat(${totalColumnUnits}, 1fr) ${removeButtonColumn}`; -} diff --git a/src/button-dropdown/__tests__/button-dropdown-item-click.test.tsx b/src/button-dropdown/__tests__/button-dropdown-item-click.test.tsx index 61fed49e55..851cfe02e5 100644 --- a/src/button-dropdown/__tests__/button-dropdown-item-click.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown-item-click.test.tsx @@ -217,8 +217,8 @@ describe('default href navigation', () => { expect(onClickSpy).toHaveBeenCalled(); }); - describe.each([true, false])('mobile=%b', mobile => { - test('toggles category on click', () => { + [true, false].forEach(mobile => { + test(`toggles category on click when mobile=${mobile}`, () => { (useMobile as jest.Mock).mockReturnValue(mobile); const categoryId = 'category'; const itemId = 'nested-item'; @@ -232,39 +232,5 @@ describe('default href navigation', () => { expect(wrapper.findItemById(itemId)).not.toBeNull(); }); - test('returns focus acter clicking item', () => { - const { container } = render( -
- -
- ); - const wrapper = createWrapper(container).findButtonDropdown()!; - wrapper.openDropdown(); - wrapper.findItemById('1')?.click(); - expect(wrapper.findNativeButton().getElement()).toHaveFocus(); - }); - test('allows focus to be moved in the onItemClick function', () => { - const { container } = render( -
- e.detail.id === '1' && container.querySelector('input')?.focus()} - /> - -
- ); - const wrapper = createWrapper(container).findButtonDropdown()!; - wrapper.openDropdown(); - wrapper.findItemById('1')?.click(); - expect(container.querySelector('input')).toHaveFocus(); - }); }); }); diff --git a/src/button-dropdown/__tests__/button-dropdown.test.tsx b/src/button-dropdown/__tests__/button-dropdown.test.tsx index f7149a0ea2..4e036b55a3 100644 --- a/src/button-dropdown/__tests__/button-dropdown.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown.test.tsx @@ -435,15 +435,6 @@ describe('with main action', () => { expect(wrapper.findMainAction()!.getElement()).toHaveFocus(); }); - - test('ref.focusDropdownTrigger focuses the dropdown', () => { - const ref = React.createRef(); - const wrapper = renderSplitButtonDropdown({ mainAction: { text: 'Main' } }, ref); - - ref.current!.focusDropdownTrigger(); - - expect(wrapper.findNativeButton()!.getElement()).toHaveFocus(); - }); }); test('should work in controlled context', () => { diff --git a/src/button-dropdown/interfaces.ts b/src/button-dropdown/interfaces.ts index 23e080b442..95e5d52022 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -188,13 +188,9 @@ export namespace ButtonDropdownProps { export interface Ref { /** - * Focuses the underlying native button. If a main action is defined this will focus that button. + * Focuses the underlying native button. */ - focus(options?: FocusOptions): void; - /** - * Focuses the underlying native button for the dropdown. - */ - focusDropdownTrigger(options?: FocusOptions): void; + focus(): void; } } diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index f84d170d2d..9772fcb7b2 100644 --- a/src/button-dropdown/internal.tsx +++ b/src/button-dropdown/internal.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useEffect, useImperativeHandle, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import clsx from 'clsx'; import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; @@ -13,6 +13,7 @@ import { useFunnel } from '../internal/analytics/hooks/use-funnel.js'; import { getBaseProps } from '../internal/base-component'; import Dropdown from '../internal/components/dropdown'; import OptionsList from '../internal/components/options-list'; +import useForwardFocus from '../internal/hooks/forward-focus'; import { useMobile } from '../internal/hooks/use-mobile'; import { useUniqueId } from '../internal/hooks/use-unique-id'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode/index.js'; @@ -110,18 +111,7 @@ const InternalButtonDropdown = React.forwardRef( const mainActionRef = useRef(null); const triggerRef = useRef(null); - useImperativeHandle( - ref, - () => ({ - focus(...args) { - (isMainAction ? mainActionRef : triggerRef).current?.focus(...args); - }, - focusDropdownTrigger(...args) { - triggerRef.current?.focus(...args); - }, - }), - [mainActionRef, triggerRef, isMainAction] - ); + useForwardFocus(ref, isMainAction ? mainActionRef : triggerRef); const clickHandler = () => { if (!loading && !disabled) { diff --git a/src/button-dropdown/utils/use-button-dropdown.ts b/src/button-dropdown/utils/use-button-dropdown.ts index a5a402ada4..f81471d284 100644 --- a/src/button-dropdown/utils/use-button-dropdown.ts +++ b/src/button-dropdown/utils/use-button-dropdown.ts @@ -75,13 +75,13 @@ export function useButtonDropdown({ target: isLink ? getItemTarget(item) : undefined, checked: isCheckbox ? !item.checked : undefined, }; - onReturnFocus(); if (onItemFollow && isLink && isPlainLeftClick(event)) { fireCancelableEvent(onItemFollow, details, event); } if (onItemClick) { fireCancelableEvent(onItemClick, details, event); } + onReturnFocus(); closeDropdown(); }; diff --git a/src/button-group/item-element.tsx b/src/button-group/item-element.tsx index 8f74d80189..3ced7c0775 100644 --- a/src/button-group/item-element.tsx +++ b/src/button-group/item-element.tsx @@ -3,8 +3,6 @@ import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; import { ButtonProps } from '../button/interfaces.js'; -import { ButtonDropdownProps } from '../button-dropdown/interfaces.js'; -import { FileInputProps } from '../file-input/interfaces'; import { fireCancelableEvent, NonCancelableEventHandler } from '../internal/events'; import { nodeBelongs } from '../internal/utils/node-belongs'; import FileInputItem from './file-input-item'; @@ -30,15 +28,11 @@ const ItemElement = forwardRef( ref: React.Ref ) => { const containerRef = useRef(null); - const buttonRef = useRef(null); - const fileInputRef = useRef(null); - const buttonDropdownRef = useRef(null); + const itemRef = useRef(null); useImperativeHandle(ref, () => ({ focus: () => { - buttonRef.current?.focus(); - fileInputRef.current?.focus(); - buttonDropdownRef.current?.focus(); + itemRef.current?.focus(); }, })); @@ -123,7 +117,7 @@ const ItemElement = forwardRef( > {item.type === 'icon-button' && ( .${gridstyles.grid} > .${gridstyles['grid-column']}:nth-child(${column}) > div > .${styles.field}`, + FormFieldWrapper + ); } findRemoveButton(): ButtonWrapper | null { return this.findComponent(`.${styles['remove-button']}`, ButtonWrapper); } - - findCustomAction(): ElementWrapper | null { - return this.findComponent(`.${styles['remove-button-container']}`, ElementWrapper); - } } export default class AttributeEditorWrapper extends ComponentWrapper {