From 0830379595b3b09322a23e1cdd72c84ed0204aa6 Mon Sep 17 00:00:00 2001 From: Georgii Lobko <47106899+georgylobko@users.noreply.github.com> Date: Tue, 21 Jan 2025 17:29:04 +0100 Subject: [PATCH 1/6] chore: Update @types/node to v20 (#3209) --- package-lock.json | 89 ++++++++++++++++++++++++++++++++++++++++++++--- package.json | 2 +- 2 files changed, 85 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7722f25527..50684c60cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "@types/glob": "^7.2.0", "@types/jest": "^29.5.13", "@types/lodash": "^4.14.176", - "@types/node": "^18.18.5", + "@types/node": "^20.17.14", "@types/react": "^16.14.20", "@types/react-dom": "^16.9.14", "@types/react-router": "^5.1.18", @@ -3936,10 +3936,14 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.18.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.5.tgz", - "integrity": "sha512-4slmbtwV59ZxitY4ixUZdy1uRLf9eSIvBWPQxNjhHYWEtn0FryfKpyS2cvADYXTayWdKEIsJengncrVvkI4I6A==", - "dev": true + "version": "20.17.14", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.14.tgz", + "integrity": "sha512-w6qdYetNL5KRBiSClK/KWai+2IMEJuAj+EujKCumalFOwXtvOXaEan9AuwcRID2IcOIAWSIfR495hBtgKlx2zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } }, "node_modules/@types/normalize-package-data": { "version": "2.4.1", @@ -4512,6 +4516,23 @@ } } }, + "node_modules/@wdio/types/node_modules/@types/node": { + "version": "18.19.71", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", + "integrity": "sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@wdio/types/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@wdio/utils": { "version": "7.33.0", "resolved": "https://registry.npmjs.org/@wdio/utils/-/utils-7.33.0.tgz", @@ -7280,6 +7301,16 @@ "integrity": "sha512-9rTIZ4ZjWwalCPiaY+kPiALLfOKgAz5CTi/Zb1L+qSZ8PH3zVo1T8JcgXIIqg1iM3pZ6hF+n9xO5r2jZ/SF+jg==", "dev": true }, + "node_modules/devtools/node_modules/@types/node": { + "version": "18.19.71", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", + "integrity": "sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/devtools/node_modules/debug": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz", @@ -7361,6 +7392,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/devtools/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/devtools/node_modules/ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", @@ -18922,6 +18960,13 @@ "fastest-levenshtein": "^1.0.7" } }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "2.0.0", "dev": true, @@ -19250,6 +19295,23 @@ "node": ">=12.0.0" } }, + "node_modules/webdriver/node_modules/@types/node": { + "version": "18.19.71", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", + "integrity": "sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/webdriver/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/webdriverio": { "version": "7.36.0", "resolved": "https://registry.npmjs.org/webdriverio/-/webdriverio-7.36.0.tgz", @@ -19288,6 +19350,16 @@ "node": ">=12.0.0" } }, + "node_modules/webdriverio/node_modules/@types/node": { + "version": "18.19.71", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.71.tgz", + "integrity": "sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/webdriverio/node_modules/minimatch": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-6.2.0.tgz", @@ -19303,6 +19375,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/webdriverio/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index e53b6ddc03..0ef5be69d1 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "@types/glob": "^7.2.0", "@types/jest": "^29.5.13", "@types/lodash": "^4.14.176", - "@types/node": "^18.18.5", + "@types/node": "^20.17.14", "@types/react": "^16.14.20", "@types/react-dom": "^16.9.14", "@types/react-router": "^5.1.18", From 372abbda82c19bd662bf9eac950092c699640320 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Wed, 22 Jan 2025 08:42:18 +0100 Subject: [PATCH 2/6] revert: "feat: Add flexible layout for attribute editor (#3123)" (#3211) --- pages/attribute-editor/buttons.page.tsx | 156 ---------------- .../form-field-label.page.tsx | 4 +- pages/attribute-editor/permutations.page.tsx | 19 -- pages/attribute-editor/simple-grid.page.tsx | 176 ------------------ pages/attribute-editor/simple.page.tsx | 4 +- .../__snapshots__/documenter.test.ts.snap | 57 +----- .../test-utils-selectors.test.tsx.snap | 2 +- .../__tests__/attribute-editor.test.tsx | 137 +------------- .../__tests__/grid-defaults.test.ts | 12 -- src/attribute-editor/__tests__/utils.test.ts | 111 ----------- src/attribute-editor/grid-defaults.ts | 125 ------------- src/attribute-editor/interfaces.ts | 56 +----- src/attribute-editor/internal.tsx | 149 ++++++--------- src/attribute-editor/row.tsx | 154 +++++++-------- src/attribute-editor/styles.scss | 60 +++--- src/attribute-editor/utils.ts | 69 ------- .../button-dropdown-item-click.test.tsx | 38 +--- .../__tests__/button-dropdown.test.tsx | 9 - src/button-dropdown/interfaces.ts | 8 +- src/button-dropdown/internal.tsx | 16 +- .../utils/use-button-dropdown.ts | 2 +- src/button-group/item-element.tsx | 18 +- src/form-field/interfaces.ts | 3 +- src/form-field/internal.tsx | 2 - src/test-utils/dom/attribute-editor/index.ts | 10 +- 25 files changed, 199 insertions(+), 1198 deletions(-) delete mode 100644 pages/attribute-editor/buttons.page.tsx delete mode 100644 pages/attribute-editor/simple-grid.page.tsx delete mode 100644 src/attribute-editor/__tests__/grid-defaults.test.ts delete mode 100644 src/attribute-editor/__tests__/utils.test.ts delete mode 100644 src/attribute-editor/grid-defaults.ts delete mode 100644 src/attribute-editor/utils.ts 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 { From 1d18d63bbdc65db28f463572f5f0c6004a272c98 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Thu, 23 Jan 2025 10:44:05 +0100 Subject: [PATCH 3/6] feat: Add flexible layout for attribute editor (#3212) --- pages/attribute-editor/buttons.page.tsx | 156 ++++++++++++++++ .../form-field-label.page.tsx | 4 +- pages/attribute-editor/permutations.page.tsx | 19 ++ pages/attribute-editor/simple-grid.page.tsx | 176 ++++++++++++++++++ pages/attribute-editor/simple.page.tsx | 4 +- .../__snapshots__/documenter.test.ts.snap | 57 +++++- .../test-utils-selectors.test.tsx.snap | 2 +- .../__tests__/attribute-editor.test.tsx | 137 +++++++++++++- .../__tests__/grid-defaults.test.ts | 12 ++ src/attribute-editor/__tests__/utils.test.ts | 111 +++++++++++ src/attribute-editor/grid-defaults.ts | 125 +++++++++++++ src/attribute-editor/interfaces.ts | 56 +++++- src/attribute-editor/internal.tsx | 149 +++++++++------ src/attribute-editor/row.tsx | 154 ++++++++------- src/attribute-editor/styles.scss | 60 +++--- src/attribute-editor/utils.ts | 69 +++++++ .../button-dropdown-item-click.test.tsx | 38 +++- .../__tests__/button-dropdown.test.tsx | 9 + src/button-dropdown/interfaces.ts | 8 +- src/button-dropdown/internal.tsx | 16 +- .../utils/use-button-dropdown.ts | 2 +- src/button-group/item-element.tsx | 18 +- src/form-field/interfaces.ts | 3 +- src/form-field/internal.tsx | 2 + src/test-utils/dom/attribute-editor/index.ts | 10 +- 25 files changed, 1198 insertions(+), 199 deletions(-) create mode 100644 pages/attribute-editor/buttons.page.tsx create mode 100644 pages/attribute-editor/simple-grid.page.tsx create mode 100644 src/attribute-editor/__tests__/grid-defaults.test.ts create mode 100644 src/attribute-editor/__tests__/utils.test.ts create mode 100644 src/attribute-editor/grid-defaults.ts create mode 100644 src/attribute-editor/utils.ts diff --git a/pages/attribute-editor/buttons.page.tsx b/pages/attribute-editor/buttons.page.tsx new file mode 100644 index 0000000000..1bb50f2073 --- /dev/null +++ b/pages/attribute-editor/buttons.page.tsx @@ -0,0 +1,156 @@ +// 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 63b44eed34..cb4b8f7d9c 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?: any; + setItems: React.Dispatch>; 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: any) => { + setItems(items => { 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 bd1f6773f9..ef51b8b666 100644 --- a/pages/attribute-editor/permutations.page.tsx +++ b/pages/attribute-editor/permutations.page.tsx @@ -118,6 +118,25 @@ 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 new file mode 100644 index 0000000000..5df6710353 --- /dev/null +++ b/pages/attribute-editor/simple-grid.page.tsx @@ -0,0 +1,176 @@ +// 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 c54960cd55..15a455b2e2 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?: any; + setItems: React.Dispatch>; prop: keyof Tag; } @@ -30,7 +30,7 @@ const Control = React.memo(({ value, index, setItems, prop }: ControlProps) => { { - setItems((items: any) => { + setItems(items => { 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 a3d0bb3fbf..0fed0e3e02 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -1467,8 +1467,26 @@ 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. @@ -1477,8 +1495,6 @@ with expandable sections.", 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, @@ -1490,6 +1506,23 @@ A maximum of four fields are supported. "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": { @@ -3633,9 +3666,25 @@ modifier keys (that is, CTRL, ALT, SHIFT, META), and the item has an \`href\` se ], "functions": [ { - "description": "Focuses the underlying native button.", + "description": "Focuses the underlying native button. If a main action is defined this will focus that button.", "name": "focus", - "parameters": [], + "parameters": [ + { + "name": "options", + "type": "FocusOptions", + }, + ], + "returnType": "void", + }, + { + "description": "Focuses the underlying native button for the dropdown.", + "name": "focusDropdownTrigger", + "parameters": [ + { + "name": "options", + "type": "FocusOptions", + }, + ], "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 d2de67dea0..153b69189e 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 5ee07cb392..da5b600d36 100644 --- a/src/attribute-editor/__tests__/attribute-editor.test.tsx +++ b/src/attribute-editor/__tests__/attribute-editor.test.tsx @@ -3,7 +3,10 @@ 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'; @@ -12,6 +15,22 @@ 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; @@ -545,12 +564,7 @@ describe('Attribute Editor', () => { }, ], }); - const [labelId, inputId] = wrapper - .findRow(1)! - .find('[role="group"]')! - .getElement() - .getAttribute('aria-labelledby')! - .split(' '); + const [labelId, inputId] = wrapper.findRow(1)!.getElement().getAttribute('aria-labelledby')!.split(' '); const label = wrapper.getElement().querySelector(`#${labelId}`)!.textContent + ' ' + @@ -570,4 +584,115 @@ 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 new file mode 100644 index 0000000000..d204bf4a6d --- /dev/null +++ b/src/attribute-editor/__tests__/grid-defaults.test.ts @@ -0,0 +1,12 @@ +// 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 new file mode 100644 index 0000000000..5e12a06fef --- /dev/null +++ b/src/attribute-editor/__tests__/utils.test.ts @@ -0,0 +1,111 @@ +// 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 new file mode 100644 index 0000000000..494018cd23 --- /dev/null +++ b/src/attribute-editor/grid-defaults.ts @@ -0,0 +1,125 @@ +// 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 04188f3f1d..74fc244306 100644 --- a/src/attribute-editor/interfaces.ts +++ b/src/attribute-editor/interfaces.ts @@ -2,7 +2,9 @@ // 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'; /* @@ -51,6 +53,14 @@ 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; @@ -62,6 +72,17 @@ 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 { @@ -118,6 +139,7 @@ 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. @@ -127,11 +149,41 @@ 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. - * - * A maximum of four fields are supported. */ 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. + */ + 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; + /** * Called when add button is clicked. */ diff --git a/src/attribute-editor/internal.tsx b/src/attribute-editor/internal.tsx index bfae184c02..07310d3bce 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,8 +15,10 @@ 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'; @@ -27,7 +29,8 @@ const InternalAttributeEditor = React.forwardRef( { additionalInfo, disableAddButton, - definition, + definition = [{}], + gridLayout, items, isItemRemovable = () => true, empty, @@ -35,6 +38,7 @@ const InternalAttributeEditor = React.forwardRef( addButtonVariant = 'normal', removeButtonText, removeButtonAriaLabel, + customRowActions, i18nStrings, onAddButtonClick, onRemoveButtonClick, @@ -43,7 +47,6 @@ 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); @@ -63,8 +66,6 @@ const InternalAttributeEditor = React.forwardRef( }, })); - const mergedRef = useMergeRefs(breakpointRef, __internalRootRef); - const additionalInfoId = useUniqueId('attribute-editor-info'); const infoAriaDescribedBy = additionalInfo ? additionalInfoId : undefined; @@ -80,54 +81,98 @@ 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} - item={item} - definition={definition} - i18nStrings={i18nStrings} - removable={isItemRemovable(item)} - removeButtonText={removeButtonText} - removeButtonRefs={removeButtonRefs.current} - onRemoveButtonClick={onRemoveButtonClick} - removeButtonAriaLabel={removeButtonAriaLabel} - /> - ))} -
- - - {addButtonText} - - - {!!additionalInfo && {additionalInfo}} +
+ {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}} +
); } diff --git a/src/attribute-editor/row.tsx b/src/attribute-editor/row.tsx index bcceaef22f..09dc829f67 100644 --- a/src/attribute-editor/row.tsx +++ b/src/attribute-editor/row.tsx @@ -3,23 +3,21 @@ 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: ColumnLayoutBreakpoint | null; + breakpoint: Breakpoint | null; + layout: AttributeEditorProps.GridLayout; item: T; definition: ReadonlyArray>; i18nStrings: AttributeEditorProps.I18nStrings | undefined; @@ -27,6 +25,7 @@ interface RowProps { removable: boolean; removeButtonText?: string; removeButtonRefs: Array; + customRowActions?: (props: AttributeEditorProps.RowActionsProps) => React.ReactNode; onRemoveButtonClick?: NonCancelableEventHandler; removeButtonAriaLabel?: (item: T) => string; } @@ -46,24 +45,22 @@ 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 }); @@ -71,81 +68,76 @@ 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 ( + - {definition.map(({ info, label, constraintText, errorText, warningText, control }, defIndex) => ( - 0} - controlId={defIndex === 0 ? firstControlId : undefined} - > - {render(item, index, control)} - - ))} - - {removable && ( - row.label)} + {render(item, index, control)} + + ); + })} +
+ {removable && + (customActions !== undefined ? ( + customActions + ) : ( + - { - removeButtonRefs[index] = ref ?? undefined; - }} - ariaLabel={(removeButtonAriaLabel ?? i18nStrings.removeButtonAriaLabel)?.(item)} - onClick={handleRemoveClick} - > - {i18n('removeButtonText', removeButtonText)} - - - )} - + {i18n('removeButtonText', removeButtonText)} + + ))}
- {isNarrowViewport && } - + {!removeButtonOnSameLine &&
} +
); } ) 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 6a9873b978..470f5e4420 100644 --- a/src/attribute-editor/styles.scss +++ b/src/attribute-editor/styles.scss @@ -9,50 +9,35 @@ .root { @include styles.styles-reset; - display: block; + display: grid; + grid-template-rows: min-content; + gap: awsui.$space-grid-gutter; + align-items: start; } .empty { @include styles.font-body-m; color: awsui.$color-text-empty; + grid-column: 1 / -1; } .row { - /* used in test-utils */ + display: contents; } -.row-control { - /* used in test-utils */ -} - -.field { - /* used in test-utils */ +.divider { + grid-column: 1 / -1; + border-block-start: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; } -.add-button { +.row-control { /* used in test-utils */ } -.remove-button { +.field { /* 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; @@ -65,7 +50,24 @@ } } -.right-align { - display: flex; - justify-content: flex-end; +.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 */ } diff --git a/src/attribute-editor/utils.ts b/src/attribute-editor/utils.ts new file mode 100644 index 0000000000..de19164575 --- /dev/null +++ b/src/attribute-editor/utils.ts @@ -0,0 +1,69 @@ +// 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 851cfe02e5..61fed49e55 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(); }); - [true, false].forEach(mobile => { - test(`toggles category on click when mobile=${mobile}`, () => { + describe.each([true, false])('mobile=%b', mobile => { + test('toggles category on click', () => { (useMobile as jest.Mock).mockReturnValue(mobile); const categoryId = 'category'; const itemId = 'nested-item'; @@ -232,5 +232,39 @@ 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 4e036b55a3..f7149a0ea2 100644 --- a/src/button-dropdown/__tests__/button-dropdown.test.tsx +++ b/src/button-dropdown/__tests__/button-dropdown.test.tsx @@ -435,6 +435,15 @@ 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 95e5d52022..23e080b442 100644 --- a/src/button-dropdown/interfaces.ts +++ b/src/button-dropdown/interfaces.ts @@ -188,9 +188,13 @@ export namespace ButtonDropdownProps { export interface Ref { /** - * Focuses the underlying native button. + * Focuses the underlying native button. If a main action is defined this will focus that button. */ - focus(): void; + focus(options?: FocusOptions): void; + /** + * Focuses the underlying native button for the dropdown. + */ + focusDropdownTrigger(options?: FocusOptions): void; } } diff --git a/src/button-dropdown/internal.tsx b/src/button-dropdown/internal.tsx index 9772fcb7b2..f84d170d2d 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, useRef } from 'react'; +import React, { useEffect, useImperativeHandle, useRef } from 'react'; import clsx from 'clsx'; import { warnOnce } from '@cloudscape-design/component-toolkit/internal'; @@ -13,7 +13,6 @@ 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'; @@ -111,7 +110,18 @@ const InternalButtonDropdown = React.forwardRef( const mainActionRef = useRef(null); const triggerRef = useRef(null); - useForwardFocus(ref, isMainAction ? mainActionRef : triggerRef); + useImperativeHandle( + ref, + () => ({ + focus(...args) { + (isMainAction ? mainActionRef : triggerRef).current?.focus(...args); + }, + focusDropdownTrigger(...args) { + triggerRef.current?.focus(...args); + }, + }), + [mainActionRef, triggerRef, isMainAction] + ); 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 f81471d284..a5a402ada4 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 3ced7c0775..8f74d80189 100644 --- a/src/button-group/item-element.tsx +++ b/src/button-group/item-element.tsx @@ -3,6 +3,8 @@ 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'; @@ -28,11 +30,15 @@ const ItemElement = forwardRef( ref: React.Ref ) => { const containerRef = useRef(null); - const itemRef = useRef(null); + const buttonRef = useRef(null); + const fileInputRef = useRef(null); + const buttonDropdownRef = useRef(null); useImperativeHandle(ref, () => ({ focus: () => { - itemRef.current?.focus(); + buttonRef.current?.focus(); + fileInputRef.current?.focus(); + buttonDropdownRef.current?.focus(); }, })); @@ -117,7 +123,7 @@ const ItemElement = forwardRef( > {item.type === 'icon-button' && ( .${gridstyles.grid} > .${gridstyles['grid-column']}:nth-child(${column}) > div > .${styles.field}`, - FormFieldWrapper - ); + return this.findComponent(`.${styles.field}:nth-child(${column})`, 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 { From 13ff9d4597e62ebf8c95d8391f6370a0bea12c01 Mon Sep 17 00:00:00 2001 From: Cansu Aksu <45541797+cansuaa@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:32:03 +0100 Subject: [PATCH 4/6] feat: Add bottom border to top navigation (#3192) --- src/top-navigation/styles.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/top-navigation/styles.scss b/src/top-navigation/styles.scss index 99740f00f5..b79520aabf 100644 --- a/src/top-navigation/styles.scss +++ b/src/top-navigation/styles.scss @@ -10,6 +10,7 @@ .top-navigation { @include styles.styles-reset; background: awsui.$color-background-container-content; + border-block-end: awsui.$border-divider-section-width solid awsui.$color-border-divider-default; > .padding-box { display: flex; From 3839ca0d4b2125547960424d2c903e55b411f31e Mon Sep 17 00:00:00 2001 From: Joan Perals Date: Thu, 23 Jan 2025 12:05:31 +0100 Subject: [PATCH 5/6] fix: Prevent dropdown misplacement in iOS (#3202) --- .../__tests__/dropdown-position.test.ts | 91 +++++++++++++++++++ .../dropdown/dropdown-fit-handler.ts | 5 +- .../components/dropdown/dropdown-position.ts | 49 ++++++++++ src/internal/components/dropdown/index.tsx | 44 ++++----- 4 files changed, 159 insertions(+), 30 deletions(-) create mode 100644 src/internal/components/dropdown/__tests__/dropdown-position.test.ts create mode 100644 src/internal/components/dropdown/dropdown-position.ts diff --git a/src/internal/components/dropdown/__tests__/dropdown-position.test.ts b/src/internal/components/dropdown/__tests__/dropdown-position.test.ts new file mode 100644 index 0000000000..897d918723 --- /dev/null +++ b/src/internal/components/dropdown/__tests__/dropdown-position.test.ts @@ -0,0 +1,91 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { applyDropdownPositionRelativeToViewport } from '../../../../../lib/components/internal/components/dropdown/dropdown-position'; + +describe('applyDropdownPositionRelativeToViewport', () => { + const triggerRect = { + blockSize: 50, + inlineSize: 100, + insetBlockStart: 100, + insetInlineStart: 100, + insetBlockEnd: 150, + insetInlineEnd: 200, + }; + + const baseDropdownPosition = { + blockSize: '100px', + inlineSize: '100px', + insetInlineStart: '100px', + dropBlockStart: false, + dropInlineStart: false, + }; + + test("sets block end when the dropdown is anchored to the trigger's block start (expands up)", () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: { ...baseDropdownPosition, dropBlockStart: true }, + isMobile: false, + }); + expect(dropdownElement.style.insetBlockEnd).toBeTruthy(); + expect(dropdownElement.style.insetBlockStart).toBeFalsy(); + }); + + test("aligns block start with the trigger's block end when the dropdown is anchored to the trigger's block end (expands down)", () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: baseDropdownPosition, + isMobile: false, + }); + expect(dropdownElement.style.insetBlockEnd).toBeFalsy(); + expect(dropdownElement.style.insetBlockStart).toEqual(`${triggerRect.insetBlockEnd}px`); + }); + + test("aligns inline start with the trigger's inline start when the dropdown is anchored to the trigger's inline start (anchored from the left in LTR)", () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: baseDropdownPosition, + isMobile: false, + }); + expect(dropdownElement.style.insetInlineStart).toEqual(`${triggerRect.insetInlineStart}px`); + }); + + test("sets inline end when the dropdown is anchored to the trigger's inline start (anchored from the right in LTR)", () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: { ...baseDropdownPosition, dropInlineStart: true }, + isMobile: false, + }); + expect(dropdownElement.style.insetInlineStart).toBeTruthy(); + }); + + test('uses fixed position on desktop', () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: baseDropdownPosition, + isMobile: false, + }); + expect(dropdownElement.style.position).toEqual('fixed'); + }); + + test('uses absolute position on mobile', () => { + const dropdownElement = document.createElement('div'); + applyDropdownPositionRelativeToViewport({ + dropdownElement, + triggerRect, + position: baseDropdownPosition, + isMobile: true, + }); + expect(dropdownElement.style.position).toEqual('absolute'); + }); +}); diff --git a/src/internal/components/dropdown/dropdown-fit-handler.ts b/src/internal/components/dropdown/dropdown-fit-handler.ts index d856fd7cc1..0144763e18 100644 --- a/src/internal/components/dropdown/dropdown-fit-handler.ts +++ b/src/internal/components/dropdown/dropdown-fit-handler.ts @@ -4,6 +4,7 @@ import { getLogicalBoundingClientRect } from '@cloudscape-design/component-toolk import { getBreakpointValue } from '../../breakpoints'; import { BoundingBox, getOverflowParentDimensions, getOverflowParents } from '../../utils/scrollable-containers'; +import { LogicalDOMRect } from './dropdown-position'; import styles from './styles.css.js'; @@ -361,7 +362,7 @@ export const calculatePosition = ( isMobile: boolean, minWidth?: number, stretchBeyondTriggerWidth?: boolean -): [DropdownPosition, DOMRect] => { +): [DropdownPosition, LogicalDOMRect] => { // cleaning previously assigned values, // so that they are not reused in case of screen resize and similar events verticalContainerElement.style.maxBlockSize = ''; @@ -393,6 +394,6 @@ export const calculatePosition = ( isMobile, stretchBeyondTriggerWidth, }); - const triggerBox = triggerElement.getBoundingClientRect(); + const triggerBox = getLogicalBoundingClientRect(triggerElement); return [position, triggerBox]; }; diff --git a/src/internal/components/dropdown/dropdown-position.ts b/src/internal/components/dropdown/dropdown-position.ts new file mode 100644 index 0000000000..3ad3aaf2c0 --- /dev/null +++ b/src/internal/components/dropdown/dropdown-position.ts @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { DropdownPosition } from './dropdown-fit-handler'; + +export interface LogicalDOMRect { + blockSize: number; + inlineSize: number; + insetBlockStart: number; + insetBlockEnd: number; + insetInlineStart: number; + insetInlineEnd: number; +} + +// Applies its position to the dropdown element when expandToViewport is set to true. +export function applyDropdownPositionRelativeToViewport({ + position, + dropdownElement, + triggerRect, + isMobile, +}: { + position: DropdownPosition; + dropdownElement: HTMLElement; + triggerRect: LogicalDOMRect; + isMobile: boolean; +}) { + // Fixed positions are not respected in iOS when the virtual keyboard is being displayed. + // For this reason we use absolute positioning in mobile. + const useAbsolutePositioning = isMobile; + + // Since when using expandToViewport=true the dropdown is attached to the root of the body, + // the same coordinates can be used for fixed or absolute position, + // except when using absolute position we need to take into account the scroll position of the body itself. + const verticalScrollOffset = useAbsolutePositioning ? document.documentElement.scrollTop : 0; + const horizontalScrollOffset = useAbsolutePositioning ? document.documentElement.scrollLeft : 0; + + dropdownElement.style.position = useAbsolutePositioning ? 'absolute' : 'fixed'; + + if (position.dropBlockStart) { + dropdownElement.style.insetBlockEnd = `calc(100% - ${verticalScrollOffset + triggerRect.insetBlockStart}px)`; + } else { + dropdownElement.style.insetBlockStart = `${verticalScrollOffset + triggerRect.insetBlockEnd}px`; + } + if (position.dropInlineStart) { + dropdownElement.style.insetInlineStart = `calc(${horizontalScrollOffset + triggerRect.insetInlineEnd}px - ${position.inlineSize})`; + } else { + dropdownElement.style.insetInlineStart = `${horizontalScrollOffset + triggerRect.insetInlineStart}px`; + } +} diff --git a/src/internal/components/dropdown/index.tsx b/src/internal/components/dropdown/index.tsx index 9a449914fd..124a84d054 100644 --- a/src/internal/components/dropdown/index.tsx +++ b/src/internal/components/dropdown/index.tsx @@ -26,6 +26,7 @@ import { hasEnoughSpaceToStretchBeyondTriggerWidth, InteriorDropdownPosition, } from './dropdown-fit-handler'; +import { applyDropdownPositionRelativeToViewport, LogicalDOMRect } from './dropdown-position'; import { DropdownProps } from './interfaces'; import styles from './styles.css.js'; @@ -196,7 +197,7 @@ const Dropdown = ({ const setDropdownPosition = ( position: DropdownPosition | InteriorDropdownPosition, - triggerBox: DOMRect, + triggerBox: LogicalDOMRect, target: HTMLDivElement, verticalContainer: HTMLDivElement ) => { @@ -233,17 +234,12 @@ const Dropdown = ({ // Position normal overflow dropdowns with fixed positioning relative to viewport if (expandToViewport && !interior) { - target.style.position = 'fixed'; - if (position.dropBlockStart) { - target.style.insetBlockEnd = `calc(100% - ${triggerBox.top}px)`; - } else { - target.style.insetBlockStart = `${triggerBox.bottom}px`; - } - if (position.dropInlineStart) { - target.style.insetInlineStart = `calc(${triggerBox.right}px - ${position.inlineSize})`; - } else { - target.style.insetInlineStart = `${triggerBox.left}px`; - } + applyDropdownPositionRelativeToViewport({ + position, + dropdownElement: target, + triggerRect: triggerBox, + isMobile, + }); // Keep track of the initial dropdown position and direction. // Dropdown direction doesn't need to change as the user scrolls, just needs to stay attached to the trigger. fixedPosition.current = position; @@ -390,21 +386,13 @@ const Dropdown = ({ return; } const updateDropdownPosition = () => { - if (triggerRef.current && dropdownRef.current && verticalContainerRef.current) { - const triggerRect = getLogicalBoundingClientRect(triggerRef.current); - const target = dropdownRef.current; - if (fixedPosition.current) { - if (fixedPosition.current.dropBlockStart) { - dropdownRef.current.style.insetBlockEnd = `calc(100% - ${triggerRect.insetBlockStart}px)`; - } else { - target.style.insetBlockStart = `${triggerRect.insetBlockEnd}px`; - } - if (fixedPosition.current.dropInlineStart) { - target.style.insetInlineStart = `calc(${triggerRect.insetInlineEnd}px - ${fixedPosition.current.inlineSize})`; - } else { - target.style.insetInlineStart = `${triggerRect.insetInlineStart}px`; - } - } + if (triggerRef.current && dropdownRef.current && verticalContainerRef.current && fixedPosition.current) { + applyDropdownPositionRelativeToViewport({ + position: fixedPosition.current, + dropdownElement: dropdownRef.current, + triggerRect: getLogicalBoundingClientRect(triggerRef.current), + isMobile, + }); } }; @@ -416,7 +404,7 @@ const Dropdown = ({ return () => { controller.abort(); }; - }, [open, expandToViewport]); + }, [open, expandToViewport, isMobile]); const referrerId = useUniqueId(); From 76a85a0a9bab7d01dd78abc1ce8fd64a4af5f2d8 Mon Sep 17 00:00:00 2001 From: Katie George Date: Thu, 23 Jan 2025 03:33:41 -0800 Subject: [PATCH 6/6] feat: Inline multiselect tokens (#3185) Co-authored-by: Katie George --- pages/multiselect/constants.ts | 22 +++++++++++++++++++ pages/multiselect/multiselect.test.page.tsx | 7 +++--- .../__snapshots__/documenter.test.ts.snap | 6 +++++ .../__tests__/multiselect.test.tsx | 10 ++++----- src/multiselect/interfaces.ts | 4 ++++ src/multiselect/internal.tsx | 2 +- 6 files changed, 42 insertions(+), 9 deletions(-) diff --git a/pages/multiselect/constants.ts b/pages/multiselect/constants.ts index 21b4d782f4..0c7ba8e221 100644 --- a/pages/multiselect/constants.ts +++ b/pages/multiselect/constants.ts @@ -11,3 +11,25 @@ export const deselectAriaLabel = (option: MultiselectProps.Option) => { const label = option?.value || option?.label; return label ? `Deselect ${label}` : 'no label'; }; + +export const getInlineAriaLabel = (selectedOptions: MultiselectProps.Options) => { + let label; + + if (selectedOptions.length === 0) { + label = 0; + } + + if (selectedOptions.length === 1) { + label = selectedOptions[0].label; + } + + if (selectedOptions.length === 2) { + label = `${selectedOptions[0].label} and ${selectedOptions[1].label}`; + } + + if (selectedOptions.length > 2) { + label = `${selectedOptions[0].label}, ${selectedOptions[1].label}, and ${selectedOptions.length - 2} more`; + } + + return label + ' selected'; +}; diff --git a/pages/multiselect/multiselect.test.page.tsx b/pages/multiselect/multiselect.test.page.tsx index 5a19befb3d..fbbf1c107f 100644 --- a/pages/multiselect/multiselect.test.page.tsx +++ b/pages/multiselect/multiselect.test.page.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import Box from '~components/box'; import Multiselect, { MultiselectProps } from '~components/multiselect'; -import { deselectAriaLabel, i18nStrings } from './constants'; +import { deselectAriaLabel, getInlineAriaLabel, i18nStrings } from './constants'; const _selectedOptions1 = [ { @@ -113,6 +113,7 @@ const options2 = [ ], }, ]; + export default function MultiselectPage() { const [selectedOptions1, setSelectedOptions1] = React.useState(_selectedOptions1); const [selectedOptions2, setSelectedOptions2] = React.useState(_selectedOptions1); @@ -235,14 +236,14 @@ export default function MultiselectPage() { Test: Inline tokens
{ setSelectedOptions7(event.detail.selectedOptions); }} diff --git a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap index 0fed0e3e02..f83e1c2da5 100644 --- a/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap @@ -11259,6 +11259,12 @@ use the \`id\` attribute, consider setting it on a parent element instead.", "optional": true, "type": "string", }, + { + "description": "Shows tokens inside the trigger instead of below it.", + "name": "inlineTokens", + "optional": true, + "type": "boolean", + }, { "description": "Overrides the invalidation state. Usually the invalid state comes from the parent \`FormField\`component, diff --git a/src/multiselect/__tests__/multiselect.test.tsx b/src/multiselect/__tests__/multiselect.test.tsx index 00962a04df..b82eb7c0dd 100644 --- a/src/multiselect/__tests__/multiselect.test.tsx +++ b/src/multiselect/__tests__/multiselect.test.tsx @@ -650,10 +650,10 @@ test('Trigger receives focus when autofocus is true', () => { expect(document.activeElement).toBe(wrapper.findTrigger().getElement()); }); -describe('With inline tokens (private API)', () => { +describe('With inline tokens', () => { it('can render inline tokens', () => { const { wrapper } = renderMultiselect( - + ); // Trigger contains token labels and the number of selected items @@ -666,7 +666,7 @@ describe('With inline tokens (private API)', () => { it('shows placeholder when no items are selected', () => { const { wrapper } = renderMultiselect( - + ); expect(wrapper.findTrigger().getElement()).toHaveTextContent('Choose something'); @@ -677,7 +677,7 @@ describe('With inline tokens (private API)', () => { { value: '1', label: 'First', description: 'description', tags: ['tag'], labelTag: 'label' }, ]; const { wrapper } = renderMultiselect( - + ); expect(wrapper.findTrigger().getElement()).toHaveTextContent('First'); @@ -689,7 +689,7 @@ describe('With inline tokens (private API)', () => { it('shows multiple selected options inline', () => { const { wrapper } = renderMultiselect( diff --git a/src/multiselect/interfaces.ts b/src/multiselect/interfaces.ts index 7b4cf65540..5ebe51e7ee 100644 --- a/src/multiselect/interfaces.ts +++ b/src/multiselect/interfaces.ts @@ -23,6 +23,10 @@ export interface MultiselectProps extends BaseSelectProps { * Only use this if the selected options are displayed elsewhere on the page. */ hideTokens?: boolean; + /** + * Shows tokens inside the trigger instead of below it. + */ + inlineTokens?: boolean; /** * Specifies an `aria-label` for the token deselection button. * @i18n diff --git a/src/multiselect/internal.tsx b/src/multiselect/internal.tsx index f2ed2cf44b..72fc1a3b34 100644 --- a/src/multiselect/internal.tsx +++ b/src/multiselect/internal.tsx @@ -28,7 +28,7 @@ type InternalMultiselectProps = SomeRequired< MultiselectProps, 'options' | 'selectedOptions' | 'filteringType' | 'statusType' | 'keepOpen' | 'hideTokens' > & - InternalBaseComponentProps & { inlineTokens?: boolean }; + InternalBaseComponentProps; const InternalMultiselect = React.forwardRef( (