diff --git a/.changeset/rich-rice-grin.md b/.changeset/rich-rice-grin.md new file mode 100644 index 0000000000..457b7eadab --- /dev/null +++ b/.changeset/rich-rice-grin.md @@ -0,0 +1,5 @@ +--- +"@frontify/fondue-components": patch +--- + +feat(AssetInput): add AssetInput component diff --git a/packages/components/src/components/AssetInput/AssetInput.metadata.json b/packages/components/src/components/AssetInput/AssetInput.metadata.json new file mode 100644 index 0000000000..fe02d965c5 --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInput.metadata.json @@ -0,0 +1,9 @@ +{ + "category": "input", + "description": "A compound input for selecting, uploading and displaying assets.", + "instructions": "Use AssetInput.Placeholder for the empty state, composing AssetInput.UploadInput and/or AssetInput.BrowseInput. Use AssetInput.Root for the filled state, composing AssetInput.Preview (with one or more AssetInput.PreviewImage/AssetInput.PreviewIcon/AssetInput.PreviewLoading), AssetInput.Title, and AssetInput.Metadata wrapping one or more AssetInput.MetadataItem entries. Control the open state with isOpen and onPress on AssetInput.Root, typically to toggle an attached Dropdown for asset operations.", + "name": "AssetInput", + "relatedComponents": [], + "storyFilePaths": ["src/components/AssetInput/AssetInput.stories.tsx"], + "tags": ["asset", "upload", "file", "input", "form"] +} diff --git a/packages/components/src/components/AssetInput/AssetInput.stories.tsx b/packages/components/src/components/AssetInput/AssetInput.stories.tsx new file mode 100644 index 0000000000..381d9c1236 --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInput.stories.tsx @@ -0,0 +1,251 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { IconArrowCircleUp, IconImageStack, IconMusicNote, IconTrashBin } from '@frontify/fondue-icons'; +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { action } from 'storybook/actions'; + +import { Dropdown } from '../Dropdown/Dropdown'; +import { Flex } from '../Flex/Flex'; + +import { AssetInput, type AssetInputRootProps } from './AssetInput'; + +type StoryArgs = AssetInputRootProps & { acceptFileType: string }; + +const meta = { + title: 'Components/AssetInput', + tags: ['autodocs'], + component: AssetInput.Root, + subcomponents: { + Placeholder: AssetInput.Placeholder, + UploadInput: AssetInput.UploadInput, + BrowseInput: AssetInput.BrowseInput, + Preview: AssetInput.Preview, + PreviewImage: AssetInput.PreviewImage, + PreviewIcon: AssetInput.PreviewIcon, + PreviewLoading: AssetInput.PreviewLoading, + Title: AssetInput.Title, + Metadata: AssetInput.Metadata, + MetadataItem: AssetInput.MetadataItem, + }, + argTypes: { + acceptFileType: { + control: { type: 'text' }, + description: 'File types accepted by the upload input.', + }, + orientation: { + control: { type: 'radio' }, + options: ['horizontal', 'vertical'], + description: 'The orientation of the asset input.', + }, + }, + args: { + acceptFileType: 'image/*', + orientation: 'horizontal', + }, + parameters: { + controls: { include: ['acceptFileType', 'orientation'] }, + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Placeholder: Story = { + name: 'Placeholder', + parameters: { + controls: { include: ['acceptFileType'] }, + }, + render: ({ acceptFileType }) => ( + + + + + ), +}; + +export const PlaceholderOnlyUpload: Story = { + name: 'Placeholder - Upload Only', + parameters: { + controls: { include: ['acceptFileType'] }, + }, + render: ({ acceptFileType }) => ( + + + + ), +}; + +export const PlaceholderOnlyBrowse: Story = { + name: 'Placeholder - Browse Only', + parameters: { + controls: { include: [] }, + }, + render: () => ( + + + + ), +}; + +export const AssetInputRoot: Story = { + name: 'Input - Single - Image', + parameters: { + controls: { include: ['orientation'] }, + }, + render: ({ orientation }) => { + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen(!isOpen)}> + + + + foo1 + + + + + Uploaded + + + JPG + 2000 bytes + + + ); + }, +}; + +export const AssetInputRootIcon: Story = { + name: 'Input - Single - Icon', + parameters: { + controls: { include: ['orientation'] }, + }, + render: ({ orientation }) => { + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen(!isOpen)}> + + + + + + foo1 + + + + + Uploaded + + + JPG + 2000 bytes + + + ); + }, +}; + +export const AssetInputRootLoading: Story = { + name: 'Input - Single - Loading', + parameters: { + controls: { include: ['orientation'] }, + }, + render: ({ orientation }) => { + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen(!isOpen)}> + + + + foo1 + + + + + Uploaded + + + JPG + 2000 bytes + + + ); + }, +}; + +export const AssetInputRootMultipleImages: Story = { + name: 'Input - Multiple - Mixed', + parameters: { + controls: { include: ['orientation'] }, + }, + render: ({ orientation }) => { + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen(!isOpen)}> + + + + + + + + + 3 assets + + 2 locations + + + ); + }, +}; + +export const AssetInputAsDropdownTrigger: Story = { + name: 'Input as Dropdown Trigger', + parameters: { + controls: { include: ['orientation'] }, + }, + render: ({ orientation }) => { + const [isOpen, setIsOpen] = useState(false); + return ( + + + + + + + foo1 + + + + + Uploaded + + + JPG + 2000 bytes + + + + + + + + Replace with browse + + + + Replace with upload + + + + + + Delete + + + + + ); + }, +}; diff --git a/packages/components/src/components/AssetInput/AssetInput.tsx b/packages/components/src/components/AssetInput/AssetInput.tsx new file mode 100644 index 0000000000..a4708d11f8 --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInput.tsx @@ -0,0 +1,31 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { forwardRef } from 'react'; + +import { AssetInputBrowseInput } from './AssetInputBrowseInput'; +import { AssetInputMetadata } from './AssetInputMetadata'; +import { AssetInputMetadataItem } from './AssetInputMetadataItem'; +import { AssetInputPlaceholder } from './AssetInputPlaceholder'; +import { AssetInputPreview } from './AssetInputPreview'; +import { AssetInputPreviewIcon } from './AssetInputPreviewIcon'; +import { AssetInputPreviewImage } from './AssetInputPreviewImage'; +import { AssetInputPreviewLoading } from './AssetInputPreviewLoading'; +import { AssetInputRoot, type AssetInputRootProps } from './AssetInputRoot'; +import { AssetInputTitle } from './AssetInputTitle'; +import { AssetInputUploadInput } from './AssetInputUploadInput'; + +export type { AssetInputRootProps }; + +export const AssetInput = { + Placeholder: AssetInputPlaceholder, + UploadInput: AssetInputUploadInput, + BrowseInput: AssetInputBrowseInput, + Root: forwardRef(AssetInputRoot), + Preview: AssetInputPreview, + PreviewImage: AssetInputPreviewImage, + PreviewIcon: AssetInputPreviewIcon, + Title: AssetInputTitle, + Metadata: AssetInputMetadata, + MetadataItem: AssetInputMetadataItem, + PreviewLoading: AssetInputPreviewLoading, +}; diff --git a/packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx b/packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx new file mode 100644 index 0000000000..79e86d5eb3 --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx @@ -0,0 +1,22 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { IconImageStack } from '@frontify/fondue-icons'; + +import { useTranslation } from '#/hooks/useTranslation'; + +import { Button } from '../Button/Button'; + +export type AssetInputBrowseInputProps = { + onBrowse: () => void; +}; + +export const AssetInputBrowseInput = ({ onBrowse }: AssetInputBrowseInputProps) => { + const { t } = useTranslation(); + + return ( + + ); +}; diff --git a/packages/components/src/components/AssetInput/AssetInputMetadata.tsx b/packages/components/src/components/AssetInput/AssetInputMetadata.tsx new file mode 100644 index 0000000000..8737c3e5df --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputMetadata.tsx @@ -0,0 +1,13 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type ReactNode } from 'react'; + +import styles from './styles/asset-input.module.scss'; + +export type AssetInputMetadataProps = { + children: ReactNode; +}; + +export const AssetInputMetadata = ({ children }: AssetInputMetadataProps) => { + return
{children}
; +}; diff --git a/packages/components/src/components/AssetInput/AssetInputMetadataItem.tsx b/packages/components/src/components/AssetInput/AssetInputMetadataItem.tsx new file mode 100644 index 0000000000..2612fef8ed --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputMetadataItem.tsx @@ -0,0 +1,13 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type ReactNode } from 'react'; + +import styles from './styles/asset-input.module.scss'; + +export type AssetInputMetadataItemProps = { + children: ReactNode; +}; + +export const AssetInputMetadataItem = ({ children }: AssetInputMetadataItemProps) => { + return {children}; +}; diff --git a/packages/components/src/components/AssetInput/AssetInputPlaceholder.tsx b/packages/components/src/components/AssetInput/AssetInputPlaceholder.tsx new file mode 100644 index 0000000000..d8c9567a1b --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputPlaceholder.tsx @@ -0,0 +1,25 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type ReactElement } from 'react'; + +import { Flex } from '../Flex/Flex'; + +import { type AssetInputBrowseInputProps } from './AssetInputBrowseInput'; +import { type AssetInputUploadInputProps } from './AssetInputUploadInput'; +import styles from './styles/asset-input.module.scss'; + +type AssetInputAction = ReactElement; + +export type AssetInputPlaceholderProps = { + children: AssetInputAction | [AssetInputAction, AssetInputAction]; +}; + +export const AssetInputPlaceholder = ({ children }: AssetInputPlaceholderProps) => { + return ( +
+ + {children} + +
+ ); +}; diff --git a/packages/components/src/components/AssetInput/AssetInputPreview.tsx b/packages/components/src/components/AssetInput/AssetInputPreview.tsx new file mode 100644 index 0000000000..1b0d296757 --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputPreview.tsx @@ -0,0 +1,32 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { Children, type ReactElement } from 'react'; + +import { type AssetInputPreviewIconProps } from './AssetInputPreviewIcon'; +import { type AssetInputPreviewImageProps } from './AssetInputPreviewImage'; +import { type AssetInputPreviewLoadingProps } from './AssetInputPreviewLoading'; +import styles from './styles/asset-input.module.scss'; + +const MAX_PREVIEW_PARTS = 4; + +type AssetInputPreviewPart = ReactElement< + AssetInputPreviewImageProps | AssetInputPreviewIconProps | AssetInputPreviewLoadingProps +>; + +type AssetInputPreviewProps = { + children: AssetInputPreviewPart | AssetInputPreviewPart[]; +}; + +export const AssetInputPreview = ({ children }: AssetInputPreviewProps) => { + const parts = Children.toArray(children).slice(0, MAX_PREVIEW_PARTS); + const emptySlotCount = parts.length > 1 ? MAX_PREVIEW_PARTS - parts.length : 0; + + return ( +
+ {parts} + {Array.from({ length: emptySlotCount }, (_, index) => ( +
+ ))} +
+ ); +}; diff --git a/packages/components/src/components/AssetInput/AssetInputPreviewIcon.tsx b/packages/components/src/components/AssetInput/AssetInputPreviewIcon.tsx new file mode 100644 index 0000000000..86f303538f --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputPreviewIcon.tsx @@ -0,0 +1,13 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type ReactNode } from 'react'; + +import styles from './styles/asset-input.module.scss'; + +export type AssetInputPreviewIconProps = { + children: ReactNode; +}; + +export const AssetInputPreviewIcon = ({ children }: AssetInputPreviewIconProps) => { + return
{children}
; +}; diff --git a/packages/components/src/components/AssetInput/AssetInputPreviewImage.tsx b/packages/components/src/components/AssetInput/AssetInputPreviewImage.tsx new file mode 100644 index 0000000000..6412364a64 --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputPreviewImage.tsx @@ -0,0 +1,12 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import styles from './styles/asset-input.module.scss'; + +export type AssetInputPreviewImageProps = { + src: string; + alt?: string; +}; + +export const AssetInputPreviewImage = ({ src, alt = '' }: AssetInputPreviewImageProps) => { + return {alt}; +}; diff --git a/packages/components/src/components/AssetInput/AssetInputPreviewLoading.tsx b/packages/components/src/components/AssetInput/AssetInputPreviewLoading.tsx new file mode 100644 index 0000000000..29ad65a760 --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputPreviewLoading.tsx @@ -0,0 +1,17 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { LoadingCircle, type LoadingCircleProps } from '../LoadingCircle/LoadingCircle'; + +import styles from './styles/asset-input.module.scss'; + +export type AssetInputPreviewLoadingProps = { + size?: LoadingCircleProps['size']; +}; + +export const AssetInputPreviewLoading = ({ size = 'small' }: AssetInputPreviewLoadingProps) => { + return ( +
+ +
+ ); +}; diff --git a/packages/components/src/components/AssetInput/AssetInputRoot.tsx b/packages/components/src/components/AssetInput/AssetInputRoot.tsx new file mode 100644 index 0000000000..daf505fa85 --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputRoot.tsx @@ -0,0 +1,38 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { IconCaretDown } from '@frontify/fondue-icons'; +import { type ForwardedRef, type ReactNode } from 'react'; + +import styles from './styles/asset-input.module.scss'; + +export type AssetInputOrientation = 'horizontal' | 'vertical'; + +export type AssetInputRootProps = { + children: ReactNode; + orientation?: AssetInputOrientation; + isOpen?: boolean; + onPress?: () => void; +}; + +export const AssetInputRoot = ( + { children, orientation = 'horizontal', isOpen = false, onPress, ...props }: AssetInputRootProps, + ref: ForwardedRef, +) => { + return ( + + ); +}; +AssetInputRoot.displayName = 'AssetInput.Root'; diff --git a/packages/components/src/components/AssetInput/AssetInputTitle.tsx b/packages/components/src/components/AssetInput/AssetInputTitle.tsx new file mode 100644 index 0000000000..5912a6a2ee --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputTitle.tsx @@ -0,0 +1,13 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { type ReactNode } from 'react'; + +import styles from './styles/asset-input.module.scss'; + +type AssetInputTitleProps = { + children: ReactNode; +}; + +export const AssetInputTitle = ({ children }: AssetInputTitleProps) => { + return {children}; +}; diff --git a/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx b/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx new file mode 100644 index 0000000000..99f1c06e54 --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx @@ -0,0 +1,47 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { IconArrowCircleUp } from '@frontify/fondue-icons'; +import { type ChangeEvent, useRef } from 'react'; + +import { useTranslation } from '#/hooks/useTranslation'; + +import { Button } from '../Button/Button'; + +export type AssetInputUploadInputProps = { + acceptFileType?: string; + allowMultiple?: boolean; + onSelect: (event: ChangeEvent) => void; +}; + +export const AssetInputUploadInput = ({ + acceptFileType, + allowMultiple = false, + onSelect, +}: AssetInputUploadInputProps) => { + const fileInputRef = useRef(null); + const { t } = useTranslation(); + + const openFileUploadDialog = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + return ( + <> + + + + + ); +}; diff --git a/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx b/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx new file mode 100644 index 0000000000..663e2592c5 --- /dev/null +++ b/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx @@ -0,0 +1,191 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { IconArrowCircleUp, IconMusicNote } from '@frontify/fondue-icons'; +import { expect, test } from '@playwright/experimental-ct-react'; +import sinon from 'sinon'; + +import { Flex } from '../../Flex/Flex'; +import { AssetInput } from '../AssetInput'; + +const ASSET_IMG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + +test('should render placeholder with upload and browse actions', async ({ mount }) => { + const onBrowse = sinon.spy(); + const onFileChange = sinon.spy(); + const wrapper = await mount( + + + + , + ); + await expect(wrapper.getByRole('button', { name: /Upload/i })).toBeVisible(); + await expect(wrapper.getByRole('button', { name: /Browse/i })).toBeVisible(); + + await wrapper.getByRole('button', { name: /Browse/i }).click(); + expect(onBrowse.calledOnce).toBe(true); + + await wrapper.locator('input[type="file"]').setInputFiles({ + name: 'test.png', + mimeType: 'image/png', + buffer: Buffer.from('x'), + }); + expect(onFileChange.calledOnce).toBe(true); +}); + +test('should render placeholder with upload only', async ({ mount }) => { + const wrapper = await mount( + + {}} /> + , + ); + await expect(wrapper.getByRole('button', { name: /Upload/i })).toBeVisible(); + await expect(wrapper.getByRole('button', { name: /Browse/i })).toHaveCount(0); +}); + +test('should render placeholder with browse only', async ({ mount }) => { + const wrapper = await mount( + + {}} /> + , + ); + await expect(wrapper.getByRole('button', { name: /Browse/i })).toBeVisible(); + await expect(wrapper.getByRole('button', { name: /Upload/i })).toHaveCount(0); +}); + +test('should set data-open when isOpen is true', async ({ mount }) => { + const wrapper = await mount( + {}}> + foo1 + , + ); + const root = wrapper.getByRole('button'); + await expect(root).toHaveAttribute('data-open', 'true'); +}); + +test('should call onPress when root is clicked', async ({ mount }) => { + const onPress = sinon.spy(); + const wrapper = await mount( + + foo1 + , + ); + const root = wrapper.getByRole('button'); + await root.click(); + expect(onPress.calledOnce).toBe(true); +}); + +for (const orientation of ['horizontal', 'vertical'] as const) { + test(`should render single image preview with title and metadata (Input - Single - Image) (${orientation})`, async ({ + mount, + }) => { + const wrapper = await mount( + {}}> + + + + foo1 + + + + + Uploaded + + + JPG + 2000 bytes + + , + ); + const root = wrapper.getByRole('button', { name: /foo1/ }); + await expect(root).toBeVisible(); + await expect(root).toHaveAttribute('data-orientation', orientation); + await expect(root).toHaveAttribute('data-open', 'false'); + await expect(root).toContainText('Uploaded'); + await expect(root).toContainText('JPG'); + await expect(root).toContainText('2000 bytes'); + await expect(root.locator('img[alt="asset preview"]')).toHaveCount(1); + }); + + test(`should render single icon preview with title and metadata (Input - Single - Icon) (${orientation})`, async ({ + mount, + }) => { + const wrapper = await mount( + {}}> + + + + + + foo1 + + Uploaded + JPG + 2000 bytes + + , + ); + const root = wrapper.getByRole('button', { name: /foo1/ }); + await expect(root).toBeVisible(); + await expect(root).toHaveAttribute('data-orientation', orientation); + await expect(root).toContainText('Uploaded'); + await expect(root.locator('img')).toHaveCount(0); + const previewIconSvg = root.locator(':scope > div').first().locator('svg'); + await expect(previewIconSvg).toBeVisible(); + await expect(previewIconSvg).toHaveCount(1); + }); + + test(`should render preview loading with title and metadata (Input - Single - Loading) (${orientation})`, async ({ + mount, + }) => { + const wrapper = await mount( + {}}> + + + + foo1 + + + + + Uploaded + + + JPG + 2000 bytes + + , + ); + const root = wrapper.getByRole('button', { name: /foo1/ }); + await expect(root).toBeVisible(); + await expect(root).toHaveAttribute('data-orientation', orientation); + await expect(root.getByTestId('fondue-loading-circle-content')).toBeVisible(); + }); + + test(`should render mixed multiple preview with title and metadata (Input - Multiple - Mixed) (${orientation})`, async ({ + mount, + }) => { + const wrapper = await mount( + {}}> + + + + + + + + + 3 assets + + 2 locations + + , + ); + const root = wrapper.getByRole('button', { name: /3 assets/ }); + await expect(root).toBeVisible(); + await expect(root).toHaveAttribute('data-orientation', orientation); + await expect(root).toContainText('2 locations'); + await expect(root.locator('img')).toHaveCount(2); + await expect(root.getByTestId('fondue-loading-circle-content')).toBeVisible(); + }); +} diff --git a/packages/components/src/components/AssetInput/styles/asset-input.module.scss b/packages/components/src/components/AssetInput/styles/asset-input.module.scss new file mode 100644 index 0000000000..2703b19555 --- /dev/null +++ b/packages/components/src/components/AssetInput/styles/asset-input.module.scss @@ -0,0 +1,220 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +@use '../../../utilities/sizeToken.module.scss'; +@use '../../../utilities/transitions.module.scss'; +@use '../../../utilities/focusStyle.module.scss'; + +.root { + width: 100%; + overflow: hidden; + display: grid; + align-items: center; + + font-family: var(--typography-font-family-primary); + font-weight: 400; + font-size: var(--typography-font-size-medium); + line-height: var(--typography-line-height-medium); + box-sizing: border-box; + + text-align: start; + color: var(--color-primary-default); + background-color: var(--color-surface-default); + border-radius: var(--border-radius-medium); + border: var(--border-width-default) solid var(--color-line-mid); + + &:focus-visible { + @include focusStyle.focus-outline-styles; + } + + &:hover, + &[data-open='true'] { + border-color: var(--color-line-strong); + } + + &[data-open='true'] .caret svg { + transform: rotate(180deg); + } + + &[data-orientation='horizontal'] { + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto; + grid-template-areas: + 'preview title caret' + 'preview metadata caret'; + + &:has(.preview > :nth-child(2)) { + .title { + font-size: var(--typography-font-size-large); + font-weight: 700; + } + + .metadata { + font-size: var(--typography-font-size-small); + margin-top: sizeToken.get(0.5); + } + + .caret svg { + width: sizeToken.get(6); + height: sizeToken.get(6); + } + } + } + + &[data-orientation='vertical'] { + grid-template-columns: 1fr auto; + grid-template-rows: auto auto auto; + grid-template-areas: + 'preview preview' + 'title caret' + 'metadata caret'; + + .preview { + height: sizeToken.get(32); + width: 100%; + border-right: none; + border-bottom: var(--border-width-default) solid var(--color-line-mid); + } + + &:not(:has(.preview > :nth-child(2))) .previewIcon svg { + width: sizeToken.get(8); + height: sizeToken.get(8); + } + + &:has(.preview > :nth-child(2)) .preview { + grid-template-columns: sizeToken.get(15.5) sizeToken.get(15.5); + grid-template-rows: sizeToken.get(15.5) sizeToken.get(15.5); + gap: sizeToken.get(1); + justify-content: center; + } + } +} + +.preview { + grid-area: preview; + aspect-ratio: 1 / 1; + min-width: 0; + min-height: 0; + border-right: var(--border-width-default) solid var(--color-line-mid); + box-sizing: content-box; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + &:not(:has(> :nth-child(2))) { + width: sizeToken.get(14); + + .previewImage { + object-fit: contain; + } + } + + &:has(> :nth-child(2)) { + display: grid; + grid-template-columns: sizeToken.get(11) sizeToken.get(11); + grid-template-rows: sizeToken.get(11) sizeToken.get(11); + gap: sizeToken.get(0.5); + + .previewImage { + object-fit: cover; + } + } +} + +.previewImage, +.previewSlot, +.previewIcon { + width: 100%; + height: 100%; + background-color: var(--color-surface-dim); + display: flex; + align-items: center; + justify-content: center; +} + +.previewIcon { + svg { + width: sizeToken.get(6); + height: sizeToken.get(6); + color: var(--color-secondary-default); + } +} + +.title { + grid-area: title; + min-width: 0; + padding-left: sizeToken.get(4); + padding-right: sizeToken.get(4); + align-self: end; +} + +.metadata { + grid-area: metadata; + display: flex; + gap: sizeToken.get(1); + align-items: center; + flex-shrink: 0; + min-width: 0; + padding-left: sizeToken.get(4); + padding-right: sizeToken.get(4); + white-space: nowrap; + overflow: hidden; + align-self: start; +} + +.metadataItem { + color: var(--color-secondary-default); + font-size: var(--typography-font-size-x-small); + display: inline-flex; + align-items: center; + + &:not(:first-child)::before { + content: ''; + display: block; + width: sizeToken.get(1); + height: sizeToken.get(1); + margin-right: sizeToken.get(1); + background-color: var(--color-container-secondary-default); + border-radius: 50%; + } +} + +.caret { + grid-area: caret; + color: var(--color-secondary-default); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + padding: sizeToken.get(4); + background-color: var(--color-surface-default); + + svg { + @include transitions.transition-transform; + width: sizeToken.get(4); + height: sizeToken.get(4); + } +} + +.placeholder { + border: var(--border-width-default) dashed var(--color-line-subtle); + border-radius: var(--border-radius-medium); + + [data-asset-input-action='upload'] { + order: 0; + } + + [data-asset-input-action='browse'] { + order: 1; + } + + &:has([data-asset-input-action='upload']) [data-asset-input-action='browse']::before { + content: ''; + position: absolute; + top: 0; + height: 100%; + left: calc(-1 * #{sizeToken.get(1)}); + width: var(--border-width-default); + background-color: var(--color-line-subtle); + } +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 5e893a9b23..38c6bc815f 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -3,6 +3,7 @@ import './styles.scss'; export { Accordion } from './components/Accordion/Accordion'; +export { AssetInput } from './components/AssetInput/AssetInput'; export { Badge } from './components/Badge/Badge'; export { Box } from './components/Box/Box'; export { Button } from './components/Button/Button'; diff --git a/packages/components/src/locales/de-CH.ts b/packages/components/src/locales/de-CH.ts index 54458a6da1..6f47365a44 100644 --- a/packages/components/src/locales/de-CH.ts +++ b/packages/components/src/locales/de-CH.ts @@ -5,6 +5,8 @@ import { de as dateLocale } from 'date-fns/locale'; import { type LocaleConfig } from './types'; const translations = { + AssetInput_browse: 'Durchsuchen', + AssetInput_upload: 'Hochladen', Badge_dismiss: '${label} schliessen', Card_deselect: 'Abwählen', Card_select: 'Auswählen', diff --git a/packages/components/src/locales/de-DE.ts b/packages/components/src/locales/de-DE.ts index 2468f142fa..28cefbab4e 100644 --- a/packages/components/src/locales/de-DE.ts +++ b/packages/components/src/locales/de-DE.ts @@ -5,6 +5,8 @@ import { de as dateLocale } from 'date-fns/locale'; import { type LocaleConfig } from './types'; const translations = { + AssetInput_browse: 'Durchsuchen', + AssetInput_upload: 'Hochladen', Badge_dismiss: '${label} schließen', Card_deselect: 'Abwählen', Card_select: 'Auswählen', diff --git a/packages/components/src/locales/en-US.ts b/packages/components/src/locales/en-US.ts index b6dc97bc91..4ed8b58821 100644 --- a/packages/components/src/locales/en-US.ts +++ b/packages/components/src/locales/en-US.ts @@ -3,6 +3,8 @@ import { enUS as dateLocale } from 'date-fns/locale'; const translations = { + AssetInput_browse: 'Browse', + AssetInput_upload: 'Upload', Badge_dismiss: 'Dismiss ${label}', Card_deselect: 'Deselect', Card_select: 'Select', diff --git a/packages/components/src/locales/es-ES.ts b/packages/components/src/locales/es-ES.ts index 903000c39d..ae5a692758 100644 --- a/packages/components/src/locales/es-ES.ts +++ b/packages/components/src/locales/es-ES.ts @@ -5,6 +5,8 @@ import { es as dateLocale } from 'date-fns/locale'; import { type LocaleConfig } from './types'; const translations = { + AssetInput_browse: 'Examinar', + AssetInput_upload: 'Subir', Badge_dismiss: 'Cerrar ${label}', Card_deselect: 'Deseleccionar', Card_select: 'Seleccionar', diff --git a/packages/components/src/locales/fr-CH.ts b/packages/components/src/locales/fr-CH.ts index 81d35da7af..62bb790a83 100644 --- a/packages/components/src/locales/fr-CH.ts +++ b/packages/components/src/locales/fr-CH.ts @@ -5,6 +5,8 @@ import { fr as dateLocale } from 'date-fns/locale'; import { type LocaleConfig } from './types'; const translations = { + AssetInput_browse: 'Parcourir', + AssetInput_upload: 'Téléverser', Badge_dismiss: 'Fermer ${label}', Card_deselect: 'Désélectionner', Card_select: 'Sélectionner', diff --git a/packages/components/src/locales/fr-FR.ts b/packages/components/src/locales/fr-FR.ts index 511a4a6425..f7bafc8d50 100644 --- a/packages/components/src/locales/fr-FR.ts +++ b/packages/components/src/locales/fr-FR.ts @@ -5,6 +5,8 @@ import { fr as dateLocale } from 'date-fns/locale'; import { type LocaleConfig } from './types'; const translations = { + AssetInput_browse: 'Parcourir', + AssetInput_upload: 'Téléverser', Badge_dismiss: 'Fermer ${label}', Card_deselect: 'Désélectionner', Card_select: 'Sélectionner', diff --git a/packages/components/src/locales/it-CH.ts b/packages/components/src/locales/it-CH.ts index 57e7b61662..c8f81f9f25 100644 --- a/packages/components/src/locales/it-CH.ts +++ b/packages/components/src/locales/it-CH.ts @@ -5,6 +5,8 @@ import { it as dateLocale } from 'date-fns/locale'; import { type LocaleConfig } from './types'; const translations = { + AssetInput_browse: 'Sfoglia', + AssetInput_upload: 'Carica', Badge_dismiss: 'Chiudi ${label}', Card_deselect: 'Deseleziona', Card_select: 'Seleziona', diff --git a/packages/components/src/locales/it-IT.ts b/packages/components/src/locales/it-IT.ts index ca8464dd03..2b12abc5cd 100644 --- a/packages/components/src/locales/it-IT.ts +++ b/packages/components/src/locales/it-IT.ts @@ -5,6 +5,8 @@ import { it as dateLocale } from 'date-fns/locale'; import { type LocaleConfig } from './types'; const translations = { + AssetInput_browse: 'Sfoglia', + AssetInput_upload: 'Carica', Badge_dismiss: 'Chiudi ${label}', Card_deselect: 'Deseleziona', Card_select: 'Seleziona', diff --git a/packages/components/src/locales/nl-NL.ts b/packages/components/src/locales/nl-NL.ts index 986a0c8883..18bc36dd17 100644 --- a/packages/components/src/locales/nl-NL.ts +++ b/packages/components/src/locales/nl-NL.ts @@ -5,6 +5,8 @@ import { nl as dateLocale } from 'date-fns/locale'; import { type LocaleConfig } from './types'; const translations = { + AssetInput_browse: 'Bladeren', + AssetInput_upload: 'Uploaden', Badge_dismiss: 'Sluit ${label}', Card_deselect: 'Deselecteren', Card_select: 'Selecteren', diff --git a/packages/components/src/locales/pl-PL.ts b/packages/components/src/locales/pl-PL.ts index 47a5bf3f3e..16759fd239 100644 --- a/packages/components/src/locales/pl-PL.ts +++ b/packages/components/src/locales/pl-PL.ts @@ -5,6 +5,8 @@ import { pl as dateLocale } from 'date-fns/locale'; import { type LocaleConfig } from './types'; const translations = { + AssetInput_browse: 'Przeglądaj', + AssetInput_upload: 'Prześlij', Badge_dismiss: 'Zamknij ${label}', Card_deselect: 'Odznacz', Card_select: 'Wybierz', diff --git a/packages/components/src/locales/pt-PT.ts b/packages/components/src/locales/pt-PT.ts index 241d8db17a..1b794f1b2b 100644 --- a/packages/components/src/locales/pt-PT.ts +++ b/packages/components/src/locales/pt-PT.ts @@ -5,6 +5,8 @@ import { pt as dateLocale } from 'date-fns/locale'; import { type LocaleConfig } from './types'; const translations = { + AssetInput_browse: 'Procurar', + AssetInput_upload: 'Carregar', Badge_dismiss: 'Fechar ${label}', Card_deselect: 'Desselecionar', Card_select: 'Selecionar',