diff --git a/pages/file-upload/permutations.page.tsx b/pages/file-upload/permutations.page.tsx index c5cbec1239..cb0348755a 100644 --- a/pages/file-upload/permutations.page.tsx +++ b/pages/file-upload/permutations.page.tsx @@ -26,7 +26,7 @@ const permutations = createPermutations(null); const [acceptMultiple, setAcceptMultiple] = useState(true); + const [verticalAlignment, setVerticalAlignment] = useState(true); const formState = useContractFilesForm(); const contractsValidationErrors = validateContractFiles(formState.files); @@ -40,11 +41,16 @@ export default function FileUploadScenarioStandalone() { Accept multiple files + setVerticalAlignment(event.detail.checked)}> + Vertical alignment + + ", }, + { + "description": "Alignment of the file tokens. Defaults to "vertical".", + "inlineType": { + "name": "FileUploadProps.FileTokenAlignment", + "type": "union", + "values": [ + "vertical", + "horizontal", + ], + }, + "name": "fileTokenAlignment", + "optional": true, + "type": "string", + }, { "description": "An array of file warnings corresponding to the files in the \`value\`.", "name": "fileWarnings", 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 ea13fb73e6..c8025d20ff 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 @@ -250,10 +250,6 @@ exports[`test-utils selectors 1`] = ` "awsui_root_gwq0h", ], "file-upload": [ - "awsui_file-option-last-modified_ezgb4", - "awsui_file-option-name_ezgb4", - "awsui_file-option-size_ezgb4", - "awsui_file-option-thumbnail-image_ezgb4", "awsui_hints_1ubbm", "awsui_root_1ubbm", ], @@ -339,6 +335,10 @@ exports[`test-utils selectors 1`] = ` "awsui_dropdown_qwoo0", "awsui_file-input-button_181f9", "awsui_file-input_181f9", + "awsui_file-option-last-modified_ofwwb", + "awsui_file-option-name_ofwwb", + "awsui_file-option-size_ofwwb", + "awsui_file-option-thumbnail_ofwwb", "awsui_filter-container_z5mul", "awsui_filtering-match-highlight_1p2cx", "awsui_handle_sdha6", @@ -365,6 +365,7 @@ exports[`test-utils selectors 1`] = ` "awsui_root_1qprf", "awsui_root_1t44z", "awsui_root_1tk3k", + "awsui_root_9f1dn", "awsui_root_qwoo0", "awsui_root_vrgzu", "awsui_selectable-item_15o6u", @@ -374,6 +375,7 @@ exports[`test-utils selectors 1`] = ` "awsui_ticks--y_f0fot", "awsui_title_1kjc7", "awsui_toggle_gfwv3", + "awsui_token_ofwwb", "awsui_value_10ipo", "awsui_wrapper_1wepg", ], diff --git a/src/file-upload/__tests__/file-upload.test.tsx b/src/file-upload/__tests__/file-upload.test.tsx index 8ac785ee43..8bcea46d70 100644 --- a/src/file-upload/__tests__/file-upload.test.tsx +++ b/src/file-upload/__tests__/file-upload.test.tsx @@ -10,8 +10,6 @@ import FileUpload, { FileUploadProps } from '../../../lib/components/file-upload import createWrapper from '../../../lib/components/test-utils/dom'; import FileDropzoneWrapper from '../../../lib/components/test-utils/dom/internal/file-dropzone'; -import tokenListSelectors from '../../../lib/components/internal/components/token-list/styles.selectors.js'; - jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), warnOnce: jest.fn(), @@ -214,11 +212,6 @@ describe('FileUpload input', () => { }); describe('File upload tokens', () => { - test('token list is not rendered when `multiple=false`', () => { - const wrapper = render({ multiple: false, value: [file1] }); - expect(wrapper.find(tokenListSelectors.root)).toBeNull(); - }); - test(`when multiple=true all file tokens are shown`, () => { const wrapper = render({ multiple: true, value: [file1, file2] }); @@ -231,100 +224,6 @@ describe('File upload tokens', () => { expect(wrapper.findFileToken(2)!.getElement()).toHaveTextContent('test-file-2.txt'); }); - test('dev warning is issued when using multiple files with a singular file upload', () => { - render({ value: [file1, file2] }); - - expect(warnOnce).toHaveBeenCalledTimes(1); - expect(warnOnce).toHaveBeenCalledWith('FileUpload', 'Value must be an array of size 0 or 1 when `multiple=false`.'); - }); - - test('file token remove button has ARIA label set', () => { - const wrapper = render({ value: [file1] }); - expect(wrapper.findFileToken(1)!.findRemoveButton()!.getElement()).toHaveAccessibleName('Remove file 1'); - }); - - test('selected file can be removed', () => { - const wrapperSingular = render({ value: [file1] }); - wrapperSingular.findFileToken(1)!.findRemoveButton()!.click(); - - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: [] } })); - - const wrapperMultiple = render({ multiple: true, value: [file1, file2] }); - wrapperMultiple.findFileToken(1)!.findRemoveButton()!.click(); - - expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ detail: { value: [file2] } })); - }); - - test('file token only shows name by default', () => { - const wrapper = render({ value: [file1] }); - - expect(wrapper.findFileToken(1)!.findFileName().getElement()).toHaveTextContent('test-file-1.txt'); - expect(wrapper.findFileToken(1)!.findFileSize()).toBe(null); - expect(wrapper.findFileToken(1)!.findFileLastModified()).toBe(null); - expect(wrapper.findFileToken(1)!.findFileThumbnail()).toBe(null); - }); - - test('file token metadata can be opt-in', () => { - const wrapper = render({ - value: [file1], - showFileSize: true, - showFileLastModified: true, - }); - expect(wrapper.findFileToken(1)!.findFileName().getElement()).toHaveTextContent('test-file-1.txt'); - expect(wrapper.findFileToken(1)!.findFileSize()!.getElement()).toHaveTextContent('0.01 KB'); - expect(wrapper.findFileToken(1)!.findFileLastModified()!.getElement()).toHaveTextContent('2020-06-01T00:00:00'); - }); - - test('thumbnail is only shown when file type starts with "image"', () => { - expect( - render({ value: [file1], showFileThumbnail: true }) - .findFileToken(1)! - .findFileThumbnail() - ).toBe(null); - - expect( - render({ value: [file2], showFileThumbnail: true }) - .findFileToken(1)! - .findFileThumbnail() - ).not.toBe(null); - }); - - test('selected file size can be customized', () => { - const wrapper = render({ - value: [file1], - showFileSize: true, - i18nStrings: { - ...defaultProps.i18nStrings, - formatFileSize: sizeInBytes => `${sizeInBytes} bytes`, - }, - }); - expect(wrapper.findFileToken(1)!.findFileSize()!.getElement()).toHaveTextContent('14 bytes'); - }); - - test('selected file last update timestamp can be customized', () => { - const wrapper = render({ - value: [file1], - showFileLastModified: true, - i18nStrings: { - ...defaultProps.i18nStrings, - formatFileLastModified: date => `${date.getFullYear()} year`, - }, - }); - expect(wrapper.findFileToken(1)!.findFileLastModified()!.getElement()).toHaveTextContent('2020 year'); - }); - - test('the `tokenLimit` property controls the number of tokens shown by default', () => { - const wrapper = render({ multiple: true, value: [file1, file2], tokenLimit: 1 }); - expect(wrapper.findFileTokens()).toHaveLength(1); - expect(wrapper.getElement().textContent).toContain('Show more files'); - }); - - test('file tokens have aria labels set to file names', () => { - const wrapper = render({ multiple: true, value: [file1, file2] }); - expect(wrapper.findFileToken(1)!.getElement()).toHaveAttribute('aria-label', file1.name); - expect(wrapper.findFileToken(2)!.getElement()).toHaveAttribute('aria-label', file2.name); - }); - test('file errors are associated to file tokens', () => { const wrapper = render({ multiple: true, value: [file1, file2], fileErrors: ['Error 1', 'Error 2'] }); expect(wrapper.findFileToken(1)!.getElement()).toHaveAccessibleDescription('Error 1'); @@ -358,23 +257,6 @@ describe('File upload dropzone', () => { }); describe('Focusing behavior', () => { - test.each([1, 2])( - `Focus is dispatched to the next token when the token before it is removed, tokenLimit=%s`, - tokenLimit => { - const wrapper = renderStateful({ multiple: true, value: [file1, file2], tokenLimit }); - wrapper.findFileToken(1)!.findRemoveButton().click(); - - expect(wrapper.findFileToken(1)!.findRemoveButton().getElement()).toHaveFocus(); - } - ); - - test('Focus is dispatched to the previous token when removing the token at the end', () => { - const wrapper = renderStateful({ multiple: true, value: [file1, file2] }); - wrapper.findFileToken(2)!.findRemoveButton().click(); - - expect(wrapper.findFileToken(1)!.findRemoveButton().getElement()).toHaveFocus(); - }); - test.each([false, true])( 'Focus is dispatched to the file input when the last token is removed, multiple=%s', multiple => { diff --git a/src/file-upload/file-option/index.tsx b/src/file-upload/file-option/index.tsx deleted file mode 100644 index ee36f07987..0000000000 --- a/src/file-upload/file-option/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -import React from 'react'; - -import InternalBox from '../../box/internal'; -import InternalSpaceBetween from '../../space-between/internal'; -import * as defaultFormatters from '../default-formatters'; -import { FileMetadata, FileUploadProps } from '../interfaces'; -import { FileOptionThumbnail } from './thumbnail'; - -import styles from './styles.css.js'; - -interface FileOptionProps { - file: File; - metadata: FileMetadata; - i18nStrings: FileUploadProps.I18nStrings; -} - -export function FileOption({ file, metadata, i18nStrings }: FileOptionProps) { - const isImage = file.type.startsWith('image/'); - const formatFileSize = i18nStrings.formatFileSize ?? defaultFormatters.formatFileSize; - const formatFileLastModified = i18nStrings.formatFileLastModified ?? defaultFormatters.formatFileLastModified; - return ( - - {metadata.showFileThumbnail && isImage && } - -
- - {file.name} - - {metadata.showFileSize && file.size ? ( - - {formatFileSize(file.size)} - - ) : null} - - {metadata.showFileLastModified && file.lastModified ? ( - - {formatFileLastModified(new Date(file.lastModified))} - - ) : null} - -
-
- ); -} diff --git a/src/file-upload/file-option/styles.scss b/src/file-upload/file-option/styles.scss deleted file mode 100644 index dce4393930..0000000000 --- a/src/file-upload/file-option/styles.scss +++ /dev/null @@ -1,34 +0,0 @@ -/* - Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - SPDX-License-Identifier: Apache-2.0 -*/ - -@use '../../internal/styles/tokens' as awsui; - -.file-option-name, -.file-option-size, -.file-option-last-modified, -.file-option-thumbnail { - /* used in test-utils */ -} - -.file-option { - inline-size: 100%; - min-inline-size: 0; - display: flex; - gap: awsui.$space-scaled-xs; -} - -.file-option-thumbnail { - margin-block-start: awsui.$space-static-xxs; - max-inline-size: 60px; -} - -.file-option-thumbnail-image { - inline-size: 100%; - block-size: auto; -} - -.file-option-metadata { - inline-size: 100%; -} diff --git a/src/file-upload/interfaces.ts b/src/file-upload/interfaces.ts index 5205ab8058..a3008aba7d 100644 --- a/src/file-upload/interfaces.ts +++ b/src/file-upload/interfaces.ts @@ -64,6 +64,10 @@ export interface FileUploadProps extends BaseComponentProps, FormFieldCommonVali * An array of file warnings corresponding to the files in the `value`. */ fileWarnings?: ReadonlyArray; + /** + * Alignment of the file tokens. Defaults to "vertical". + */ + fileTokenAlignment?: FileUploadProps.FileTokenAlignment; /** * An object containing all the localized strings required by the component: * * `uploadButtonText` (function): A function to render the text of the file upload button. It takes `multiple` attribute to define plurality. @@ -89,6 +93,8 @@ export namespace FileUploadProps { file: File; } + export type FileTokenAlignment = 'vertical' | 'horizontal'; + export interface I18nStrings { uploadButtonText: (multiple: boolean) => string; dropzoneText: (multiple: boolean) => string; diff --git a/src/file-upload/internal.tsx b/src/file-upload/internal.tsx index 0af85798e1..56b0e79f9d 100644 --- a/src/file-upload/internal.tsx +++ b/src/file-upload/internal.tsx @@ -13,7 +13,7 @@ import { ConstraintText, FormFieldError, FormFieldWarning } from '../form-field/ import { getBaseProps } from '../internal/base-component'; import InternalFileDropzone, { useFilesDragging } from '../internal/components/file-dropzone'; import InternalFileInput from '../internal/components/file-input'; -import TokenList from '../internal/components/token-list'; +import InternalFileTokenGroup from '../internal/components/file-token-group'; import { fireNonCancelableEvent } from '../internal/events'; import checkControlled from '../internal/hooks/check-controlled'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; @@ -22,8 +22,6 @@ import { useMergeRefs } from '../internal/hooks/use-merge-refs'; import { useUniqueId } from '../internal/hooks/use-unique-id'; import { joinStrings } from '../internal/utils/strings'; import InternalSpaceBetween from '../space-between/internal'; -import { Token } from '../token-group/token'; -import { FileOption } from './file-option'; import { FileUploadProps } from './interfaces'; import fileInputStyles from '../internal/components/file-input/styles.css.js'; @@ -52,6 +50,7 @@ function InternalFileUpload( warningText, fileErrors, fileWarnings, + fileTokenAlignment = 'vertical', ...restProps }: InternalFileUploadProps, externalRef: ForwardedRef @@ -164,49 +163,21 @@ function InternalFileUpload( )} - {!multiple && value.length > 0 ? ( - - onFileRemove(0)} - errorText={fileErrors?.[0]} - warningText={fileWarnings?.[0]} - errorIconAriaLabel={i18nStrings.errorIconAriaLabel} - warningIconAriaLabel={i18nStrings.warningIconAriaLabel} - data-index={0} - > - - - - ) : null} - - {multiple && value.length > 0 ? ( - - ( - onFileRemove(fileIndex)} - errorText={fileErrors?.[fileIndex]} - warningText={fileWarnings?.[fileIndex]} - errorIconAriaLabel={i18nStrings.errorIconAriaLabel} - warningIconAriaLabel={i18nStrings.warningIconAriaLabel} - data-index={fileIndex} - > - - - )} - limit={tokenLimit} - i18nStrings={{ - limitShowFewer: i18nStrings.limitShowFewer, - limitShowMore: i18nStrings.limitShowMore, - }} - /> - + {value.length > 0 ? ( + ({ + file, + errorText: fileErrors?.[fileIndex], + warningText: fileWarnings?.[fileIndex], + }))} + showFileLastModified={metadata.showFileLastModified} + showFileSize={metadata.showFileSize} + showFileThumbnail={metadata.showFileThumbnail} + i18nStrings={i18nStrings} + onDismiss={event => onFileRemove(event.detail.fileIndex)} + /> ) : null} ); diff --git a/src/file-upload/__tests__/default-formatters.test.ts b/src/internal/components/file-token-group/__tests__/default-formatters.test.tsx similarity index 100% rename from src/file-upload/__tests__/default-formatters.test.ts rename to src/internal/components/file-token-group/__tests__/default-formatters.test.tsx diff --git a/src/internal/components/file-token-group/__tests__/file-token-group.test.tsx b/src/internal/components/file-token-group/__tests__/file-token-group.test.tsx new file mode 100644 index 0000000000..14fca2e7e8 --- /dev/null +++ b/src/internal/components/file-token-group/__tests__/file-token-group.test.tsx @@ -0,0 +1,342 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useState } from 'react'; +import { act, fireEvent, render as testingLibraryRender } from '@testing-library/react'; + +import '../../../../__a11y__/to-validate-a11y'; +import FileTokenGroup, { + FileTokenGroupProps, +} from '../../../../../lib/components/internal/components/file-token-group'; +import createWrapper from '../../../../../lib/components/test-utils/dom'; +import FileTokenGroupWrapper from '../../../../../lib/components/test-utils/dom/internal/file-token-group'; + +import styles from '../../../../../lib/components/internal/components/file-token-group/styles.css.js'; +import testStyles from '../../../../../lib/components/internal/components/file-token-group/test-classes/styles.css.js'; +import tooltipStyles from '../../../../../lib/components/internal/components/tooltip/styles.selectors.js'; + +jest.mock('../../../../../lib/components/internal/utils/date-time', () => ({ + formatDateTime: () => '2020-06-01T00:00:00', +})); + +const onDismiss = jest.fn(); + +const defaultProps: FileTokenGroupProps = { + items: [], + onDismiss, + i18nStrings: { + removeFileAriaLabel: fileIndex => `Remove file ${fileIndex + 1}`, + errorIconAriaLabel: 'Error', + warningIconAriaLabel: 'Warning', + limitShowFewer: 'Show fewer files', + limitShowMore: 'Show more files', + }, +}; + +const file1 = new File([new Blob(['Test content 1'], { type: 'text/plain' })], 'test-file-1.txt', { + type: 'text/plain', + lastModified: 1590962400000, +}); +const file2 = new File([new Blob(['Test content 2'], { type: 'text/plain' })], 'test-file-2.txt', { + type: 'image/png', + lastModified: 1590962400000, +}); +const file3 = new File( + [new Blob(['Test content 3'], { type: 'text/plain' })], + 'test-file-3-with-a-really-long-name.txt', + { + type: 'image/png', + lastModified: 1590962400000, + } +); + +function render(props: Partial) { + testingLibraryRender( +
+ +
Test label
+
+ ); + const element = createWrapper().findByClassName(FileTokenGroupWrapper.rootSelector)!.getElement(); + return new FileTokenGroupWrapper(element); +} + +function renderStateful(props: Partial = {}) { + testingLibraryRender(); + const element = createWrapper().findByClassName(FileTokenGroupWrapper.rootSelector)!.getElement(); + return new FileTokenGroupWrapper(element); +} + +function StatefulFileTokenGroup({ items: initialItems = [], ...rest }: Partial) { + const [items, setItems] = useState(initialItems); + return ( + setItems(prev => prev.filter((_, index) => index !== event.detail.fileIndex))} + /> + ); +} + +describe('File upload tokens', () => { + test(`renders file tokens`, () => { + const wrapper = render({ items: [{ file: file1 }, { file: file2 }] }); + + expect(wrapper.findFileTokens()).toHaveLength(2); + + expect(wrapper.findFileTokens()[0].getElement()).toHaveTextContent('test-file-1.txt'); + expect(wrapper.findFileToken(1)!.getElement()).toHaveTextContent('test-file-1.txt'); + + expect(wrapper.findFileTokens()[1].getElement()).toHaveTextContent('test-file-2.txt'); + expect(wrapper.findFileToken(2)!.getElement()).toHaveTextContent('test-file-2.txt'); + }); + + test('file token remove button has ARIA label set', () => { + const wrapper = render({ items: [{ file: file1 }] }); + expect(wrapper.findFileToken(1)!.findRemoveButton()!.getElement()).toHaveAccessibleName('Remove file 1'); + }); + + test('selected file can be removed - single', () => { + const wrapperSingular = render({ items: [{ file: file1 }] }); + wrapperSingular.findFileToken(1)!.findRemoveButton()!.click(); + + expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ detail: { fileIndex: 0 } })); + }); + + test('selected file can be removed - multiple', () => { + const wrapperMultiple = render({ items: [{ file: file1 }, { file: file2 }] }); + wrapperMultiple.findFileToken(2)!.findRemoveButton()!.click(); + + expect(onDismiss).toHaveBeenCalledWith(expect.objectContaining({ detail: { fileIndex: 1 } })); + }); + + test('file token only shows name by default', () => { + const wrapper = render({ items: [{ file: file1 }] }); + + expect(wrapper.findFileToken(1)!.findFileName().getElement()).toHaveTextContent('test-file-1.txt'); + expect(wrapper.findFileToken(1)!.findFileSize()).toBe(null); + expect(wrapper.findFileToken(1)!.findFileLastModified()).toBe(null); + expect(wrapper.findFileToken(1)!.findFileThumbnail()).toBe(null); + }); + + test('file token metadata can be opt-in', () => { + const wrapper = render({ + items: [{ file: file1 }], + showFileSize: true, + showFileLastModified: true, + }); + expect(wrapper.findFileToken(1)!.findFileName().getElement()).toHaveTextContent('test-file-1.txt'); + expect(wrapper.findFileToken(1)!.findFileSize()!.getElement()).toHaveTextContent('0.01 KB'); + expect(wrapper.findFileToken(1)!.findFileLastModified()!.getElement()).toHaveTextContent('2020-06-01T00:00:00'); + }); + + test('thumbnail is only shown when file type starts with "image"', () => { + const wrapper = render({ items: [{ file: file1 }, { file: file2 }], showFileThumbnail: true }); + expect(wrapper.findFileToken(1)!.findFileThumbnail()).toBe(null); + + expect(wrapper.findFileToken(2)!.findFileThumbnail()).not.toBe(null); + }); + + test('selected file size can be customized', () => { + const wrapper = render({ + items: [{ file: file1 }], + showFileSize: true, + i18nStrings: { + ...defaultProps.i18nStrings, + formatFileSize: sizeInBytes => `${sizeInBytes} bytes`, + }, + }); + expect(wrapper.findFileToken(1)!.findFileSize()!.getElement()).toHaveTextContent('14 bytes'); + }); + + test('selected file last update timestamp can be customized', () => { + const wrapper = render({ + items: [{ file: file1 }], + showFileLastModified: true, + i18nStrings: { + ...defaultProps.i18nStrings, + formatFileLastModified: date => `${date.getFullYear()} year`, + }, + }); + expect(wrapper.findFileToken(1)!.findFileLastModified()!.getElement()).toHaveTextContent('2020 year'); + }); + + test('the `tokenLimit` property controls the number of tokens shown by default', () => { + const wrapper = render({ items: [{ file: file1 }, { file: file2 }], limit: 1 }); + expect(wrapper.findFileTokens()).toHaveLength(1); + expect(wrapper.getElement().textContent).toContain('Show more files'); + }); + + test('file tokens have aria labels set to file names', () => { + const wrapper = render({ items: [{ file: file1 }, { file: file2 }] }); + expect(wrapper.findFileToken(1)!.getElement()).toHaveAttribute('aria-label', file1.name); + expect(wrapper.findFileToken(2)!.getElement()).toHaveAttribute('aria-label', file2.name); + }); + + test('file errors are associated to file tokens', () => { + const wrapper = render({ + items: [ + { file: file1, errorText: 'Error 1' }, + { file: file2, errorText: 'Error 2' }, + ], + }); + expect(wrapper.findFileToken(1)!.findFileError().getElement()).toHaveTextContent('Error 1'); + expect(wrapper.findFileToken(1)!.getElement()).toHaveAccessibleDescription('Error 1'); + expect(wrapper.findFileToken(2)!.getElement()).toHaveAccessibleDescription('Error 2'); + }); + + test('file warnings are associated to file tokens', () => { + const wrapper = render({ + items: [ + { file: file1, warningText: 'Warning 1' }, + { file: file2, warningText: 'Warning 2' }, + ], + }); + expect(wrapper.findFileToken(1)!.findFileWarning().getElement()).toHaveTextContent('Warning 1'); + expect(wrapper.findFileToken(1)!.getElement()).toHaveAccessibleDescription('Warning 1'); + expect(wrapper.findFileToken(2)!.getElement()).toHaveAccessibleDescription('Warning 2'); + }); + + test('file error takes precedence over file warning associated to file tokens', () => { + const wrapper = render({ + items: [{ file: file1, errorText: 'Error 1', warningText: 'Warning 1' }], + }); + expect(wrapper.findFileToken(1)!.getElement()).toHaveAccessibleDescription('Error 1'); + expect(wrapper.findFileToken(1)!.getElement()).not.toHaveAccessibleDescription('Warning 1'); + }); +}); + +describe('File loading', () => { + test('Aria-disabled added when loading', () => { + const wrapper = render({ items: [{ file: file1, loading: true }, { file: file2 }] }); + + expect(wrapper.findFileToken(1)?.getElement()).toHaveAttribute('aria-disabled'); + expect(wrapper.findFileToken(2)?.getElement()).not.toHaveAttribute('aria-disabled'); + }); + + test('Spinner added when loading', () => { + const wrapper = render({ items: [{ file: file1, loading: true }, { file: file2 }] }); + + expect(wrapper.findFileToken(1)?.getElement().firstChild).toHaveClass(styles.loading); + expect(wrapper.findFileToken(2)?.getElement().firstChild).not.toHaveClass(styles.loading); + }); +}); + +describe('Tooltip', () => { + test('Should show ellipsis on long file names', () => { + const wrapper = render({ items: [{ file: file3 }] }); + act(() => { + fireEvent.mouseEnter(wrapper.findFileToken(1)!.findFileName().getElement()); + }); + + expect(wrapper.findFileToken(1)!.findFileName().getElement()).toHaveClass(testStyles['ellipsis-active']); + }); + + test('Should show tooltip on mouse enter', () => { + const wrapper = render({ items: [{ file: file3 }], alignment: 'horizontal' }); + + act(() => { + fireEvent.mouseEnter(wrapper.findFileToken(1)!.findFileName().getElement()); + }); + + expect(document.querySelector(`.${tooltipStyles.root}`)).not.toBeNull(); + + act(() => { + fireEvent.mouseLeave(wrapper.findFileToken(1)!.findFileName().getElement()); + }); + + expect(document.querySelector(`.${tooltipStyles.root}`)).toBeNull(); + }); +}); + +describe('Focusing behavior', () => { + test.each([1, 2])(`Focus is dispatched to the next token when the token before it is removed, limit=%s`, limit => { + const wrapper = renderStateful({ items: [{ file: file1 }, { file: file2 }], limit }); + wrapper.findFileToken(1)!.findRemoveButton().click(); + + expect(wrapper.findFileToken(1)!.findRemoveButton().getElement()).toHaveFocus(); + }); + + test('Focus is dispatched to the previous token when removing the token at the end', () => { + const wrapper = renderStateful({ items: [{ file: file1 }, { file: file2 }] }); + wrapper.findFileToken(2)!.findRemoveButton().click(); + + expect(wrapper.findFileToken(1)!.findRemoveButton().getElement()).toHaveFocus(); + }); +}); + +describe('a11y', () => { + test('empty', async () => { + const wrapper = render({ items: [] }); + await expect(wrapper.getElement()).toValidateA11y(); + }); + + test('single', async () => { + const wrapper = render({ + items: [{ file: file1 }], + showFileSize: true, + showFileLastModified: true, + }); + await expect(wrapper.getElement()).toValidateA11y(); + }); + + test('single w/ errors', async () => { + const wrapper = render({ + items: [{ file: file1, errorText: 'Error' }], + showFileSize: true, + showFileLastModified: true, + }); + + await expect(wrapper.getElement()).toValidateA11y(); + }); + + test('single w/ warnings', async () => { + const wrapper = render({ + items: [{ file: file1, warningText: 'Warning' }], + showFileSize: true, + showFileLastModified: true, + }); + + await expect(wrapper.getElement()).toValidateA11y(); + }); + + test('multiple', async () => { + const wrapper = render({ + items: [{ file: file1 }, { file: file2 }], + showFileSize: true, + showFileLastModified: true, + }); + await expect(wrapper.getElement()).toValidateA11y(); + }); + + test('multiple w/ errors', async () => { + const wrapper = render({ + items: [ + { file: file1, errorText: 'Error 1' }, + { file: file2, errorText: 'Error 2' }, + ], + showFileSize: true, + showFileLastModified: true, + }); + await expect(wrapper.getElement()).toValidateA11y(); + }); + + test('multiple w/ warnings', async () => { + const wrapper = render({ + items: [ + { file: file1, warningText: 'Warning 1' }, + { file: file2, warningText: 'Warning 2' }, + ], + showFileSize: true, + showFileLastModified: true, + }); + await expect(wrapper.getElement()).toValidateA11y(); + }); + + test('loading', async () => { + const wrapper = render({ + items: [{ file: file1, loading: true }], + }); + await expect(wrapper.getElement()).toValidateA11y(); + }); +}); diff --git a/src/internal/components/file-token-group/constants.scss b/src/internal/components/file-token-group/constants.scss new file mode 100644 index 0000000000..6c9aba203f --- /dev/null +++ b/src/internal/components/file-token-group/constants.scss @@ -0,0 +1,14 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../../styles/tokens' as awsui; + +$image-size: 48px; +$file-token-height: 68px; +$compact-token-width: 230px; +$spinner-size: awsui.$size-icon-normal; + +$token-background: awsui.$color-background-item-selected; +$token-border-color: awsui.$color-border-item-selected; diff --git a/src/file-upload/default-formatters.ts b/src/internal/components/file-token-group/default-formatters.ts similarity index 90% rename from src/file-upload/default-formatters.ts rename to src/internal/components/file-token-group/default-formatters.ts index 1374abc65b..3fffd6538b 100644 --- a/src/file-upload/default-formatters.ts +++ b/src/internal/components/file-token-group/default-formatters.ts @@ -1,7 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { formatDateTime } from '../internal/utils/date-time'; +import { formatDateTime } from '../../utils/date-time'; const KB = 1000; const MB = 1000 ** 2; diff --git a/src/internal/components/file-token-group/file-token.tsx b/src/internal/components/file-token-group/file-token.tsx new file mode 100644 index 0000000000..c3e852777c --- /dev/null +++ b/src/internal/components/file-token-group/file-token.tsx @@ -0,0 +1,194 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useRef, useState } from 'react'; +import clsx from 'clsx'; + +import InternalBox from '../../../box/internal.js'; +import { FormFieldError, FormFieldWarning } from '../../../form-field/internal'; +import InternalSpaceBetween from '../../../space-between/internal.js'; +import InternalSpinner from '../../../spinner/internal.js'; +import DismissButton from '../../../token-group/dismiss-button'; +import { TokenGroupProps } from '../../../token-group/interfaces.js'; +import { BaseComponentProps } from '../../base-component/index.js'; +import { useUniqueId } from '../../hooks/use-unique-id'; +import Tooltip from '../tooltip/index'; +import * as defaultFormatters from './default-formatters.js'; +import { FileOptionThumbnail } from './thumbnail.js'; + +import styles from './styles.css.js'; +import testUtilStyles from './test-classes/styles.css.js'; + +export namespace FileTokenProps { + export interface I18nStrings { + removeFileAriaLabel: (fileIndex: number) => string; + errorIconAriaLabel?: string; + warningIconAriaLabel?: string; + formatFileSize?: (sizeInBytes: number) => string; + formatFileLastModified?: (date: Date) => string; + } +} + +export interface FileTokenProps extends BaseComponentProps { + file: File; + onDismiss: () => void; + showFileSize?: boolean; + showFileLastModified?: boolean; + showFileThumbnail?: boolean; + errorText?: React.ReactNode; + warningText?: React.ReactNode; + loading?: boolean; + readOnly?: boolean; + i18nStrings: FileTokenProps.I18nStrings; + dismissLabel?: string; + alignment?: TokenGroupProps.Alignment; + groupContainsImage?: boolean; + isImage: boolean; + index: number; +} + +function InternalFileToken({ + file, + showFileLastModified, + showFileSize, + showFileThumbnail, + i18nStrings, + onDismiss, + errorText, + warningText, + readOnly, + loading, + alignment, + groupContainsImage, + isImage, + index, +}: FileTokenProps) { + const formatFileSize = i18nStrings.formatFileSize ?? defaultFormatters.formatFileSize; + const formatFileLastModified = i18nStrings.formatFileLastModified ?? defaultFormatters.formatFileLastModified; + + const errorId = useUniqueId('error'); + const warningId = useUniqueId('warning'); + + const showWarning = warningText && !errorText; + const containerRef = useRef(null); + const fileNameRef = useRef(null); + const fileNameContainerRef = useRef(null); + const [showTooltip, setShowTooltip] = useState(false); + + function isEllipsisActive() { + const span = fileNameRef.current; + const container = fileNameContainerRef.current; + + if (span && container) { + return span.offsetWidth >= container.offsetWidth; + } + return false; + } + + const fileIsSingleRow = + !showFileLastModified && !showFileSize && (!groupContainsImage || (groupContainsImage && !showFileThumbnail)); + + return ( +
+
+ {loading && ( +
+ +
+ )} + + {showFileThumbnail && isImage && } + +
+ +
setShowTooltip(true)} + onMouseOut={() => setShowTooltip(false)} + ref={fileNameContainerRef} + > + + {file.name} + +
+ + {showFileSize && file.size ? ( + + {formatFileSize(file.size)} + + ) : null} + + {showFileLastModified && file.lastModified ? ( + + {formatFileLastModified(new Date(file.lastModified))} + + ) : null} +
+
+
+ {onDismiss && !readOnly && ( + + )} +
+ {errorText && ( + + {errorText} + + )} + {showWarning && ( + + {warningText} + + )} + {showTooltip && isEllipsisActive() && ( + {file.name}} + /> + )} +
+ ); +} + +export default InternalFileToken; diff --git a/src/internal/components/file-token-group/index.tsx b/src/internal/components/file-token-group/index.tsx new file mode 100644 index 0000000000..a7a1d95e20 --- /dev/null +++ b/src/internal/components/file-token-group/index.tsx @@ -0,0 +1,91 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useState } from 'react'; +import clsx from 'clsx'; + +import { getBaseProps } from '../../base-component/index.js'; +import { fireNonCancelableEvent } from '../../events/index.js'; +import { InternalBaseComponentProps } from '../../hooks/use-base-component/index.js'; +import { useListFocusController } from '../../hooks/use-list-focus-controller.js'; +import { useMergeRefs } from '../../hooks/use-merge-refs/index.js'; +import TokenList from '../token-list/index.js'; +import InternalFileToken from './file-token.js'; +import { FileTokenGroupProps } from './interfaces.js'; + +import tokenListStyles from '../token-list/styles.css.js'; +import styles from './styles.css.js'; +import testStyles from './test-classes/styles.css.js'; + +export { FileTokenGroupProps }; + +type InternalFileTokenGroupProps = FileTokenGroupProps & InternalBaseComponentProps; + +function InternalFileTokenGroup({ + items, + showFileLastModified, + showFileSize, + showFileThumbnail, + i18nStrings, + onDismiss, + limit, + readOnly, + alignment = 'vertical', + __internalRootRef, + ...restProps +}: InternalFileTokenGroupProps) { + const baseProps = getBaseProps(restProps); + + const [nextFocusIndex, setNextFocusIndex] = useState(null); + const tokenListRef = useListFocusController({ + nextFocusIndex, + onFocusMoved: target => { + target.focus(); + setNextFocusIndex(null); + }, + listItemSelector: `.${tokenListStyles['list-item']}`, + showMoreSelector: `.${tokenListStyles.toggle}`, + }); + + const mergedRef = useMergeRefs(__internalRootRef, tokenListRef); + + const isImage = (file: File) => file.type.startsWith('image/'); + const groupContainsImage = items.filter(item => isImage(item.file)).length > 0; + + return ( +
+ ( + { + fireNonCancelableEvent(onDismiss, { fileIndex }); + setNextFocusIndex(fileIndex); + }} + errorText={file.errorText} + warningText={file.warningText} + i18nStrings={i18nStrings} + loading={file.loading} + readOnly={readOnly} + alignment={alignment} + groupContainsImage={groupContainsImage} + isImage={isImage(file.file)} + index={fileIndex} + /> + )} + limit={limit} + i18nStrings={{ + limitShowFewer: i18nStrings.limitShowFewer, + limitShowMore: i18nStrings.limitShowMore, + }} + /> +
+ ); +} + +export default InternalFileTokenGroup; diff --git a/src/internal/components/file-token-group/interfaces.ts b/src/internal/components/file-token-group/interfaces.ts new file mode 100644 index 0000000000..08c2805bc1 --- /dev/null +++ b/src/internal/components/file-token-group/interfaces.ts @@ -0,0 +1,96 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BaseComponentProps } from '../../base-component'; +import { NonCancelableEventHandler } from '../../events'; + +export interface FileTokenGroupProps extends BaseComponentProps { + /** + * Show file size in the token. Use `i18nStrings.formatFileSize` to customize it. + */ + showFileSize?: boolean; + + /** + * Show file last modified timestamp in the token. Use `i18nStrings.formatFileLastModified` to customize it. + */ + showFileLastModified?: boolean; + + /** + * Show file thumbnail in the token. Only supported for images. + */ + showFileThumbnail?: boolean; + /** + * Called when the user clicks on the dismiss button. The token won't be automatically removed. + * Make sure that you add a listener to this event to update your application state. + */ + onDismiss: NonCancelableEventHandler; + + /** + * Specifies the maximum number of displayed tokens. If the property isn't set, all of the tokens are displayed. + */ + limit?: number; + /** + * Specifies the direction in which tokens are aligned (`horizontal | vertical`). + */ + alignment?: FileTokenGroupProps.Alignment; + + /** + * + * An array of objects representing token items. Each token has the following properties: + * + * - `file` (string) - File value. + * - `loading` (boolean) - (Optional) Determine whether the token is loading. + * - `errorText` (string) - (Optional) Text that displays as a validation error message. + * - `warningText` (string) - (Optional) - Text that displays as a validation warning message. + */ + items: ReadonlyArray; + /** + * Adds an `aria-label` to the "Show fewer" button. + * Use to assign unique labels when there are multiple file token groups with the same `limitShowFewer` label on one page. + */ + limitShowFewerAriaLabel?: string; + /** + * Adds an `aria-label` to the "Show more" button. + * Use to assign unique labels when there are multiple file token groups with the same `limitShowMore` label on one page. + */ + limitShowMoreAriaLabel?: string; + /** + * Specifies if the control is read-only, which prevents the + * user from modifying the value. A read-only control is still focusable. + */ + readOnly?: boolean; + /** + * An object containing all the localized strings required by the component: + * * `removeFileAriaLabel` (function): A function to render the ARIA label for file token remove button. + * * `errorIconAriaLabel` (string): The ARIA label to be shown on the error file icon. + * * `warningIconAriaLabel` (string): The ARIA label to be shown on the warning file icon. + * * `formatFileSize` (function): (Optional) A function that takes file size in bytes, and produces a formatted string. + * * `formatFileLastModified` (function): (Optional) A function that takes the files last modified date, and produces a formatted string. + */ + i18nStrings: FileTokenGroupProps.I18nStrings; +} + +export namespace FileTokenGroupProps { + export interface DismissDetail { + fileIndex: number; + } + + export interface I18nStrings { + limitShowFewer?: string; + limitShowMore?: string; + + removeFileAriaLabel: (fileIndex: number) => string; + errorIconAriaLabel?: string; + warningIconAriaLabel?: string; + formatFileSize?: (sizeInBytes: number) => string; + formatFileLastModified?: (date: Date) => string; + } + + export type Alignment = 'horizontal' | 'vertical'; + + export interface Item { + file: File; + loading?: boolean; + errorText?: null | string; + warningText?: null | string; + } +} diff --git a/src/internal/components/file-token-group/styles.scss b/src/internal/components/file-token-group/styles.scss new file mode 100644 index 0000000000..0992cdef79 --- /dev/null +++ b/src/internal/components/file-token-group/styles.scss @@ -0,0 +1,143 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../../styles/tokens' as awsui; +@use '../../styles' as styles; +@use './constants' as constants; +@use '../../../token-group/mixins.scss' as mixins; +@use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; + +@mixin token-box-validation { + border-inline-start-width: awsui.$border-invalid-width; + + > .dismiss-button { + color: awsui.$color-text-interactive-default; + &:hover { + color: awsui.$color-text-interactive-hover; + } + } +} + +.root { + @include styles.styles-reset(); +} + +.file-loading-overlay { + position: absolute; + inset-inline-end: awsui.$space-static-xs; + inset-block-end: awsui.$space-static-xxs; + + &-single-row { + inset-inline-end: awsui.$space-static-xxl; + } +} + +.file-option-name, +.file-option-size, +.file-option-last-modified { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.file-option { + inline-size: 100%; + min-inline-size: 0; + display: flex; + gap: awsui.$space-scaled-xs; +} + +.file-option-thumbnail { + margin-block-start: awsui.$space-static-xxs; +} + +.file-option-thumbnail-image { + @include styles.font-body-s; + + inline-size: constants.$image-size; + block-size: constants.$image-size; + object-fit: cover; + + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.file-option-metadata { + inline-size: 100%; + + &.with-image { + inline-size: calc(100% - constants.$image-size); + } + + &.single-row-loading { + inline-size: calc(100% - constants.$spinner-size); + } +} + +.token { + position: relative; + block-size: 100%; + display: flex; + flex-direction: column; + gap: awsui.$space-xxs; + + &-grid { + display: grid; + grid-template-rows: max-content auto; + + @include styles.media-breakpoint-down(styles.$breakpoint-x-small) { + display: flex; + } + } + + &-contains-image { + grid-template-rows: constants.$file-token-height auto; + } +} + +.token-box { + @include mixins.token-box-styles(); + + &.horizontal { + max-inline-size: constants.$compact-token-width; + + @include styles.media-breakpoint-down(styles.$breakpoint-x-small) { + max-inline-size: 100%; + } + } + + &.error { + border-color: awsui.$color-border-status-error; + @include token-box-validation; + } + + &.warning { + border-color: awsui.$color-border-status-warning; + @include token-box-validation; + } + + &.read-only { + border-color: awsui.$color-border-input-disabled; + background-color: awsui.$color-background-container-content; + pointer-events: none; + + > .dismiss-button { + color: awsui.$color-text-button-inline-icon-disabled; + + &:hover { + /* stylelint-disable-next-line plugin/no-unsupported-browser-features */ + cursor: initial; + color: awsui.$color-text-button-inline-icon-disabled; + } + } + } + + &.loading { + border-color: awsui.$color-border-control-disabled; + background-color: awsui.$color-background-container-content; + } +} diff --git a/src/internal/components/file-token-group/test-classes/styles.scss b/src/internal/components/file-token-group/test-classes/styles.scss new file mode 100644 index 0000000000..634f0265e6 --- /dev/null +++ b/src/internal/components/file-token-group/test-classes/styles.scss @@ -0,0 +1,12 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ +.root, +.file-option-thumbnail, +.file-option-name, +.file-option-size, +.file-option-last-modified, +.ellipsis-active { + /* used in test-utils */ +} diff --git a/src/file-upload/file-option/thumbnail.tsx b/src/internal/components/file-token-group/thumbnail.tsx similarity index 100% rename from src/file-upload/file-option/thumbnail.tsx rename to src/internal/components/file-token-group/thumbnail.tsx diff --git a/src/internal/components/token-list/index.tsx b/src/internal/components/token-list/index.tsx index 6ace0cfdb0..68a1ba05ff 100644 --- a/src/internal/components/token-list/index.tsx +++ b/src/internal/components/token-list/index.tsx @@ -77,7 +77,14 @@ export default function TokenList({ return (
{hasVisibleItems && ( -
    +
      {visibleItems.map((item, itemIndex) => (
    • { - alignment: 'vertical' | 'horizontal' | 'inline'; + alignment: 'vertical' | 'horizontal' | 'inline' | 'horizontal-grid'; items: readonly Item[]; limit?: number; after?: React.ReactNode; diff --git a/src/internal/components/token-list/styles.scss b/src/internal/components/token-list/styles.scss index 536b6e9baa..b7eb6bd882 100644 --- a/src/internal/components/token-list/styles.scss +++ b/src/internal/components/token-list/styles.scss @@ -5,18 +5,20 @@ @use '../../styles/tokens' as awsui; @use '../../styles' as styles; +@use '../../../internal/components/file-token-group/constants' as constants; @use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; .root { - display: flex; gap: awsui.$space-scaled-xs; &.horizontal { + display: flex; gap: awsui.$space-xs; flex-direction: row; flex-wrap: wrap; } &.vertical { + display: flex; flex-direction: column; } } @@ -41,6 +43,16 @@ &.vertical { flex-direction: column; } + &.grid { + display: grid; + gap: awsui.$space-xs; + grid-template-columns: repeat(auto-fill, constants.$compact-token-width); + + @include styles.media-breakpoint-down(styles.$breakpoint-x-small) { + display: flex; + flex-direction: column; + } + } } .list-item { diff --git a/src/test-utils/dom/file-upload/index.ts b/src/test-utils/dom/file-upload/index.ts index 836d6fd883..a14cd54c9a 100644 --- a/src/test-utils/dom/file-upload/index.ts +++ b/src/test-utils/dom/file-upload/index.ts @@ -3,13 +3,13 @@ import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; import ButtonWrapper from '../button'; +import { FileTokenWrapper } from '../internal/file-token-group'; -import fileUploadOptionSelectors from '../../../file-upload/file-option/styles.selectors.js'; import fileUploadSelectors from '../../../file-upload/styles.selectors.js'; import formFieldStyles from '../../../form-field/styles.selectors.js'; import fileUploadInputSelectors from '../../../internal/components/file-input/styles.selectors.js'; +import tokenGroupSelectors from '../../../internal/components/file-token-group/styles.selectors.js'; import tokenListSelectors from '../../../internal/components/token-list/styles.selectors.js'; -import tokenGroupSelectors from '../../../token-group/styles.selectors.js'; export default class FileUploadWrapper extends ComponentWrapper { static rootSelector: string = fileUploadSelectors.root; @@ -56,33 +56,3 @@ export default class FileUploadWrapper extends ComponentWrapper { return this.find(`.${fileUploadSelectors.hints} .${formFieldStyles.warning} .${formFieldStyles.warning__message}`); } } - -class FileTokenWrapper extends ComponentWrapper { - findFileName(): ElementWrapper { - return this.findByClassName(fileUploadOptionSelectors['file-option-name'])!; - } - - findFileSize(): null | ElementWrapper { - return this.findByClassName(fileUploadOptionSelectors['file-option-size']); - } - - findFileLastModified(): null | ElementWrapper { - return this.findByClassName(fileUploadOptionSelectors['file-option-last-modified']); - } - - findFileThumbnail(): null | ElementWrapper { - return this.findByClassName(fileUploadOptionSelectors['file-option-thumbnail-image']); - } - - findFileError(): null | ElementWrapper { - return this.find(`.${formFieldStyles.error} .${formFieldStyles.error__message}`); - } - - findFileWarning(): null | ElementWrapper { - return this.find(`.${formFieldStyles.warning} .${formFieldStyles.warning__message}`); - } - - findRemoveButton(): ElementWrapper { - return this.findByClassName(tokenGroupSelectors['dismiss-button'])!; - } -} diff --git a/src/test-utils/dom/internal/file-token-group.ts b/src/test-utils/dom/internal/file-token-group.ts new file mode 100644 index 0000000000..33cd7526ac --- /dev/null +++ b/src/test-utils/dom/internal/file-token-group.ts @@ -0,0 +1,59 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ComponentWrapper, ElementWrapper } from '@cloudscape-design/test-utils-core/dom'; + +import formFieldStyles from '../../../form-field/styles.selectors.js'; +import selectors from '../../../internal/components/file-token-group/styles.selectors.js'; +import testSelectors from '../../../internal/components/file-token-group/test-classes/styles.selectors.js'; +import tokenGroupSelectors from '../../../token-group/styles.selectors.js'; + +export default class FileTokenGroupWrapper extends ComponentWrapper { + static rootSelector: string = testSelectors.root; + + findFileTokens(): Array { + return this.findAllByClassName(selectors.token).map( + tokenElement => new FileTokenWrapper(tokenElement.getElement()) + ); + } + + /** + * Returns a file token from for a given index. + * + * @param tokenIndex 1-based index of the file token to return. + */ + findFileToken(fileTokenIndex: number): null | FileTokenWrapper { + return this.findComponent(`.${selectors.token}[data-index="${fileTokenIndex - 1}"]`, FileTokenWrapper); + } +} + +export class FileTokenWrapper extends ComponentWrapper { + static rootSelector: string = selectors.token; + + findFileName(): ElementWrapper { + return this.findByClassName(selectors['file-option-name'])!; + } + + findFileSize(): ElementWrapper { + return this.findByClassName(selectors['file-option-size'])!; + } + + findFileLastModified(): ElementWrapper { + return this.findByClassName(selectors['file-option-last-modified'])!; + } + + findFileThumbnail(): ElementWrapper { + return this.findByClassName(selectors['file-option-thumbnail'])!; + } + + findFileError(): ElementWrapper { + return this.find(`.${formFieldStyles.error} .${formFieldStyles.error__message}`)!; + } + + findFileWarning(): ElementWrapper { + return this.find(`.${formFieldStyles.warning} .${formFieldStyles.warning__message}`)!; + } + + findRemoveButton(): ElementWrapper { + return this.findByClassName(tokenGroupSelectors['dismiss-button'])!; + } +} diff --git a/src/token-group/__tests__/token-group.test.tsx b/src/token-group/__tests__/token-group.test.tsx index cf94372e4a..5f6a5fe21d 100644 --- a/src/token-group/__tests__/token-group.test.tsx +++ b/src/token-group/__tests__/token-group.test.tsx @@ -1,12 +1,11 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import React, { useState } from 'react'; -import { render, screen } from '@testing-library/react'; +import { render } from '@testing-library/react'; import TestI18nProvider from '../../../lib/components/i18n/testing'; import createWrapper, { IconWrapper, TokenGroupWrapper } from '../../../lib/components/test-utils/dom'; import TokenGroup, { TokenGroupProps } from '../../../lib/components/token-group'; -import { Token } from '../../../lib/components/token-group/token'; import { getIconHTML } from '../../icon/__tests__/utils'; import optionSelectors from '../../../lib/components/internal/components/option/styles.selectors.js'; @@ -315,20 +314,6 @@ describe('TokenGroup', () => { }); }); -describe('Token', () => { - test('Renders token error and associates it with the token', () => { - const { container } = render( - - Content - - ); - const tokenElement = createWrapper(container).findByClassName(selectors.token)!.getElement(); - expect(screen.getByLabelText('Error icon')).toBeDefined(); - expect(screen.getByText('Error text')).toBeDefined(); - expect(tokenElement).toHaveAccessibleDescription('Error text'); - }); -}); - describe('i18n', () => { test('supports rendering limitShowFewer and limitShowMore using i18n provider', () => { const { container } = render( diff --git a/src/token-group/constants.scss b/src/token-group/constants.scss index 9785c3eaab..6ed92a762d 100644 --- a/src/token-group/constants.scss +++ b/src/token-group/constants.scss @@ -7,5 +7,4 @@ @use '../internal/styles/tokens' as awsui; $token-background: awsui.$color-background-item-selected; - $token-border-color: awsui.$color-border-item-selected; diff --git a/src/token-group/mixins.scss b/src/token-group/mixins.scss new file mode 100644 index 0000000000..8c08f0bf09 --- /dev/null +++ b/src/token-group/mixins.scss @@ -0,0 +1,27 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ +@use '../internal/styles' as styles; +@use '../internal/styles/tokens' as awsui; +@use './constants' as constants; + +@mixin token-box-styles { + position: relative; + block-size: 100%; + border-block: awsui.$border-field-width solid constants.$token-border-color; + border-inline: awsui.$border-field-width solid constants.$token-border-color; + padding-block-start: styles.$control-padding-vertical; + padding-block-end: styles.$control-padding-vertical; + padding-inline-start: styles.$control-padding-horizontal; + padding-inline-end: awsui.$space-xxs; + display: flex; + align-items: flex-start; + background: constants.$token-background; + border-start-start-radius: awsui.$border-radius-token; + border-start-end-radius: awsui.$border-radius-token; + border-end-start-radius: awsui.$border-radius-token; + border-end-end-radius: awsui.$border-radius-token; + color: awsui.$color-text-body-default; + box-sizing: border-box; +} diff --git a/src/token-group/styles.scss b/src/token-group/styles.scss index b088c34313..b8c85efd43 100644 --- a/src/token-group/styles.scss +++ b/src/token-group/styles.scss @@ -7,6 +7,7 @@ @use '../internal/styles/tokens' as awsui; @use '@cloudscape-design/component-toolkit/internal/focus-visible' as focus-visible; @use './constants' as constants; +@use './mixins.scss' as mixins; .root { @include styles.styles-reset; @@ -51,41 +52,9 @@ } .token-box { - block-size: 100%; - border-block: awsui.$border-field-width solid constants.$token-border-color; - border-inline: awsui.$border-field-width solid constants.$token-border-color; - padding-block-start: styles.$control-padding-vertical; - padding-block-end: styles.$control-padding-vertical; - padding-inline-start: styles.$control-padding-horizontal; - padding-inline-end: awsui.$space-xxs; - display: flex; - align-items: flex-start; - background: constants.$token-background; - border-start-start-radius: awsui.$border-radius-token; - border-start-end-radius: awsui.$border-radius-token; - border-end-start-radius: awsui.$border-radius-token; - border-end-end-radius: awsui.$border-radius-token; - color: awsui.$color-text-body-default; - box-sizing: border-box; + @include mixins.token-box-styles(); } -@mixin token-box-validation { - border-inline-start-width: awsui.$border-invalid-width; - > .dismiss-button { - color: awsui.$color-text-interactive-default; - &:hover { - color: awsui.$color-text-interactive-hover; - } - } -} -.token-box-error { - border-color: awsui.$color-border-status-error; - @include token-box-validation; -} -.token-box-warning { - border-color: awsui.$color-border-status-warning; - @include token-box-validation; -} .token-box-readonly { border-color: awsui.$color-border-input-disabled; background-color: awsui.$color-background-container-content; diff --git a/src/token-group/token.tsx b/src/token-group/token.tsx index b8f12c22f9..6d741da18f 100644 --- a/src/token-group/token.tsx +++ b/src/token-group/token.tsx @@ -4,9 +4,7 @@ import React from 'react'; import clsx from 'clsx'; -import { FormFieldError, FormFieldWarning } from '../form-field/internal'; import { getBaseProps } from '../internal/base-component'; -import { useUniqueId } from '../internal/hooks/use-unique-id'; import DismissButton from './dismiss-button'; import styles from './styles.css.js'; @@ -18,48 +16,25 @@ interface TokenProps { onDismiss?: () => void; disabled?: boolean; readOnly?: boolean; - errorText?: React.ReactNode; - errorIconAriaLabel?: string; - warningText?: React.ReactNode; - warningIconAriaLabel?: string; className?: string; } -export function Token({ - ariaLabel, - disabled, - readOnly, - dismissLabel, - onDismiss, - children, - errorText, - warningText, - errorIconAriaLabel, - warningIconAriaLabel, - ...restProps -}: TokenProps) { - const errorId = useUniqueId('error'); - const warningId = useUniqueId('warning'); +export function Token({ ariaLabel, disabled, readOnly, dismissLabel, onDismiss, children, ...restProps }: TokenProps) { const baseProps = getBaseProps(restProps); - const showWarning = warningText && !errorText; - return (
      {children} @@ -67,16 +42,6 @@ export function Token({ )}
      - {errorText && ( - - {errorText} - - )} - {showWarning && ( - - {warningText} - - )}
      ); }