diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload.md b/packages/dnb-design-system-portal/src/docs/uilib/components/upload.md new file mode 100644 index 00000000000..6ed3f5f338f --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload.md @@ -0,0 +1,14 @@ +--- +title: 'Upload' +description: 'The Upload widget should be used in scenarios where the user has to upload files. Files can be uploaded by clicking button. You also have the opportunity to add descriptive texts below the title where you could put max file size, allowed fileformats etc.' +status: 'new' +showTabs: true +hideTabs: + - title: Events +--- + +import UploadInfo from 'Docs/uilib/components/upload/info' +import UploadDemos from 'Docs/uilib/components/upload/demos' + + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/Examples.tsx b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/Examples.tsx new file mode 100644 index 00000000000..8dd6fc25134 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/Examples.tsx @@ -0,0 +1,230 @@ +/** + * UI lib Component Example + * + */ + +import React from 'react' +import ComponentBox from 'dnb-design-system-portal/src/shared/tags/ComponentBox' + +const createMockFile = (name: string, size: number, type: string) => { + const file = new File([], name, { type }) + Object.defineProperty(file, 'size', { + get() { + return size + }, + }) + return file +} + +const useMockFiles = (setFiles, extend) => { + React.useEffect(() => { + setFiles([ + { + file: createMockFile('fileName.png', 123, 'image/png'), + ...extend, + }, + ]) + }, []) +} + +export const UploadPrefilledFileList = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files, setFiles} = Upload.useUpload('file-list') + useMockFiles(setFiles, { errorMessage: 'This is no real file!' }) + + return ( + + ) +} +render() + ` + } + +) + +export const UploadBasic = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files} = Upload.useUpload('upload-basic') + + return ( + + ) +} +render() + ` + } + +) + +export const UploadMultipleFiles = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files, setFiles} = Upload.useUpload('upload-multiple-files') + return ( + + ) +} +render() + ` + } + +) +export const UploadRemoveFile = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files, setFiles} = Upload.useUpload('upload-remove-files') + return ( + <> + + + + ) +} +render() + ` + } + +) + +export const UploadIsLoading = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files, setFiles} = Upload.useUpload('upload-is-loading') + useMockFiles(setFiles, { isLoading: true }) + + return ( + <> + + { + setFiles(files.map((file) => { + return {...file, isLoading: checked} + })) + }} + >Files is loading toggle + + ) +} +render() + ` + } + +) + +export const UploadErrorMessage = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files, setFiles} = Upload.useUpload('upload-error-message') + return ( + <> + + { + setFiles( + files.map((file) => { + return {...file, errorMessage: checked? 'custom error message': null} + }) + ) + } + } + > + Toggle error message + + + ) +} +render() + ` + } + +) + +export const UploadAcceptedFormats = () => ( + + { + /* jsx */ ` +const Component = () => { + const {files, setFiles} = Upload.useUpload('upload-accepted-formats') + + return ( + + ) +} +render() + ` + } + +) + +export const UploadCustomText = () => ( + + { + /* jsx */ ` + + ` + } + +) diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/demos.md b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/demos.md new file mode 100644 index 00000000000..96d5567e7dc --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/demos.md @@ -0,0 +1,58 @@ +--- +showTabs: true +--- + +import { +UploadBasic, +UploadPrefilledFileList, +UploadRemoveFile, +UploadMultipleFiles, +UploadIsLoading, +UploadErrorMessage, +UploadAcceptedFormats, +UploadCustomText, +} from 'Docs/uilib/components/upload/Examples' + +## Demos + +### Upload (default) + + + +### 'useUpload' React Hook + +By using the `Upload.useUpload` you can remove or add files or the status displayed in the component. + + + +### Upload multiple files + + + +### Upload loading state + +When uploading the file you can set the loading state of the request using the `Upload.useUpload` hook and passing isLoading to the file that is being uploaded. + + + +### Upload error message + +The only checks we do currently is for the file size and the file type. These errors are handled by the HTML element ´input´ so they aren't selectable. If you want any other error messages you can use the `Upload.useUpload` the same way as with the loading state. + + + +### Upload specific accepted file formats + +You can pass the file formats as a string array. This will restrict which files that can be selected. + + + +### Upload custom text + +All the text can be custom. + + + +### Upload with prefilled error + + diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/info.md b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/info.md new file mode 100644 index 00000000000..a11fa3d49d5 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/info.md @@ -0,0 +1,7 @@ +--- +showTabs: true +--- + +## Description + +The Upload should be used in scenarios where the user has to upload any kind of files. diff --git a/packages/dnb-design-system-portal/src/docs/uilib/components/upload/properties.md b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/properties.md new file mode 100644 index 00000000000..39887de7e18 --- /dev/null +++ b/packages/dnb-design-system-portal/src/docs/uilib/components/upload/properties.md @@ -0,0 +1,22 @@ +--- +showTabs: true +--- + +## Properties + +| Properties | Description | +| ------------------------------------------- | ----------------------------------------------------------------------------------- | +| `acceptedFileTypes` | _(required)_ List of accepted file types. | +| `fileMaxSize` | _(optional)_ fileMaxSize is max size of each file in MB | +| `multipleFiles` | _(optional)_ if set true, accepting multiple files is allowed | +| `title` | _(optional)_ Custom text property. Replaces the default title | +| `text` | _(optional)_ Custom text property. Replaces the default text | +| `formatsDescription` | _(optional)_ Custom text property. Replaces the default accepted format description | +| `fileSizeDescription` | _(optional)_ Custom text property. Replaces the default max file size description | +| `fileSizeContent` | _(optional)_ Custom text property. Replaces the default file size content | +| `uploadButtonText` | _(optional)_ Custom text property. Replaces the default upload button text | +| `uploadLoadingText` | _(optional)_ Custom text property. Replaces the default loading text | +| `deleteButton` | _(optional)_ Custom text property. Replaces the default delete button text | +| `fileListAriaLabel` | _(optional)_ Custom text property. Replaces the default list aria label | +| `skeleton` | _(optional)_ Skeleton should be applied when loading content Default: null. | +| [Space](/uilib/components/space/properties) | _(optional)_ Spacing properties like `top` or `bottom` are supported. | diff --git a/packages/dnb-eufemia/src/components/Upload.js b/packages/dnb-eufemia/src/components/Upload.js new file mode 100644 index 00000000000..90d57a9f132 --- /dev/null +++ b/packages/dnb-eufemia/src/components/Upload.js @@ -0,0 +1,14 @@ +/** + * ATTENTION: This file is auto generated by using "prepareTemplates". + * Do not change the content! + * + */ + +/** + * Library Index upload to autogenerate all the components and extensions + * Used by "prepareUploads" + */ + +import Upload from './upload/Upload' +export * from './upload/Upload' +export default Upload diff --git a/packages/dnb-eufemia/src/components/index.js b/packages/dnb-eufemia/src/components/index.js index 992019406e8..83e8722eddd 100644 --- a/packages/dnb-eufemia/src/components/index.js +++ b/packages/dnb-eufemia/src/components/index.js @@ -54,6 +54,7 @@ import Textarea from './textarea/Textarea' import Timeline from './timeline/Timeline' import ToggleButton from './toggle-button/ToggleButton' import Tooltip from './tooltip/Tooltip' +import Upload from './upload/Upload' import VisuallyHidden from './visually-hidden/VisuallyHidden' // define / export all the available components @@ -102,5 +103,6 @@ export { Timeline, ToggleButton, Tooltip, + Upload, VisuallyHidden, } diff --git a/packages/dnb-eufemia/src/components/lib.js b/packages/dnb-eufemia/src/components/lib.js index a6af619a972..a075f9a7b0a 100644 --- a/packages/dnb-eufemia/src/components/lib.js +++ b/packages/dnb-eufemia/src/components/lib.js @@ -56,6 +56,7 @@ import Textarea from './textarea/Textarea' import Timeline from './timeline/Timeline' import ToggleButton from './toggle-button/ToggleButton' import Tooltip from './tooltip/Tooltip' +import Upload from './upload/Upload' import VisuallyHidden from './visually-hidden/VisuallyHidden' // define / export all the available components @@ -104,6 +105,7 @@ export { Timeline, ToggleButton, Tooltip, + Upload, VisuallyHidden, } @@ -153,6 +155,7 @@ export const getComponents = () => { Timeline, ToggleButton, Tooltip, + Upload, VisuallyHidden, } } diff --git a/packages/dnb-eufemia/src/components/upload/Upload.tsx b/packages/dnb-eufemia/src/components/upload/Upload.tsx new file mode 100644 index 00000000000..909b3e35d1b --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/Upload.tsx @@ -0,0 +1,193 @@ +import React from 'react' +import classnames from 'classnames' + +// Components +import Lead from '../../elements/Lead' +import P from '../../elements/P' +import Dl from '../../elements/Dl' +import Dt from '../../elements/Dt' +import Dd from '../../elements/Dd' +import HeightAnimation from '../height-animation/HeightAnimation' + +// Shared +import { createSpacingClasses } from '../space/SpacingHelper' +import Provider from '../../shared/Provider' +import Context from '../../shared/Context' +import { extendPropsWithContext } from '../../shared/component-helper' +import { format } from '../number-format/NumberUtils' +import { LocaleProps, SpacingProps } from 'src/shared/types' + +// Internal +import UploadFileInput from './UploadFileInput' +import type { UploadFile, UploadProps } from './types' +import UploadFileListCell from './UploadFileListCell' +import useUpload from './useUpload' + +export const defaultProps = { + fileMaxSize: 5000, +} + +const Upload = (localProps: UploadProps & SpacingProps & LocaleProps) => { + const context = React.useContext(Context) + + const extendedProps = extendPropsWithContext( + localProps, + defaultProps, + { skeleton: context?.skeleton }, + context.getTranslation(localProps).Upload, + context.Upload + ) + + const { + id, + skeleton, + className, + acceptedFileTypes, + multipleFiles, + fileMaxSize, + title, + text, + formatsDescription, + fileSizeDescription, + fileSizeContent, + uploadButtonText, + uploadLoadingText, + uploadErrorLargeFile, + deleteButton, + fileListAriaLabel, + ...props + } = extendedProps + + const spacingClasses = createSpacingClasses(props) + + const { files, setFiles } = useUpload(id) + + const prettyfiedAcceptedFileFormats = acceptedFileTypes + .join(', ') + .toUpperCase() + + return ( + + + + {title} + + +

+ {text} +

+ +
+ +
+ {formatsDescription} +
+
+ {prettyfiedAcceptedFileFormats} +
+
+ + +
+ {fileSizeDescription} +
+
+ {String(fileSizeContent).replace( + '%size', + format(fileMaxSize).toString() + )} +
+
+
+ + + + + + + + +
+
+ ) + + function UploadFileList() { + if (files == null || files.length < 1) return null + + return ( +
    + {files.map((uploadFile: UploadFile, index: number) => { + const onDeleteFile = () => { + const cleanedFiles = files.filter( + (fileListElement) => fileListElement.file != uploadFile.file + ) + setFiles(cleanedFiles) + } + return ( + + ) + })} +
+ ) + } + + function onInputUpload(addedFiles: UploadFile[]) { + const newFiles = [...files, ...addedFiles] + + setFiles(newFiles) + } +} + +Upload.useUpload = useUpload + +export default Upload diff --git a/packages/dnb-eufemia/src/components/upload/UploadFileInput.tsx b/packages/dnb-eufemia/src/components/upload/UploadFileInput.tsx new file mode 100644 index 00000000000..80e5b532d30 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/UploadFileInput.tsx @@ -0,0 +1,104 @@ +import React, { useEffect, useRef } from 'react' + +// Components +import Button from '../button/Button' + +// Icons +import { folder as FolderIcon } from '../../icons' + +// Shared +import { format } from '../number-format/NumberUtils' +import { makeUniqueId } from '../../shared/component-helper' + +// Internal +import { UploadFile } from './types' + +export type UploadFileInputProps = { + id?: string + acceptedFormats: string[] + onUpload: (files: UploadFile[]) => void + fileMaxSize: number + uploadFileButtonText: React.ReactNode + uploadErrorLargeFile: React.ReactNode + multipleFiles: boolean +} + +const BYTES_IN_A_MEGA_BYTE = 1048576 + +const UploadFileInput = ({ + id, + acceptedFormats, + uploadFileButtonText, + onUpload, + fileMaxSize, + uploadErrorLargeFile, + multipleFiles = false, +}: UploadFileInputProps) => { + const fileInput = useRef(null) + + const accept = acceptedFormats.reduce((accept, format, index) => { + const previus = index === 0 ? '' : `${accept},` + return `${previus} .${format}` + }, '') + + useEffect(() => { + fileInput.current.value = null + fileInput.current.accept = accept + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const openFileDialog = () => fileInput.current?.click() + + const sharedId = id || makeUniqueId() + + return ( +
+ + + +
+ ) + + function handleFileInput({ target: { files } }) { + const uploadFile = [...Array(files.length)].map((_item, index) => { + const file: UploadFile = { file: files[index] } + const errorMessage = getErrorMessage(file.file.size) + + if (errorMessage) return { ...file, errorMessage } + return file + }) + onUpload(uploadFile) + } + + function getErrorMessage(fileSize: number) { + const errorMessage = String(uploadErrorLargeFile).replace( + '%size', + format(fileMaxSize).toString() + ) + // Converts from b (binary) to MB (decimal) + return fileSize / BYTES_IN_A_MEGA_BYTE > fileMaxSize + ? errorMessage + : null + } +} + +export default UploadFileInput diff --git a/packages/dnb-eufemia/src/components/upload/UploadFileListCell.tsx b/packages/dnb-eufemia/src/components/upload/UploadFileListCell.tsx new file mode 100644 index 00000000000..06dae5a4240 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/UploadFileListCell.tsx @@ -0,0 +1,186 @@ +import React, { useRef } from 'react' +import classnames from 'classnames' + +// Components +import Button from '../button/Button' +import Icon from '../../components/Icon' +import FormStatus from '../../components/FormStatus' +import ProgressIndicator from '../../components/progress-indicator' +import P from '../../../src/elements/P' + +// Icons +import { + trash as TrashIcon, + exclamation_medium as ExclamationIcon, + file_pdf_medium as pdf, + file_xls_medium as xls, + file_ppt_medium as ppt, + file_csv_medium as csv, + file_txt_medium as txt, + file_xml_medium as xml, + file_medium as file, +} from '../../icons' +import { UploadFile } from './types' + +// Shared +import { getPreviousSibling, warn } from '../../shared/component-helper' + +const images = { + pdf, + xls, + ppt, + csv, + txt, + xml, + file, +} + +export type UploadFileListCellProps = { + /** + * Uploaded file + */ + uploadFile: UploadFile + /** + * Calls onDelete when clicking the delete button + */ + onDelete: () => void + /** + * Text + */ + uploadLoadingText: React.ReactNode + deleteButtonText: React.ReactNode +} + +const UploadFileListCell = ({ + uploadFile, + onDelete, + uploadLoadingText, + deleteButtonText, +}: UploadFileListCellProps) => { + const { file, errorMessage, isLoading } = uploadFile + const { name, type } = file + + const fileType = type.split('/')[1] + + const hasWarning = errorMessage != null + + const imageUrl = URL.createObjectURL(file) + + const cellRef = useRef() + + const handleDisappearFocus = () => { + try { + const cellElement = cellRef.current + const focusElement = getPreviousSibling( + '.dnb-upload', + cellElement + ).querySelector( + '.dnb-upload__file-input-button' + ) as HTMLButtonElement + focusElement.focus() + } catch (e) { + warn(e) + } + } + + const onDeleteHandler = () => { + handleDisappearFocus() + + onDelete() + } + + return ( +
  • +
    +
    + {getIcon()} + {getTitle()} +
    +
    + +
    +
    + + {getWarning()} +
  • + ) + + function getIcon() { + if (isLoading) { + return + } + + if (hasWarning) return + + let iconFileType = fileType + + if (!Object.prototype.hasOwnProperty.call(images, fileType)) { + iconFileType = 'file' + } + return + } + + function getTitle() { + return isLoading ? ( +
    + {uploadLoadingText} +
    + ) : ( +
    + + {name} + +

    + {fileType.toUpperCase()} +

    +
    + ) + } + + function getWarning() { + return hasWarning ? ( + + ) : null + } +} + +export default UploadFileListCell diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/Upload.screenshot.test.js b/packages/dnb-eufemia/src/components/upload/__tests__/Upload.screenshot.test.js new file mode 100644 index 00000000000..a86e61d7d82 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/Upload.screenshot.test.js @@ -0,0 +1,34 @@ +/** + * Screenshot Test + * This file will not run on "test:staged" because we don't require any related files + */ + +import { + testPageScreenshot, + setupPageScreenshot, +} from '../../../core/jest/jestSetupScreenshots' + +describe('Upload screenshot', () => { + setupPageScreenshot({ url: '/uilib/components/upload/demos' }) + + it('have to match the default', async () => { + const screenshot = await testPageScreenshot({ + selector: '[data-visual-test="upload-basic"]', + }) + expect(screenshot).toMatchImageSnapshot() + }) + + it('have to match the loading state', async () => { + const screenshot = await testPageScreenshot({ + selector: '[data-visual-test="upload-is-loading"] .dnb-upload', + }) + expect(screenshot).toMatchImageSnapshot() + }) + + it('have to match file list', async () => { + const screenshot = await testPageScreenshot({ + selector: '[data-visual-test="upload-file-list"]', + }) + expect(screenshot).toMatchImageSnapshot() + }) +}) diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/Upload.test.tsx b/packages/dnb-eufemia/src/components/upload/__tests__/Upload.test.tsx new file mode 100644 index 00000000000..60ec704e2d5 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/Upload.test.tsx @@ -0,0 +1,379 @@ +import React, { useEffect } from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import Upload from '../Upload' +import nbNO from '../../../shared/locales/nb-NO' +import createMockFile from './testHelpers' +import { loadScss, axeComponent } from '../../../core/jest/jestSetup' +import { UploadProps } from '../types' +import useUpload from '../useUpload' + +const nb = nbNO['nb-NO'].Upload + +global.URL.createObjectURL = jest.fn(() => 'url') + +const defaultProps: UploadProps = { + id: 'id', + acceptedFileTypes: ['png'], +} + +describe('Upload', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('renders the component', () => { + render() + + expect(screen.queryByTestId('upload')).not.toBeNull() + }) + + it('renders the upload file input section', () => { + render() + + expect(screen.queryByTestId('upload-file-input')).not.toBeNull() + }) + + describe('Text', () => { + it('renders the title', () => { + render() + + const element = screen.queryByTestId('upload-title') + + expect(element).not.toBeNull() + + expect(element.textContent).toMatch(nb.title) + }) + + it('renders the custom title', () => { + const customTitle = 'custom title' + + render() + + const element = screen.queryByTestId('upload-title') + + expect(element.textContent).toMatch(customTitle) + }) + + it('renders the text', () => { + render() + + const element = screen.queryByTestId('upload-text') + + expect(element).not.toBeNull() + + expect(element.textContent).toMatch(nb.text) + }) + + it('renders the custom text', () => { + const customText = 'custom text' + + render() + + const element = screen.queryByTestId('upload-text') + + expect(element.textContent).toMatch(customText) + }) + + it('renders the format description', () => { + render() + + const element = screen.queryByTestId( + 'upload-accepted-formats-description' + ) + + expect(element).not.toBeNull() + + expect(element.textContent).toMatch(nb.formatsDescription) + }) + + it('renders the custom format description', () => { + const customFormatDescription = 'custom formats description' + + render( + + ) + + const element = screen.queryByTestId( + 'upload-accepted-formats-description' + ) + + expect(element.textContent).toMatch(customFormatDescription) + }) + + it('renders the custom accepted format', () => { + const acceptedFileTypes = ['png, jpg'] + + render( + + ) + + const element = screen.queryByTestId('upload-accepted-formats') + + const formattedFileTypes = acceptedFileTypes.join(', ').toUpperCase() + + expect(element).not.toBeNull() + expect(element.textContent).toMatch(formattedFileTypes) + }) + + it('renders the file size description', () => { + render() + + const element = screen.queryByTestId('upload-file-size-description') + + expect(element).not.toBeNull() + }) + + it('renders the custom file size description', () => { + const fileSizeDescription = 'file size description' + + render( + + ) + + const element = screen.queryByTestId('upload-file-size-description') + + expect(element.textContent).toMatch(fileSizeDescription) + }) + + it('renders the file size', () => { + const fileMaxSize = 2 + render() + + const element = screen.queryByTestId('upload-file-size') + + expect(element).not.toBeNull() + expect(element.textContent).toMatch( + String(nb.fileSizeContent).replace('%size', fileMaxSize.toString()) + ) + }) + + it('renders the custom file size', () => { + const fileMaxSize = 2 + const fileSizeContent = '%size custom' + + render( + + ) + + const element = screen.queryByTestId('upload-file-size') + + expect(element.textContent).toMatch( + String(fileMaxSize).replace('%size', `${fileMaxSize}`) + ) + }) + + it('renders the upload file input section button text', () => { + render() + + const element = screen.queryByTestId('upload-file-input-button') + + expect(element.textContent).toMatch(nb.uploadButtonText) + }) + + it('renders the upload file input section button custom text', () => { + const uploadButtonText = 'upload button text' + + render( + + ) + + const element = screen.queryByTestId('upload-file-input-button') + + expect(element.textContent).toMatch(uploadButtonText) + }) + }) + + describe('useUpload', () => { + it('calls uses the useUpload hook to store files', async () => { + const validationFunction = jest.fn() + + const file = createMockFile('fileName.png', 100, 'image/png') + + const id = 'random-id' + + const expectedResult = [{ file }] + + render() + + const inputElement = screen.queryByTestId('upload-file-input-input') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file] }, + }) + ) + + const MockComponent = () => { + const { files } = useUpload(id) + useEffect(() => validationFunction(files), []) + + return
    + } + + render() + + expect(validationFunction).toHaveBeenCalledWith(expectedResult) + }) + + it('renders the list of files', async () => { + const files = [ + { file: createMockFile('fileName.png', 100, 'image/png') }, + { file: createMockFile('fileName2.png', 100, 'image/png') }, + { file: createMockFile('fileName3.png', 100, 'image/png') }, + ] + + const id = 'random-id' + + render() + + const MockComponent = () => { + const { setFiles } = useUpload(id) + + useEffect(() => setFiles(files), []) + + return
    + } + + render() + + const fileCells = screen.queryAllByTestId('upload-file-list-cell') + + expect(fileCells.length).toBe(files.length) + }) + + it('shows no files', async () => { + const files = [] + + const id = 'random-id3' + + const { queryByTestId } = render( + + ) + + const MockComponent = () => { + const { setFiles } = useUpload(id) + + useEffect(() => setFiles(files), []) + + return
    + } + render() + + const emptyFileCell = queryByTestId('upload-file-list-cell') + + expect(emptyFileCell).toBeNull() + }) + + it('shows the file when the file is added', async () => { + const files = [ + { file: createMockFile('fileName.png', 100, 'image/png') }, + ] + + const id = 'random-id2' + + render() + + const MockComponent = () => { + const { setFiles } = useUpload(id) + + useEffect(() => setFiles(files), []) + + return
    + } + + const emptyFileCell = screen.queryByTestId('upload-file-list-cell') + + expect(emptyFileCell).toBeNull() + + render() + + const fileCell = screen.queryByTestId('upload-file-list-cell') + + expect(fileCell).not.toBeNull() + }) + + it('removes the file from the list when clicking delete', () => { + const files = [ + { file: createMockFile('fileName.png', 100, 'image/png') }, + ] + + const id = 'random-id3' + + const { queryByTestId } = render( + + ) + const MockComponent = () => { + const { setFiles } = useUpload(id) + + useEffect(() => setFiles(files), []) + + return
    + } + + render() + + const fileCell = queryByTestId('upload-file-list-cell') + + expect(fileCell).not.toBeNull() + + const deleteButton = queryByTestId('upload-delete-button') + + fireEvent.click(deleteButton) + + expect(queryByTestId('upload-file-list-cell')).toBeNull() + }) + + it('sets focus on choose button when clicking delete', () => { + const files = [ + { file: createMockFile('fileName.png', 100, 'image/png') }, + ] + + const id = 'random-id3' + + const { queryByTestId } = render( + + ) + const MockComponent = () => { + const { setFiles } = useUpload(id) + + useEffect(() => setFiles(files), []) + + return
    + } + + render() + + const deleteButton = queryByTestId('upload-delete-button') + + fireEvent.click(deleteButton) + + expect(document.activeElement).toBe( + queryByTestId('upload-file-input-button') + ) + }) + }) +}) + +describe('Upload aria', () => { + it('should validate', async () => { + const Component = render() + expect(await axeComponent(Component)).toHaveNoViolations() + }) +}) + +describe('Upload scss', () => { + it('have to match snapshot', () => { + const scss = loadScss(require.resolve('../style/dnb-upload.scss')) + expect(scss).toMatchSnapshot() + }) +}) diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileInput.test.tsx b/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileInput.test.tsx new file mode 100644 index 00000000000..a23267093ea --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileInput.test.tsx @@ -0,0 +1,138 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import UploadFileInput, { UploadFileInputProps } from '../UploadFileInput' +import createMockFile from './testHelpers' + +const defaultProps: UploadFileInputProps = { + acceptedFormats: ['png'], + onUpload: jest.fn(), + uploadFileButtonText: 'upload button text', + fileMaxSize: 1000, + uploadErrorLargeFile: 'error message', + multipleFiles: false, +} + +describe('UploadFileInput', () => { + it('renders the component', () => { + render() + + expect(screen.queryByTestId('upload-file-input')).not.toBeNull() + }) + + it('renders the upload button', () => { + render() + expect(screen.queryByTestId('upload-file-input-button')).not.toBeNull() + }) + + it('renders the upload button text', () => { + const buttonText = 'button text' + render( + + ) + expect( + screen.queryByTestId('upload-file-input-button').textContent + ).toMatch(buttonText) + }) + + it('accepts multiple files when multipleFiles is true', () => { + render() + + const element = screen.queryByTestId('upload-file-input-input') + + expect(element.hasAttribute('multiple')).toBeTruthy() + }) + + it('renders the input', () => { + render() + const element = screen.queryByTestId('upload-file-input-input') + + expect(element).not.toBeNull() + expect(element.getAttribute('class')).toMatch('dnb-upload__file-input') + }) + + it('simulates a click on the input when clicking the button', () => { + render() + + const buttonElement = screen.queryByTestId('upload-file-input-button') + + const inputElement = screen.queryByTestId('upload-file-input-input') + + const clickEventListener = jest.fn() + inputElement.addEventListener('click', clickEventListener) + + fireEvent.click(buttonElement) + + expect(clickEventListener).toHaveBeenCalled() + }) + + it('calls the onUpload function', async () => { + const file = createMockFile('fileName.png', 100, 'image/png') + + const onUpload = jest.fn() + + render() + + const inputElement = screen.queryByTestId('upload-file-input-input') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file] }, + }) + ) + expect(onUpload).toHaveBeenCalledWith([{ file }]) + }) + + it('can upload multiple files', async () => { + const file1 = createMockFile('fileName1.png', 100, 'image/png') + const file2 = createMockFile('fileName2.png', 100, 'image/png') + + const onUpload = jest.fn() + + render() + + const inputElement = screen.queryByTestId('upload-file-input-input') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file1, file2] }, + }) + ) + expect(onUpload).toHaveBeenCalledWith([ + { file: file1 }, + { file: file2 }, + ]) + }) + + it('returns the file size error message', async () => { + const file1 = createMockFile('fileName1.png', 100000000, 'image/png') + + const fileMaxSize = 1 + const errorMessage = 'error message %size' + const errorMessageFormatted = `error message ${fileMaxSize}` + + const onUpload = jest.fn() + + render( + + ) + + const inputElement = screen.queryByTestId('upload-file-input-input') + + await waitFor(() => + fireEvent.change(inputElement, { + target: { files: [file1] }, + }) + ) + expect(onUpload).toHaveBeenCalledWith([ + { file: file1, errorMessage: errorMessageFormatted }, + ]) + }) +}) diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileListCell.test.tsx b/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileListCell.test.tsx new file mode 100644 index 00000000000..fe7ae0b7c77 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/UploadFileListCell.test.tsx @@ -0,0 +1,335 @@ +import UploadFileListCell, { + UploadFileListCellProps, +} from '../UploadFileListCell' +import createMockFile from './testHelpers' +import { render, screen } from '@testing-library/react' +import React from 'react' + +global.URL.createObjectURL = jest.fn(() => 'url') + +const defaultProps: UploadFileListCellProps = { + deleteButtonText: 'delete', + onDelete: jest.fn(), + uploadFile: { file: createMockFile('file.png', 100, 'image/png') }, + uploadLoadingText: 'loading', +} + +describe('UploadFileListCell', () => { + it('renders the component', () => { + render() + + const element = screen.queryByTestId('upload-file-list-cell') + + expect(element).not.toBeNull() + }) + + it('renders the error styling', () => { + render( + + ) + + const element = screen.queryByTestId('upload-file-list-cell') + + expect(element.className).toMatch('dnb-upload__file-cell--warning') + }) + + it('renders the no error styling', () => { + render( + + ) + + const element = screen.queryByTestId('upload-file-list-cell') + + expect(element.className).not.toMatch('dnb-upload__file-cell--error') + expect(element.className).toMatch('dnb-upload__file-cell') + }) + + it('renders the subtitle', () => { + render( + + ) + + const element = screen.queryByTestId('upload-subtitle') + + expect(element).not.toBeNull() + expect(element.textContent).toMatch('PNG') + }) + + it('renders the form errorMessage warning', () => { + render( + + ) + + const element = screen.queryByTestId('upload-warning') + + expect(element).not.toBeNull() + }) + + it('renders the form errorMessage warning message', () => { + const errorMessage = 'error message' + + render( + + ) + + const element = screen.queryByTestId('upload-warning') + + expect(element.textContent).toMatch(errorMessage) + }) + + describe('Icons', () => { + it('renders the exclamation icon', () => { + render( + + ) + + expect( + screen.queryByTestId('exclamation medium icon') + ).not.toBeNull() + }) + + it('renders the pdf icon', () => { + render( + + ) + + expect(screen.queryByTestId('file pdf medium icon')).not.toBeNull() + }) + + it('renders the xls icon', () => { + render( + + ) + + expect(screen.queryByTestId('file xls medium icon')).not.toBeNull() + }) + + it('renders the ppt icon', () => { + render( + + ) + + expect(screen.queryByTestId('file ppt medium icon')).not.toBeNull() + }) + + it('renders the csv icon', () => { + render( + + ) + + expect(screen.queryByTestId('file csv medium icon')).not.toBeNull() + }) + + it('renders the txt icon', () => { + render( + + ) + + expect(screen.queryByTestId('file txt medium icon')).not.toBeNull() + }) + + it('renders the xml icon', () => { + render( + + ) + + expect(screen.queryByTestId('file xml medium icon')).not.toBeNull() + }) + + it('renders the file icon as default', () => { + render( + + ) + + expect(screen.queryByTestId('file medium icon')).not.toBeNull() + }) + }) + + describe('File Anchor', () => { + it('renders the anchor', () => { + render() + const anchorElement = screen.queryByTestId('upload-file-anchor') + + expect(anchorElement).not.toBeNull() + }) + + it('renders the anchor text', () => { + const fileName = 'file.png' + + render( + + ) + const anchorElement = screen.queryByTestId('upload-file-anchor') + expect(anchorElement.textContent).toMatch(fileName) + }) + + it('renders the anchor href', () => { + const mockUrl = 'mock-url' + + global.URL.createObjectURL = jest.fn().mockReturnValueOnce(mockUrl) + + render( + + ) + const anchorElement = screen.queryByTestId( + 'upload-file-anchor' + ) as HTMLAnchorElement + expect(anchorElement.href).toMatch(mockUrl) + }) + + it('renders without the error style', () => { + render( + + ) + + const anchorElement = screen.queryByTestId('upload-file-anchor') + + expect(anchorElement.className).not.toMatch( + 'dnb-upload__file-cell--error' + ) + }) + }) + + describe('Delete Button', () => { + it('renders the delete button', () => { + render() + + const element = screen.queryByTestId('upload-delete-button') + + expect(element).not.toBeNull() + }) + + it('renders the delete button text', () => { + const deleteButtonText = 'delete' + + render( + + ) + + const element = screen.queryByTestId('upload-delete-button') + + expect(element.textContent).toMatch(deleteButtonText) + }) + + it('renders button as tertiary', () => { + render() + + const element = screen.queryByTestId('upload-delete-button') + + expect(element.className).toMatch('dnb-button--tertiary') + }) + + it('renders the file cell loading state', () => { + render( + + ) + + const element = screen.queryByTestId('upload-progress-indicator') + + expect(element).not.toBeNull() + }) + + it('does not render the loading state when not loading', () => { + render( + + ) + + const element = screen.queryByTestId('upload-progress-indicator') + + expect(element).toBeNull() + }) + }) +}) diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/Upload.test.tsx.snap b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/Upload.test.tsx.snap new file mode 100644 index 00000000000..208c1ba6477 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/Upload.test.tsx.snap @@ -0,0 +1,695 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Upload scss have to match snapshot 1`] = ` +"/* +* DNB Upload +* +*/ +/** + * This file is only used to make components independent + * so that they can get imported individually, without the core styles + * + */ +/* + * Utilities + */ +/* + * Scopes + * + */ +/* + * Document Reset + * + */ +/* +* DNB icon +* +*/ +/* +* Icon component +* +*/ +.dnb-icon { + display: inline-block; + vertical-align: middle; + font-size: 1rem; + line-height: 1rem; + color: inherit; + width: 1em; + height: 1em; } + .dnb-icon img, + .dnb-icon svg { + width: inherit; + height: inherit; + shape-rendering: geometricPrecision; + vertical-align: top; } + .dnb-icon svg[width='100%'] { + width: inherit; } + .dnb-icon svg[height='100%'] { + height: inherit; } + .dnb-icon--inherit-color svg:not([fill]), + .dnb-icon--inherit-color svg [fill] { + fill: currentColor; } + .dnb-icon--inherit-color svg [stroke] { + stroke: currentColor; } + .dnb-icon--small { + font-size: 0.75rem; } + .dnb-icon--default { + font-size: 1rem; } + .dnb-icon--medium { + font-size: 1.5rem; } + .dnb-icon--large { + font-size: 2rem; } + .dnb-icon--x-large { + font-size: 2.5rem; } + .dnb-icon--xx-large { + font-size: 3rem; } + .dnb-icon--custom-size { + width: auto; + height: auto; } + .dnb-icon--auto { + font-size: 1em; } + .dnb-icon--auto > .dnb-icon--wrapper { + display: inline-flex; + align-items: center; + justify-content: center; } + h1 .dnb-icon, + h2 .dnb-icon, + h3 .dnb-icon, + h4 .dnb-icon, + h5 .dnb-icon, + h6 .dnb-icon { + vertical-align: middle; } + .dnb-icon.dnb-skeleton { + color: var(--skeleton-color) !important; } + .dnb-icon.dnb-skeleton::before, .dnb-icon.dnb-skeleton::after { + content: none !important; } + @media screen and (-ms-high-contrast: none) { + .dnb-icon { + flex: none; } } + +/* +* DNB Button +* +*/ +/* +* DNB Tooltip +* +*/ +.dnb-tooltip { + font-family: var(--font-family-default); + font-weight: var(--font-weight-basis); + font-size: var(--font-size-small); + font-style: normal; + line-height: var(--line-height-basis); + color: var(--color-black-80, #333); + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + /** + * Ensure consistency and use the same as HTML reset -> html {...} + * between base and code package + */ + -moz-tab-size: 4; + tab-size: 4; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + word-break: break-word; + /** + * 1. Remove repeating backgrounds in all browsers (opinionated). + * 2. Add border box sizing in all browsers (opinionated). + */ + /** + * 1. Add text decoration inheritance in all browsers (opinionated). + * 2. Add vertical alignment inheritance in all browsers (opinionated). + */ + margin: 0; + padding: 0; } + .dnb-tooltip *, + .dnb-tooltip ::before, + .dnb-tooltip ::after { + background-repeat: no-repeat; + /* 1 */ + box-sizing: border-box; + /* 2 */ } + .dnb-tooltip ::before, + .dnb-tooltip ::after { + text-decoration: inherit; + /* 1 */ + vertical-align: inherit; + /* 2 */ } + +/* +* Tooltip component +* +*/ +.dnb-tooltip { + position: absolute; + z-index: 3100; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 0 1rem; + opacity: 0; + visibility: hidden; + transition: opacity 200ms cubic-bezier(0.42, 0, 0, 1); } + .dnb-tooltip--large { + padding: 0.25rem 1rem; } + .dnb-tooltip--animate_position { + transition: all 200ms cubic-bezier(0.42, 0, 0, 1), opacity 200ms cubic-bezier(0.42, 0, 0, 1); } + .dnb-tooltip--active { + visibility: visible; + /* + because of the first \\"show\\" we also use animation + also, use forwards because of the usage of visibility + */ + animation: show-tooltip 200ms cubic-bezier(0.42, 0, 0, 1) forwards; } + html[data-visual-test] .dnb-tooltip--active, .dnb-tooltip--active.dnb-tooltip--no-animation { + animation: show-tooltip 1ms cubic-bezier(0.42, 0, 0, 1) forwards; } + .dnb-tooltip--hide { + visibility: visible; + animation: hide-tooltip 200ms cubic-bezier(0.42, 0, 0, 1) forwards; } + .dnb-tooltip--hide.dnb-tooltip--no-animation { + animation: hide-tooltip 1ms linear forwards; } + .dnb-tooltip--fixed { + position: fixed; } + html[data-visual-test] .dnb-tooltip--hide { + animation: hide-tooltip 1ms linear 1s forwards; } + .dnb-tooltip__portal { + position: absolute; + top: 0; + left: 0; + right: 0; } + .dnb-tooltip__content { + min-width: 2rem; + min-height: 1.5rem; + padding: 0; } + .dnb-tooltip__arrow { + position: absolute; + pointer-events: none; + margin: 0; + width: 1rem; + height: 0.5rem; + overflow: hidden; } + .dnb-tooltip__arrow::before { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 1rem; + height: 1rem; + transform: translateY(70%) rotate(45deg); } + .dnb-tooltip__arrow__position--bottom { + top: -0.5rem; } + .dnb-tooltip__arrow__position--top { + bottom: -0.5rem; + transform: rotate(180deg); } + .dnb-tooltip__arrow__position--left { + right: -0.75rem; + margin-right: 3px; + transform: rotate(90deg); } + .dnb-tooltip__arrow__position--right { + left: -0.75rem; + margin-left: 3px; + transform: rotate(270deg); } + .dnb-tooltip__arrow__arrow--left { + align-self: flex-start; } + .dnb-tooltip__arrow__arrow--right { + align-self: flex-end; } + +@keyframes show-tooltip { + from { + opacity: 0; } + to { + opacity: 1; } } + +@keyframes hide-tooltip { + from { + opacity: 1; } + to { + opacity: 0; + visibility: hidden; } } + +/* +* DNB FormStatus +* +*/ +.dnb-form-status { + font-family: var(--font-family-default); + font-weight: var(--font-weight-basis); + font-size: var(--font-size-small); + font-style: normal; + line-height: var(--line-height-basis); + color: var(--color-black-80, #333); + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + /** + * Ensure consistency and use the same as HTML reset -> html {...} + * between base and code package + */ + -moz-tab-size: 4; + tab-size: 4; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + word-break: break-word; + /** + * 1. Remove repeating backgrounds in all browsers (opinionated). + * 2. Add border box sizing in all browsers (opinionated). + */ + /** + * 1. Add text decoration inheritance in all browsers (opinionated). + * 2. Add vertical alignment inheritance in all browsers (opinionated). + */ + margin: 0; + padding: 0; } + .dnb-form-status *, + .dnb-form-status ::before, + .dnb-form-status ::after { + background-repeat: no-repeat; + /* 1 */ + box-sizing: border-box; + /* 2 */ } + .dnb-form-status ::before, + .dnb-form-status ::after { + text-decoration: inherit; + /* 1 */ + vertical-align: inherit; + /* 2 */ } + +/* + * FormStatus component + * + */ +:root { + --form-status-radius: 0.25rem; } + +.dnb-form-status { + display: flex; + opacity: 1; + transition: height 400ms cubic-bezier(0.42, 0, 0, 1), opacity 400ms cubic-bezier(0.42, 0, 0, 1), margin 400ms cubic-bezier(0.42, 0, 0, 1), padding 400ms cubic-bezier(0.42, 0, 0, 1); } + .dnb-form-status--hidden { + will-change: height, opacity, margin, padding; + width: 0; + height: 0; + opacity: 0; } + .dnb-form-status--is-animating { + overflow: hidden; + width: auto; } + .dnb-form-status--disappear, .dnb-form-status--hidden { + margin: 0 !important; + padding: 0 !important; } + .dnb-form-status__shell { + display: flex; + justify-content: flex-start; + align-items: flex-start; + min-width: inherit; + border-radius: var(--form-status-radius); } + .dnb-form-status__text { + padding: 0.625rem 1rem; + cursor: text; + color: inherit; + line-height: var(--line-height-small); + font-size: var(--font-size-small); + white-space: normal; } + button .dnb-form-status__text { + cursor: inherit; } + .dnb-form-status__text .dnb-anchor { + font-size: inherit; } + .dnb-icon + .dnb-form-status__text { + padding-left: 0.5rem; } + .dnb-form-status__shell > .dnb-icon { + display: flex; + justify-content: center; + align-items: center; + margin: 0.3333333em 0.3333333em 0.3333333em 0.6666666em; } + .dnb-form-status__size--large .dnb-form-status__text { + padding-top: 1.125rem; + padding-bottom: 1.125rem; } + .dnb-form-status__size--large .dnb-form-status__shell > .dnb-icon { + margin-top: 0.6666666em; + margin-bottom: 0.6666666em; } + .dnb-form-status--stretch { + flex-grow: 1; } + .dnb-form-status--stretch .dnb-form-status__shell { + width: 100%; } + .dnb-form-status--stretch .dnb-form-status__text { + max-width: 47rem; } + .dnb-form-status[hidden] { + display: none; } + .dnb-form-status--no-animation, + html[data-visual-test] .dnb-form-status { + transition-duration: 1ms !important; } + @media screen and (-ms-high-contrast: none) { + .dnb-form-status__shell > .dnb-icon { + border-width: 1px; } } + +.dnb-button { + font-family: var(--font-family-default); + font-weight: var(--font-weight-basis); + font-size: var(--font-size-small); + font-style: normal; + line-height: var(--line-height-basis); + color: var(--color-black-80, #333); + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + /** + * Ensure consistency and use the same as HTML reset -> html {...} + * between base and code package + */ + -moz-tab-size: 4; + tab-size: 4; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + word-break: break-word; + /** + * 1. Remove repeating backgrounds in all browsers (opinionated). + * 2. Add border box sizing in all browsers (opinionated). + */ + /** + * 1. Add text decoration inheritance in all browsers (opinionated). + * 2. Add vertical alignment inheritance in all browsers (opinionated). + */ + margin: 0; + padding: 0; } + .dnb-button *, + .dnb-button ::before, + .dnb-button ::after { + background-repeat: no-repeat; + /* 1 */ + box-sizing: border-box; + /* 2 */ } + .dnb-button ::before, + .dnb-button ::after { + text-decoration: inherit; + /* 1 */ + vertical-align: inherit; + /* 2 */ } + +/* +* Button component +* +*/ +:root { + --button-font-size: var(--font-size-basis); + --button-font-size-small: var(--font-size-small); + --button-width: 2.5rem; + --button-height: 2.5rem; + --button-width--small: 1.5rem; + --button-height--small: 1.5rem; + --button-width--medium: 2rem; + --button-height--medium: 2rem; + --button-width--large: 3rem; + --button-height--large: 3rem; + --button-icon-size: 1rem; + --button-border-width: 0.0625rem; + --button-border-width--hover: 0.1875rem; + --button-border-radius: calc(var(--button-height) / 2); + --button-border-radius--small: calc(var(--button-height--small) / 2); + --button-border-radius--medium: calc(var(--button-height--medium) / 2); + --button-border-radius--large: calc(var(--button-height--large) / 2); } + +.dnb-button { + position: relative; + user-select: none; + -webkit-user-select: none; + cursor: pointer; + white-space: nowrap; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--button-width); + height: auto; + padding: 0; + border: var(--button-border-width) solid transparent; + border-radius: var(--button-border-radius); + text-decoration: none; + font-size: var(--font-size-small); + /* stylelint-disable-next-line */ } + .dnb-button--wrap { + overflow-wrap: break-word; + white-space: normal; } + .dnb-button, + .dnb-core-style .dnb-button { + line-height: var(--button-height); } + .dnb-button__text { + margin: 0.5rem 0; + font-size: var(--button-font-size); + line-height: var(--line-height-basis); + color: inherit; + transform: translateY(-0.03125rem); } + .dnb-button__text [data-os='linux'] { + transform: translateY(-0.035rem); } + .dnb-button__alignment { + display: inline-block; + width: 0; } + .dnb-button__bounding { + position: absolute; + top: 0; + bottom: 0; + right: 0; + left: 0; + transform: scale(1.1, 1.4); + background-color: transparent; + border-radius: var(--button-border-radius); } + .dnb-button--has-text { + padding-left: 1.5rem; + padding-right: 1.5rem; } + .dnb-button--size-small { + width: var(--button-width--small); + font-size: var(--button-font-size-small); + border-radius: var(--button-border-radius--small); } + .dnb-button--size-small, + .dnb-core-style .dnb-button--size-small { + line-height: var(--button-height--small); } + .dnb-button--size-small .dnb-button__text { + margin: 0; } + .dnb-button--has-text.dnb-button--size-small { + padding-left: 1rem; + padding-right: 1rem; } + .dnb-button--has-text.dnb-button--icon-position-left.dnb-button--size-small { + padding-left: 0.5rem; } + .dnb-button--has-text.dnb-button--icon-position-right.dnb-button--size-small { + padding-right: 0.5rem; } + .dnb-button--size-medium { + width: var(--button-width--medium); + border-radius: var(--button-border-radius--medium); } + .dnb-button--size-medium, + .dnb-core-style .dnb-button--size-medium { + line-height: var(--button-height--medium); } + .dnb-button--size-medium .dnb-button__text { + margin: 0; } + .dnb-button--has-text.dnb-button--size-medium { + padding-left: 1rem; + padding-right: 1rem; } + .dnb-button--has-text.dnb-button--icon-position-left.dnb-button--size-medium { + padding-left: 0.5rem; } + .dnb-button--has-text.dnb-button--icon-position-right.dnb-button--size-medium { + padding-right: 0.5rem; } + .dnb-button--size-large { + width: var(--button-width--large); + border-radius: var(--button-border-radius--large); } + .dnb-button--size-large, + .dnb-core-style .dnb-button--size-large { + line-height: var(--button-height--large); } + .dnb-button--has-text.dnb-button--size-large { + padding-left: 2rem; + padding-right: 2rem; } + .dnb-button--has-text.dnb-button--icon-position-left.dnb-button--size-large { + padding-left: 1rem; } + .dnb-button--has-text.dnb-button--icon-position-right.dnb-button--size-large { + padding-right: 1rem; } + .dnb-button--has-text { + width: auto; } + .dnb-button--has-text .dnb-button__icon { + margin: 0 calc(var(--button-icon-size) / 2); } + .dnb-button--has-text.dnb-button--icon-position-left { + padding-left: 0.5rem; } + .dnb-button--has-text.dnb-button--icon-position-right { + padding-right: 0.5rem; } + .dnb-button--has-text.dnb-button--has-icon .dnb-button__icon { + order: 2; } + .dnb-button--has-text.dnb-button--has-icon .dnb-button__text { + order: 1; } + .dnb-button:not(.dnb-button--has-text) .dnb-button__icon { + width: inherit; } + .dnb-button__icon.dnb-icon svg:not([width]):not([height]) { + width: var(--button-icon-size); + height: var(--button-icon-size); } + [href] > .dnb-button__icon.dnb-icon { + line-height: var(--button-font-size); } + .dnb-button--has-text.dnb-button--has-icon.dnb-button--icon-position-left .dnb-button__icon, .dnb-button--has-text.dnb-button--has-icon.dnb-button--icon-position-top .dnb-button__icon { + order: 1; } + .dnb-button--has-text.dnb-button--has-icon.dnb-button--icon-position-left > *, + .dnb-button--has-text.dnb-button--has-icon.dnb-button--icon-position-left .dnb-button__text, .dnb-button--has-text.dnb-button--has-icon.dnb-button--icon-position-top > *, + .dnb-button--has-text.dnb-button--has-icon.dnb-button--icon-position-top .dnb-button__text { + order: 2; } + .dnb-button--stretch { + width: 100%; } + .dnb-button--reset { + margin: 0; + padding: 0; + width: auto; + height: auto; + overflow: visible; + border: none; + border-radius: 0; + background-color: transparent; + appearance: none; + box-shadow: none; + color: inherit; + font: inherit; + text-align: inherit; + line-height: inherit; } + html:not([data-whatintent='touch']) .dnb-button--reset:hover[disabled] { + cursor: not-allowed; } + html:not([data-whatintent='touch']) .dnb-button--reset:hover:not([disabled]) { + box-shadow: none; + border: none; } + .dnb-button--reset:not([disabled]):focus, .dnb-button--reset:not([disabled]):active { + outline: none; } + html[data-whatinput='keyboard'] .dnb-button--reset:not([disabled]):focus, html[data-whatinput='keyboard'] .dnb-button--reset:not([disabled]):active { + --border-color: var(--color-emerald-green); + box-shadow: 0 0 0 0.125rem var(--border-color); + border-color: transparent; } + @media screen and (-ms-high-contrast: none) { + html[data-whatinput='keyboard'] .dnb-button--reset:not([disabled]):focus, html[data-whatinput='keyboard'] .dnb-button--reset:not([disabled]):active { + box-shadow: 0 0 0 0.125rem var(--color-emerald-green); } } + html[data-whatinput='mouse'] .dnb-button--reset:not([disabled]):focus, + html[data-whatinput='mouse'] .dnb-button--reset:not([disabled]):active { + box-shadow: none; + color: inherit; + border: none; } + .dnb-button[type='button'], .dnb-button[type='reset'], .dnb-button[type='submit'] { + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; } + .dnb-button[disabled] { + cursor: not-allowed; + outline: none; } + .dnb-form-row--vertical .dnb-form-row__content > .dnb-button { + align-self: flex-start; } + .dnb-form-row--horizontal .dnb-form-row__content .dnb-button__text { + white-space: nowrap; } + .dnb-button + .dnb-form-status { + margin-top: 0.5rem; } + @media screen and (-ms-high-contrast: none) { + .dnb-button { + flex: none; } + .dnb-button__icon, .dnb-button__text { + transform: translateY(-0.0625rem); } } + +/* Firefox includes a hidden border which messes up button dimensions */ +button.dnb-button::-moz-focus-inner { + border: none; } + +.dnb-upload { + font-family: var(--font-family-default); + font-weight: var(--font-weight-basis); + font-size: var(--font-size-small); + font-style: normal; + line-height: var(--line-height-basis); + color: var(--color-black-80, #333); + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + /** + * Ensure consistency and use the same as HTML reset -> html {...} + * between base and code package + */ + -moz-tab-size: 4; + tab-size: 4; + -ms-text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; + word-break: break-word; + /** + * 1. Remove repeating backgrounds in all browsers (opinionated). + * 2. Add border box sizing in all browsers (opinionated). + */ + /** + * 1. Add text decoration inheritance in all browsers (opinionated). + * 2. Add vertical alignment inheritance in all browsers (opinionated). + */ + margin: 0; + padding: 0; } + .dnb-upload *, + .dnb-upload ::before, + .dnb-upload ::after { + background-repeat: no-repeat; + /* 1 */ + box-sizing: border-box; + /* 2 */ } + .dnb-upload ::before, + .dnb-upload ::after { + text-decoration: inherit; + /* 1 */ + vertical-align: inherit; + /* 2 */ } + +/* +* Upload component +* +*/ +.dnb-upload { + display: flex; + flex-direction: column; + position: relative; + padding: var(--spacing-medium); + background-color: var(--color-white); } + .dnb-upload__outline { + content: ''; + pointer-events: none; + position: absolute; + inset: 0; + height: 100%; + width: 100%; + stroke: var(--color-sea-green); + border-radius: 0.25rem; } + .dnb-upload__condition-list__label { + font-weight: var(--font-weight-medium); + margin-right: var(--spacing-x-small); } + .dnb-upload__text.dnb-p { + color: var(--color-black-55); } + .dnb-upload__file-input { + position: absolute; + visibility: hidden; } + .dnb-upload__file-list { + position: relative; + padding: 0; + margin-top: var(--spacing-medium); + margin-bottom: 0; + list-style: none; } + .dnb-upload__file-list::before, .dnb-upload__file-cell::after { + content: ''; + position: absolute; + inset: 0; + height: 1px; + background-color: var(--color-black-8); } + .dnb-upload__file-cell { + position: relative; + padding: var(--spacing-small) 0; } + .dnb-upload__file-cell::after { + top: auto; } + .dnb-upload__file-cell__content { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; } + .dnb-upload__file-cell__content__left { + display: flex; + flex-direction: row; + align-items: center; } + .dnb-upload__file-cell--warning .dnb-upload__file-cell__content__left .dnb-icon { + color: var(--color-fire-red); } + .dnb-upload__file-cell__text-container { + display: flex; + flex-direction: column; + margin-left: var(--spacing-small); } + .dnb-upload__file-cell__text-container--loading { + font-size: var(--font-size-basis); } + .dnb-upload__file-cell__subtitle.dnb-p { + color: var(--color-black-55); } +" +`; diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-upload-screenshot-have-to-match-file-list-1-c85d6.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-upload-screenshot-have-to-match-file-list-1-c85d6.snap.png new file mode 100644 index 00000000000..6d36216ae88 Binary files /dev/null and b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-upload-screenshot-have-to-match-file-list-1-c85d6.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-upload-screenshot-have-to-match-the-custom-accepted-formats-1-b65b2.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-upload-screenshot-have-to-match-the-custom-accepted-formats-1-b65b2.snap.png new file mode 100644 index 00000000000..20ac300b211 Binary files /dev/null and b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-upload-screenshot-have-to-match-the-custom-accepted-formats-1-b65b2.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-upload-screenshot-have-to-match-the-default-1-33947.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-upload-screenshot-have-to-match-the-default-1-33947.snap.png new file mode 100644 index 00000000000..82f66c2fbd7 Binary files /dev/null and b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-upload-screenshot-have-to-match-the-default-1-33947.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-upload-screenshot-have-to-match-the-loading-state-1-b2ffe.snap.png b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-upload-screenshot-have-to-match-the-loading-state-1-b2ffe.snap.png new file mode 100644 index 00000000000..a9fb985930b Binary files /dev/null and b/packages/dnb-eufemia/src/components/upload/__tests__/__snapshots__/upload-screenshot-test-js-upload-screenshot-have-to-match-the-loading-state-1-b2ffe.snap.png differ diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/testHelpers.ts b/packages/dnb-eufemia/src/components/upload/__tests__/testHelpers.ts new file mode 100644 index 00000000000..6210e3d8a5f --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/testHelpers.ts @@ -0,0 +1,13 @@ +export default function createMockFile( + name: string, + size: number, + type: string +) { + const file = new File([], name, { type }) + Object.defineProperty(file, 'size', { + get() { + return size + }, + }) + return file +} diff --git a/packages/dnb-eufemia/src/components/upload/__tests__/useUpload.test.tsx b/packages/dnb-eufemia/src/components/upload/__tests__/useUpload.test.tsx new file mode 100644 index 00000000000..3719a899a51 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/__tests__/useUpload.test.tsx @@ -0,0 +1,116 @@ +import { act, render } from '@testing-library/react' +import useUpload from './../useUpload' +import React, { useEffect } from 'react' +import createMockFile from './testHelpers' +import EventEmitter from '../../../../src/shared/helpers/EventEmitter' + +describe('useUpload', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('return an empty list', () => { + const validationFunction = jest.fn() + + const MockComponents = () => { + const { files } = useUpload('id') + + validationFunction(files) + + return
    + } + + render() + + expect(validationFunction).toHaveBeenCalledWith([]) + }) + + it('return the updateFiles function', () => { + const validationFunction = jest.fn() + + const MockComponents = () => { + const { setFiles } = useUpload('id') + + validationFunction(setFiles) + + return
    + } + + render() + + expect(validationFunction).toHaveBeenCalledWith(expect.any(Function)) + }) + + it('return the added files', () => { + const validationFunction = jest.fn() + + const mockFile = createMockFile('fileName.png', 100, 'image/png') + + const MockComponents = () => { + const { setFiles, files } = useUpload('id') + + useEffect(() => { + setFiles([{ file: mockFile }]) + }, []) + + validationFunction(files) + return
    + } + + render() + act(() => { + expect(validationFunction).toHaveBeenCalledWith([{ file: mockFile }]) + }) + }) + + it('use the event emitter to store a file', () => { + const mockFile = { + file: createMockFile('fileName.png', 100, 'image/png'), + } + const id = 'unique-id-1' + + const MockComponents = () => { + const { setFiles } = useUpload(id) + + useEffect(() => setFiles([mockFile]), []) + + return
    + } + + render() + + const emitter = EventEmitter.createInstance(id) + const emitterFiles = emitter.get().files + + expect(emitterFiles).toMatchObject([mockFile]) + }) + + it('use the event emitter to return a file', () => { + const mockFile = { + file: createMockFile('fileName.png', 100, 'image/png'), + } + const id = 'unique-id-2' + + const validationFunction = jest.fn() + + const MockComponents = () => { + const { files } = useUpload(id) + + useEffect(() => { + validationFunction(files) + }, []) + + return
    + } + + const emitter = EventEmitter.createInstance(id) + const emitterData = { files: [mockFile] } + emitter.update(emitterData) + + render() + + act(() => { + expect(validationFunction).toHaveBeenCalledWith([mockFile]) + }) + }) +}) diff --git a/packages/dnb-eufemia/src/components/upload/index.js b/packages/dnb-eufemia/src/components/upload/index.js new file mode 100644 index 00000000000..67067079c7c --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/index.js @@ -0,0 +1,8 @@ +/** + * Component Entry + * + */ + +import Upload from './Upload' +export default Upload +export * from './Upload' diff --git a/packages/dnb-eufemia/src/components/upload/stories/Upload.stories.tsx b/packages/dnb-eufemia/src/components/upload/stories/Upload.stories.tsx new file mode 100644 index 00000000000..b8c14b7fd5a --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/stories/Upload.stories.tsx @@ -0,0 +1,73 @@ +/** + * @dnb/eufemia Component Story + * + */ + +import React, { useEffect } from 'react' +import { Wrapper, Box } from 'storybook-utils/helpers' +import { Upload } from '../..' + +export default { + title: 'Eufemia/Components/Upload', +} + +export const UploadSandbox = () => { + const { files: files1 } = Upload.useUpload('upload-example-1') + Upload.useUpload('upload-example-6') + + useEffect(() => { + console.log(files1) + }, [files1]) + + return ( + + + + + + + + + + + + + + + + + + Two Upload components can be controlled using the same id + + + + + ) +} diff --git a/packages/dnb-eufemia/src/components/upload/style.js b/packages/dnb-eufemia/src/components/upload/style.js new file mode 100644 index 00000000000..a767e2f50a8 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/style.js @@ -0,0 +1,6 @@ +/** + * Web Style Import + * + */ + +import './style/dnb-upload.scss' diff --git a/packages/dnb-eufemia/src/components/upload/style/_upload.scss b/packages/dnb-eufemia/src/components/upload/style/_upload.scss new file mode 100644 index 00000000000..438ffbd4fe3 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/style/_upload.scss @@ -0,0 +1,109 @@ +/* +* Upload component +* +*/ + +.dnb-upload { + display: flex; + flex-direction: column; + + position: relative; + padding: var(--spacing-medium); + + background-color: var(--color-white); + + // svg element + &__outline { + content: ''; + + pointer-events: none; + + position: absolute; + inset: 0; + + height: 100%; + width: 100%; + + stroke: var(--color-sea-green); + border-radius: 0.25rem; + } + + &__condition-list { + &__label { + font-weight: var(--font-weight-medium); + margin-right: var(--spacing-x-small); + } + } + + &__text.dnb-p { + color: var(--color-black-55); + } + + &__file-input { + position: absolute; + visibility: hidden; + } + + &__file-list { + position: relative; + + padding: 0; + margin-top: var(--spacing-medium); + margin-bottom: 0; + + list-style: none; + } + + // Border on top/bottom of list items + &__file-list::before, + &__file-cell::after { + content: ''; + position: absolute; + inset: 0; + height: 1px; + background-color: var(--color-black-8); + } + + &__file-cell { + position: relative; + padding: var(--spacing-small) 0; + + // Align border to bottom + &::after { + top: auto; + } + + &__content { + display: flex; + flex-direction: row; + + justify-content: space-between; + align-items: center; + + &__left { + display: flex; + flex-direction: row; + align-items: center; + } + } + + &--warning &__content__left .dnb-icon { + color: var(--color-fire-red); + } + + &__text-container { + display: flex; + flex-direction: column; + + margin-left: var(--spacing-small); + + &--loading { + font-size: var(--font-size-basis); + } + } + + &__subtitle.dnb-p { + color: var(--color-black-55); + } + } +} diff --git a/packages/dnb-eufemia/src/components/upload/style/dnb-upload.scss b/packages/dnb-eufemia/src/components/upload/style/dnb-upload.scss new file mode 100644 index 00000000000..2803ec38d51 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/style/dnb-upload.scss @@ -0,0 +1,16 @@ +/* +* DNB Upload +* +*/ + +// import dependencies +@import '../../../style/components/imports.scss'; +@import '../../icon/style/dnb-icon.scss'; +@import '../../button/style/dnb-button.scss'; +@import '../../form-status/style/dnb-form-status.scss'; + +.dnb-upload { + @include componentReset(); +} + +@import './_upload.scss'; diff --git a/packages/dnb-eufemia/src/components/upload/style/index.js b/packages/dnb-eufemia/src/components/upload/style/index.js new file mode 100644 index 00000000000..4ef8acea2ce --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/style/index.js @@ -0,0 +1,6 @@ +/** + * Web Style Import + * + */ + +import './dnb-upload.scss' diff --git a/packages/dnb-eufemia/src/components/upload/types.ts b/packages/dnb-eufemia/src/components/upload/types.ts new file mode 100644 index 00000000000..18bd7a38bf0 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/types.ts @@ -0,0 +1,56 @@ +import React from 'react' +import { SkeletonShow } from '../skeleton/Skeleton' + +export type UploadProps = { + /** + * unique id used with the useUpload hook to manage the files + */ + id: string + + /** + * list of accepted file types. + */ + acceptedFileTypes: string[] + + /** + * Custom className on the component root + * Default: null + */ + className?: string + + /** + * Skeleton should be applied when loading content + * Default: null + */ + skeleton?: SkeletonShow + + /** + * If set true, accepting multiple files is allowed + */ + multipleFiles?: boolean + + /** + * fileMaxSize is max size of each file in MB + */ + fileMaxSize?: number + + /** + * Custom text properties + */ + title?: React.ReactNode + text?: React.ReactNode + formatsDescription?: React.ReactNode + fileSizeDescription?: React.ReactNode + fileSizeContent?: React.ReactNode + uploadButtonText?: React.ReactNode + uploadErrorLargeFile?: React.ReactNode + uploadLoadingText?: React.ReactNode + deleteButton?: React.ReactNode + fileListAriaLabel?: string +} + +export type UploadFile = { + file: File + errorMessage?: React.ReactNode + isLoading?: boolean +} diff --git a/packages/dnb-eufemia/src/components/upload/useUpload.ts b/packages/dnb-eufemia/src/components/upload/useUpload.ts new file mode 100644 index 00000000000..7b9ad643cf2 --- /dev/null +++ b/packages/dnb-eufemia/src/components/upload/useUpload.ts @@ -0,0 +1,25 @@ +import { useEventEmitter } from '../../../src/shared/component-helper' +import type { UploadFile } from './types' + +/** + * Use together with Upload with the same id to manage the files from outside the component. + * @param id string, must match the id of the Upload component + * @returns { files: UploadFile[], setFiles: (file: UploadFile[]) => void } + */ +function useUpload(id: string): { + files: UploadFile[] + setFiles: (files: UploadFile[]) => void +} { + const { data, update } = useEventEmitter(id) + + function setFiles(files: UploadFile[]) { + update({ files }) + } + + return { + files: data?.files || [], + setFiles, + } +} + +export default useUpload diff --git a/packages/dnb-eufemia/src/index.js b/packages/dnb-eufemia/src/index.js index a9c8a5eddcd..6c912c8414c 100644 --- a/packages/dnb-eufemia/src/index.js +++ b/packages/dnb-eufemia/src/index.js @@ -82,6 +82,7 @@ import Textarea from './components/textarea/Textarea' import Timeline from './components/timeline/Timeline' import ToggleButton from './components/toggle-button/ToggleButton' import Tooltip from './components/tooltip/Tooltip' +import Upload from './components/upload/Upload' import VisuallyHidden from './components/visually-hidden/VisuallyHidden' // define / export all the available components @@ -158,6 +159,7 @@ export { Timeline, ToggleButton, Tooltip, + Upload, VisuallyHidden, } diff --git a/packages/dnb-eufemia/src/shared/Context.tsx b/packages/dnb-eufemia/src/shared/Context.tsx index 0baed64d747..adab4fa7437 100644 --- a/packages/dnb-eufemia/src/shared/Context.tsx +++ b/packages/dnb-eufemia/src/shared/Context.tsx @@ -26,6 +26,7 @@ import type { DrawerProps } from '../components/drawer/types' import type { DialogProps } from '../components/dialog/types' import type { TooltipProps } from '../components/tooltip/types' import type { SectionProps } from '../components/section/Section' +import { UploadProps } from '../components/upload/types' // All TypeScript based Eufemia elements import type { AnchorProps } from '../elements/Anchor' @@ -53,6 +54,7 @@ export type ContextProps = { Tooltip?: Partial Section?: Partial ScrollView?: Partial + Upload?: Partial // -- TODO: Not converted yet -- diff --git a/packages/dnb-eufemia/src/shared/locales/en-GB.js b/packages/dnb-eufemia/src/shared/locales/en-GB.js index d1710e5137c..06857e8d569 100644 --- a/packages/dnb-eufemia/src/shared/locales/en-GB.js +++ b/packages/dnb-eufemia/src/shared/locales/en-GB.js @@ -122,5 +122,18 @@ export default { Tag: { removeIconTitle: 'Remove', }, + Upload: { + title: 'Upload documents', + text: 'Drag & drop your files or choose files to upload.', + formatsDescription: 'Allowed formats:', + fileSizeDescription: 'Max. filesize:', + fileSizeContent: '%size MB', + uploadButtonText: 'Browse files', + uploadLoadingText: 'Uploading', + uploadErrorLargeFile: + 'The file you are trying to upload is too big, the maximum size supported is %size MB. Please try again.', + deleteButton: 'Delete', + fileListAriaLabel: 'uploaded files', + }, }, } diff --git a/packages/dnb-eufemia/src/shared/locales/nb-NO.js b/packages/dnb-eufemia/src/shared/locales/nb-NO.js index 0f0ff46d371..47e6c6c5ea2 100644 --- a/packages/dnb-eufemia/src/shared/locales/nb-NO.js +++ b/packages/dnb-eufemia/src/shared/locales/nb-NO.js @@ -122,5 +122,18 @@ export default { Tag: { removeIconTitle: 'Fjern', }, + Upload: { + title: 'Last opp dokumenter', + text: 'Dra & slipp eller velg hvilke filer du vil laste opp.', + formatsDescription: 'Tilatte formater:', + fileSizeDescription: 'Maks filstørrelse:', + fileSizeContent: '%size MB', + uploadButtonText: 'Utforsk filer', + uploadLoadingText: 'Laster opp', + uploadErrorLargeFile: + 'Filen du prøver å laste opp er for stor, vi støtter ikke filer større enn %size MB. Vennligst prøv igjen.', + deleteButton: 'Slett', + fileListAriaLabel: 'opplastede filer', + }, }, } diff --git a/packages/dnb-eufemia/src/style/dnb-ui-components.scss b/packages/dnb-eufemia/src/style/dnb-ui-components.scss index 6ca4f51e95d..faf8f352a94 100644 --- a/packages/dnb-eufemia/src/style/dnb-ui-components.scss +++ b/packages/dnb-eufemia/src/style/dnb-ui-components.scss @@ -49,4 +49,5 @@ @import '../components/timeline/style/_timeline.scss'; @import '../components/toggle-button/style/_toggle-button.scss'; @import '../components/tooltip/style/_tooltip.scss'; +@import '../components/upload/style/_upload.scss'; @import '../components/visually-hidden/style/_visually-hidden.scss'; diff --git a/packages/dnb-eufemia/src/style/elements/__tests__/__snapshots__/Elements.test.js.snap b/packages/dnb-eufemia/src/style/elements/__tests__/__snapshots__/Elements.test.js.snap index 8c116e51171..5f8c9e1ac25 100644 --- a/packages/dnb-eufemia/src/style/elements/__tests__/__snapshots__/Elements.test.js.snap +++ b/packages/dnb-eufemia/src/style/elements/__tests__/__snapshots__/Elements.test.js.snap @@ -711,11 +711,14 @@ a.dnb-button { content: none; } .dnb-dl { - margin: 0; padding: 0; font-size: var(--font-size-basis); line-height: var(--line-height-basis); color: var(--theme-color-black-80, currentColor); } + .dnb-dl:not([class*='dnb-space__top']) { + margin-top: 0; } + .dnb-dl:not([class*='dnb-space__bottom']) { + margin-bottom: 0; } .dnb-dl dt { margin-top: 1rem; padding: 0; diff --git a/packages/dnb-eufemia/src/style/elements/lists.scss b/packages/dnb-eufemia/src/style/elements/lists.scss index 27636075a7f..23f87708800 100644 --- a/packages/dnb-eufemia/src/style/elements/lists.scss +++ b/packages/dnb-eufemia/src/style/elements/lists.scss @@ -105,7 +105,12 @@ } } @mixin dlStyle() { - margin: 0; + &:not([class*='dnb-space__top']) { + margin-top: 0; + } + &:not([class*='dnb-space__bottom']) { + margin-bottom: 0; + } padding: 0; font-size: var(--font-size-basis);