From 20c0a0eaaa0a071394fdb08c9bd28accdb98a437 Mon Sep 17 00:00:00 2001 From: fulopdaniel Date: Wed, 13 May 2026 14:41:09 +0200 Subject: [PATCH 1/8] feat(AssetInput): add AssetInput component --- .../AssetInput/AssetInput.metadata.json | 9 + .../AssetInput/AssetInput.stories.tsx | 191 ++++++++++++++++ .../src/components/AssetInput/AssetInput.tsx | 27 +++ .../AssetInput/AssetInputBrowseInput.tsx | 18 ++ .../AssetInput/AssetInputMetadata.tsx | 13 ++ .../AssetInput/AssetInputPlaceholder.tsx | 42 ++++ .../AssetInput/AssetInputPreview.tsx | 43 ++++ .../AssetInput/AssetInputPreviewIcon.tsx | 13 ++ .../AssetInput/AssetInputPreviewImage.tsx | 12 ++ .../AssetInput/AssetInputPreviewLoading.tsx | 17 ++ .../components/AssetInput/AssetInputRoot.tsx | 81 +++++++ .../components/AssetInput/AssetInputTitle.tsx | 13 ++ .../AssetInput/AssetInputUploadInput.tsx | 45 ++++ .../AssetInput/__tests__/AssetInput.ct.tsx | 184 ++++++++++++++++ .../AssetInput/styles/asset-input.module.scss | 203 ++++++++++++++++++ packages/components/src/index.ts | 1 + 16 files changed, 912 insertions(+) create mode 100644 packages/components/src/components/AssetInput/AssetInput.metadata.json create mode 100644 packages/components/src/components/AssetInput/AssetInput.stories.tsx create mode 100644 packages/components/src/components/AssetInput/AssetInput.tsx create mode 100644 packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx create mode 100644 packages/components/src/components/AssetInput/AssetInputMetadata.tsx create mode 100644 packages/components/src/components/AssetInput/AssetInputPlaceholder.tsx create mode 100644 packages/components/src/components/AssetInput/AssetInputPreview.tsx create mode 100644 packages/components/src/components/AssetInput/AssetInputPreviewIcon.tsx create mode 100644 packages/components/src/components/AssetInput/AssetInputPreviewImage.tsx create mode 100644 packages/components/src/components/AssetInput/AssetInputPreviewLoading.tsx create mode 100644 packages/components/src/components/AssetInput/AssetInputRoot.tsx create mode 100644 packages/components/src/components/AssetInput/AssetInputTitle.tsx create mode 100644 packages/components/src/components/AssetInput/AssetInputUploadInput.tsx create mode 100644 packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx create mode 100644 packages/components/src/components/AssetInput/styles/asset-input.module.scss 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..8e96d9f097 --- /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 one or more AssetInput.Metadata items. 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..def9ce833d --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInput.stories.tsx @@ -0,0 +1,191 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { IconArrowCircleUp, IconMusicNote } from '@frontify/fondue-icons'; +import { type Meta, type StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { action } from 'storybook/actions'; + +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, + }, + 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 + + ); + }, +}; diff --git a/packages/components/src/components/AssetInput/AssetInput.tsx b/packages/components/src/components/AssetInput/AssetInput.tsx new file mode 100644 index 0000000000..d4b2078a22 --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInput.tsx @@ -0,0 +1,27 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { AssetInputBrowseInput } from './AssetInputBrowseInput'; +import { AssetInputMetadata } from './AssetInputMetadata'; +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: AssetInputRoot, + Preview: AssetInputPreview, + PreviewImage: AssetInputPreviewImage, + PreviewIcon: AssetInputPreviewIcon, + Title: AssetInputTitle, + Metadata: AssetInputMetadata, + 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..19e707ce3c --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx @@ -0,0 +1,18 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { IconImageStack } from '@frontify/fondue-icons'; + +import { Button } from '../Button/Button'; + +type AssetInputBrowseInputProps = { + onBrowse: () => void; +}; + +export const AssetInputBrowseInput = ({ onBrowse }: AssetInputBrowseInputProps) => { + 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..14d2220578 --- /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'; + +type AssetInputMetadataProps = { + children: ReactNode; +}; + +export const AssetInputMetadata = ({ children }: AssetInputMetadataProps) => { + 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..1031bbe40b --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputPlaceholder.tsx @@ -0,0 +1,42 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { Children, isValidElement, useMemo, type ReactNode } from 'react'; + +import { Flex } from '../Flex/Flex'; + +import { AssetInputBrowseInput } from './AssetInputBrowseInput'; +import { AssetInputUploadInput } from './AssetInputUploadInput'; +import styles from './styles/asset-input.module.scss'; + +export type AssetInputPlaceholderProps = { children: ReactNode }; + +export const AssetInputPlaceholder = ({ children }: AssetInputPlaceholderProps) => { + const inputs = useMemo(() => { + const inputs: ReactNode[] = []; + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + if (child.type === AssetInputUploadInput || child.type === AssetInputBrowseInput) { + inputs.push(child); + } + }); + return inputs; + }, [children]); + + if (inputs.length === 0) { + return null; + } + + const [firstInput, secondInput] = inputs; + + return ( +
+ + {firstInput} + {secondInput ?
: null} + {secondInput} + +
+ ); +}; diff --git a/packages/components/src/components/AssetInput/AssetInputPreview.tsx b/packages/components/src/components/AssetInput/AssetInputPreview.tsx new file mode 100644 index 0000000000..a247d0369a --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputPreview.tsx @@ -0,0 +1,43 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { Children, isValidElement, useMemo, type ReactNode } from 'react'; + +import { AssetInputPreviewIcon } from './AssetInputPreviewIcon'; +import { AssetInputPreviewImage } from './AssetInputPreviewImage'; +import { AssetInputPreviewLoading } from './AssetInputPreviewLoading'; +import styles from './styles/asset-input.module.scss'; + +type AssetInputPreviewProps = { + children: ReactNode; +}; + +export const AssetInputPreview = ({ children }: AssetInputPreviewProps) => { + const previewParts = useMemo(() => { + const previewParts: ReactNode[] = []; + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + if ( + child.type === AssetInputPreviewImage || + child.type === AssetInputPreviewIcon || + child.type === AssetInputPreviewLoading + ) { + previewParts.push(child); + } + }); + if (previewParts.length > 1 && previewParts.length < 4) { + const emptyParts = Array.from({ length: 4 - previewParts.length }, (_, i) => ( +
+ )); + previewParts.push(...emptyParts); + } + return previewParts.slice(0, 4); + }, [children]); + + return ( +
1} className={styles.preview}> + {previewParts} +
+ ); +}; diff --git a/packages/components/src/components/AssetInput/AssetInputPreviewIcon.tsx b/packages/components/src/components/AssetInput/AssetInputPreviewIcon.tsx new file mode 100644 index 0000000000..17ce61c87a --- /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'; + +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..17603a7346 --- /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'; + +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..08e44f2b83 --- /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'; + +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..c96fef39cb --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputRoot.tsx @@ -0,0 +1,81 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { IconCaretDown } from '@frontify/fondue-icons'; +import { Children, isValidElement, useMemo, type ReactNode } from 'react'; + +import { AssetInputMetadata } from './AssetInputMetadata'; +import { AssetInputPreview } from './AssetInputPreview'; +import { AssetInputTitle } from './AssetInputTitle'; +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, +}: AssetInputRootProps) => { + const { parts, metadata } = useMemo(() => { + const parts: Record<'preview' | 'title', ReactNode> = { + preview: null, + title: null, + }; + const metadataItems: { key: number; child: ReactNode }[] = []; + Children.forEach(children, (child, index) => { + if (!isValidElement(child)) { + return; + } + if (child.type === AssetInputPreview) { + parts.preview = child; + } + if (child.type === AssetInputTitle) { + parts.title = child; + } + if (child.type === AssetInputMetadata) { + metadataItems.push({ key: index, child }); + } + }); + const metadata = metadataItems.flatMap((meta, index) => + index === 0 + ? [meta.child] + : [
, meta.child], + ); + return { parts, metadata }; + }, [children]); + + const { title, preview } = parts; + const hasMetadata = metadata.length > 0; + + return ( + + ); +}; 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..2b45709b98 --- /dev/null +++ b/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx @@ -0,0 +1,45 @@ +/* (c) Copyright Frontify Ltd., all rights reserved. */ + +import { IconArrowCircleUp } from '@frontify/fondue-icons'; +import { useCallback, useId, useRef } from 'react'; + +import { Button } from '../Button/Button'; + +type AssetInputUploadInputProps = { + acceptFileType?: string; + allowMultiple?: boolean; + onFileChange: (event: React.ChangeEvent) => void; +}; + +export const AssetInputUploadInput = ({ + acceptFileType, + allowMultiple = false, + onFileChange, +}: AssetInputUploadInputProps) => { + const fileInputRef = useRef(null); + const id = useId(); + const openFileUploadDialog = useCallback(() => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }, [fileInputRef]); + + 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..5f73ece68d --- /dev/null +++ b/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx @@ -0,0 +1,184 @@ +/* (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(); + await expect(root.locator('[data-multiple-parts="true"]')).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..b88c7ca7f6 --- /dev/null +++ b/packages/components/src/components/AssetInput/styles/asset-input.module.scss @@ -0,0 +1,203 @@ +/* (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: flex; + 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 { + transform: rotate(180deg); + } + } + + &[data-orientation='vertical'] { + flex-direction: column; + + .preview { + height: sizeToken.get(32); + width: 100%; + border-right: none; + border-bottom: var(--border-width-default) solid var(--color-line-mid); + } + + &:has([data-multiple-parts='false']) { + .previewIcon { + svg { + width: sizeToken.get(8); + height: sizeToken.get(8); + } + } + } + + &:has([data-multiple-parts='true']) { + .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; + } + } + } + + &[data-orientation='horizontal']:has([data-multiple-parts='true']) { + .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); + } + } + } +} + +.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; + + &[data-multiple-parts='false'] { + width: sizeToken.get(14); + + .previewImage { + object-fit: contain; + } + } + + &[data-multiple-parts='true'] { + 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); + } +} + +.placeholder { + border: var(--border-width-default) dashed var(--color-line-subtle); + border-radius: var(--border-radius-medium); +} + +.separator { + width: var(--border-width-default); + background-color: var(--color-line-subtle); +} + +.mainContainer { + display: flex; + width: 100%; + align-items: center; +} + +.contentContainer { + display: flex; + flex-direction: column; + padding-left: sizeToken.get(4); + padding-right: sizeToken.get(4); + min-width: 0; +} + +.caretContainer { + display: flex; + align-items: center; + justify-content: center; + padding: sizeToken.get(4); + margin-left: auto; + flex-shrink: 0; + background-color: var(--color-surface-default); +} + +.caret { + @include transitions.transition-transform; + color: var(--color-secondary-default); + flex-shrink: 0; + + svg { + width: sizeToken.get(4); + height: sizeToken.get(4); + } +} + +.metadataContainer { + display: flex; + gap: sizeToken.get(1); + white-space: nowrap; + overflow: hidden; + align-items: center; + flex-shrink: 0; +} + +.metadata { + color: var(--color-secondary-default); + font-size: var(--typography-font-size-x-small); +} + +.metaSeparator { + width: sizeToken.get(1); + height: sizeToken.get(1); + background-color: var(--color-container-secondary-default); + border-radius: 50%; +} 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'; From 54deae792abb6b25eaf0bdca4e690a7093fd6751 Mon Sep 17 00:00:00 2001 From: Daniel Fulop Date: Wed, 13 May 2026 15:01:20 +0200 Subject: [PATCH 2/8] Create rich-rice-grin.md --- .changeset/rich-rice-grin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/rich-rice-grin.md 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 From c2885fc4194fada743df43a57424f6d6c2351cf3 Mon Sep 17 00:00:00 2001 From: fulopdaniel Date: Thu, 14 May 2026 09:26:50 +0200 Subject: [PATCH 3/8] add one more story --- .../AssetInput/AssetInput.stories.tsx | 51 ++++++++++++++++++- .../src/components/AssetInput/AssetInput.tsx | 4 +- .../components/AssetInput/AssetInputRoot.tsx | 17 ++++--- 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/components/src/components/AssetInput/AssetInput.stories.tsx b/packages/components/src/components/AssetInput/AssetInput.stories.tsx index def9ce833d..c345f90c8e 100644 --- a/packages/components/src/components/AssetInput/AssetInput.stories.tsx +++ b/packages/components/src/components/AssetInput/AssetInput.stories.tsx @@ -1,10 +1,11 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ -import { IconArrowCircleUp, IconMusicNote } from '@frontify/fondue-icons'; +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'; @@ -189,3 +190,51 @@ export const AssetInputRootMultipleImages: Story = { ); }, }; + +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 index d4b2078a22..ec25339140 100644 --- a/packages/components/src/components/AssetInput/AssetInput.tsx +++ b/packages/components/src/components/AssetInput/AssetInput.tsx @@ -1,5 +1,7 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ +import { forwardRef } from 'react'; + import { AssetInputBrowseInput } from './AssetInputBrowseInput'; import { AssetInputMetadata } from './AssetInputMetadata'; import { AssetInputPlaceholder } from './AssetInputPlaceholder'; @@ -17,7 +19,7 @@ export const AssetInput = { Placeholder: AssetInputPlaceholder, UploadInput: AssetInputUploadInput, BrowseInput: AssetInputBrowseInput, - Root: AssetInputRoot, + Root: forwardRef(AssetInputRoot), Preview: AssetInputPreview, PreviewImage: AssetInputPreviewImage, PreviewIcon: AssetInputPreviewIcon, diff --git a/packages/components/src/components/AssetInput/AssetInputRoot.tsx b/packages/components/src/components/AssetInput/AssetInputRoot.tsx index c96fef39cb..5c4c887dd7 100644 --- a/packages/components/src/components/AssetInput/AssetInputRoot.tsx +++ b/packages/components/src/components/AssetInput/AssetInputRoot.tsx @@ -1,7 +1,7 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ import { IconCaretDown } from '@frontify/fondue-icons'; -import { Children, isValidElement, useMemo, type ReactNode } from 'react'; +import { Children, isValidElement, useMemo, type ForwardedRef, type ReactNode } from 'react'; import { AssetInputMetadata } from './AssetInputMetadata'; import { AssetInputPreview } from './AssetInputPreview'; @@ -14,15 +14,13 @@ export type AssetInputRootProps = { children: ReactNode; orientation: AssetInputOrientation; isOpen: boolean; - onPress: () => void; + onPress?: () => void; }; -export const AssetInputRoot = ({ - children, - orientation = 'horizontal', - isOpen = false, - onPress, -}: AssetInputRootProps) => { +export const AssetInputRoot = ( + { children, orientation = 'horizontal', isOpen = false, onPress, ...props }: AssetInputRootProps, + ref: ForwardedRef, +) => { const { parts, metadata } = useMemo(() => { const parts: Record<'preview' | 'title', ReactNode> = { preview: null, @@ -56,11 +54,13 @@ export const AssetInputRoot = ({ return ( ); }; +AssetInputRoot.displayName = 'AssetInput.Root'; From 326fbf50e2b5c6abcfb7be97b423746ab5000634 Mon Sep 17 00:00:00 2001 From: fulopdaniel Date: Tue, 19 May 2026 11:51:10 +0200 Subject: [PATCH 4/8] Add translations for buttons --- .../src/components/AssetInput/AssetInputBrowseInput.tsx | 6 +++++- .../src/components/AssetInput/AssetInputUploadInput.tsx | 5 ++++- packages/components/src/locales/de-CH.ts | 2 ++ packages/components/src/locales/de-DE.ts | 2 ++ packages/components/src/locales/en-US.ts | 2 ++ packages/components/src/locales/es-ES.ts | 2 ++ packages/components/src/locales/fr-CH.ts | 2 ++ packages/components/src/locales/fr-FR.ts | 2 ++ packages/components/src/locales/it-CH.ts | 2 ++ packages/components/src/locales/it-IT.ts | 2 ++ packages/components/src/locales/nl-NL.ts | 2 ++ packages/components/src/locales/pl-PL.ts | 2 ++ packages/components/src/locales/pt-PT.ts | 2 ++ 13 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx b/packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx index 19e707ce3c..743106f7b3 100644 --- a/packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx +++ b/packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx @@ -2,6 +2,8 @@ import { IconImageStack } from '@frontify/fondue-icons'; +import { useTranslation } from '#/hooks/useTranslation'; + import { Button } from '../Button/Button'; type AssetInputBrowseInputProps = { @@ -9,10 +11,12 @@ type AssetInputBrowseInputProps = { }; export const AssetInputBrowseInput = ({ onBrowse }: AssetInputBrowseInputProps) => { + const { t } = useTranslation(); + return ( ); }; diff --git a/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx b/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx index 2b45709b98..c56b54beb6 100644 --- a/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx +++ b/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx @@ -3,6 +3,8 @@ import { IconArrowCircleUp } from '@frontify/fondue-icons'; import { useCallback, useId, useRef } from 'react'; +import { useTranslation } from '#/hooks/useTranslation'; + import { Button } from '../Button/Button'; type AssetInputUploadInputProps = { @@ -18,6 +20,7 @@ export const AssetInputUploadInput = ({ }: AssetInputUploadInputProps) => { const fileInputRef = useRef(null); const id = useId(); + const { t } = useTranslation(); const openFileUploadDialog = useCallback(() => { if (fileInputRef.current) { fileInputRef.current.click(); @@ -28,7 +31,7 @@ export const AssetInputUploadInput = ({ <> Date: Tue, 19 May 2026 12:13:48 +0200 Subject: [PATCH 5/8] Fix placeholder and button related comments --- .../AssetInput/AssetInput.stories.tsx | 4 +-- .../AssetInput/AssetInputBrowseInput.tsx | 4 +-- .../AssetInput/AssetInputPlaceholder.tsx | 35 +++++-------------- .../AssetInput/AssetInputUploadInput.tsx | 19 +++++----- .../AssetInput/__tests__/AssetInput.ct.tsx | 4 +-- .../AssetInput/styles/asset-input.module.scss | 21 ++++++++--- 6 files changed, 41 insertions(+), 46 deletions(-) diff --git a/packages/components/src/components/AssetInput/AssetInput.stories.tsx b/packages/components/src/components/AssetInput/AssetInput.stories.tsx index c345f90c8e..151f2f2f6d 100644 --- a/packages/components/src/components/AssetInput/AssetInput.stories.tsx +++ b/packages/components/src/components/AssetInput/AssetInput.stories.tsx @@ -57,7 +57,7 @@ export const Placeholder: Story = { }, render: ({ acceptFileType }) => ( - + ), @@ -70,7 +70,7 @@ export const PlaceholderOnlyUpload: Story = { }, render: ({ acceptFileType }) => ( - + ), }; diff --git a/packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx b/packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx index 743106f7b3..79e86d5eb3 100644 --- a/packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx +++ b/packages/components/src/components/AssetInput/AssetInputBrowseInput.tsx @@ -6,7 +6,7 @@ import { useTranslation } from '#/hooks/useTranslation'; import { Button } from '../Button/Button'; -type AssetInputBrowseInputProps = { +export type AssetInputBrowseInputProps = { onBrowse: () => void; }; @@ -14,7 +14,7 @@ export const AssetInputBrowseInput = ({ onBrowse }: AssetInputBrowseInputProps) const { t } = useTranslation(); return ( - diff --git a/packages/components/src/components/AssetInput/AssetInputPlaceholder.tsx b/packages/components/src/components/AssetInput/AssetInputPlaceholder.tsx index 1031bbe40b..d8c9567a1b 100644 --- a/packages/components/src/components/AssetInput/AssetInputPlaceholder.tsx +++ b/packages/components/src/components/AssetInput/AssetInputPlaceholder.tsx @@ -1,41 +1,24 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ -import { Children, isValidElement, useMemo, type ReactNode } from 'react'; +import { type ReactElement } from 'react'; import { Flex } from '../Flex/Flex'; -import { AssetInputBrowseInput } from './AssetInputBrowseInput'; -import { AssetInputUploadInput } from './AssetInputUploadInput'; +import { type AssetInputBrowseInputProps } from './AssetInputBrowseInput'; +import { type AssetInputUploadInputProps } from './AssetInputUploadInput'; import styles from './styles/asset-input.module.scss'; -export type AssetInputPlaceholderProps = { children: ReactNode }; +type AssetInputAction = ReactElement; -export const AssetInputPlaceholder = ({ children }: AssetInputPlaceholderProps) => { - const inputs = useMemo(() => { - const inputs: ReactNode[] = []; - Children.forEach(children, (child) => { - if (!isValidElement(child)) { - return; - } - if (child.type === AssetInputUploadInput || child.type === AssetInputBrowseInput) { - inputs.push(child); - } - }); - return inputs; - }, [children]); - - if (inputs.length === 0) { - return null; - } - - const [firstInput, secondInput] = inputs; +export type AssetInputPlaceholderProps = { + children: AssetInputAction | [AssetInputAction, AssetInputAction]; +}; +export const AssetInputPlaceholder = ({ children }: AssetInputPlaceholderProps) => { return (
- {firstInput} - {secondInput ?
: null} - {secondInput} + {children}
); diff --git a/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx b/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx index c56b54beb6..6dbe1c5e9a 100644 --- a/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx +++ b/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx @@ -1,47 +1,46 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ import { IconArrowCircleUp } from '@frontify/fondue-icons'; -import { useCallback, useId, useRef } from 'react'; +import { ChangeEvent, useRef } from 'react'; import { useTranslation } from '#/hooks/useTranslation'; import { Button } from '../Button/Button'; -type AssetInputUploadInputProps = { +export type AssetInputUploadInputProps = { acceptFileType?: string; allowMultiple?: boolean; - onFileChange: (event: React.ChangeEvent) => void; + onSelect: (event: ChangeEvent) => void; }; export const AssetInputUploadInput = ({ acceptFileType, allowMultiple = false, - onFileChange, + onSelect, }: AssetInputUploadInputProps) => { const fileInputRef = useRef(null); - const id = useId(); const { t } = useTranslation(); - const openFileUploadDialog = useCallback(() => { + + const openFileUploadDialog = () => { if (fileInputRef.current) { fileInputRef.current.click(); } - }, [fileInputRef]); + }; return ( <> - ); diff --git a/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx b/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx index 5f73ece68d..d32600e78b 100644 --- a/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx +++ b/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx @@ -15,7 +15,7 @@ test('should render placeholder with upload and browse actions', async ({ mount const onFileChange = sinon.spy(); const wrapper = await mount( - + , ); @@ -36,7 +36,7 @@ test('should render placeholder with upload and browse actions', async ({ mount test('should render placeholder with upload only', async ({ mount }) => { const wrapper = await mount( - {}} /> + {}} /> , ); await expect(wrapper.getByRole('button', { name: /Upload/i })).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 index b88c7ca7f6..1348243a00 100644 --- a/packages/components/src/components/AssetInput/styles/asset-input.module.scss +++ b/packages/components/src/components/AssetInput/styles/asset-input.module.scss @@ -139,11 +139,24 @@ .placeholder { border: var(--border-width-default) dashed var(--color-line-subtle); border-radius: var(--border-radius-medium); -} -.separator { - width: var(--border-width-default); - background-color: var(--color-line-subtle); + [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); + } } .mainContainer { From fee764616c773f128e7a4eb4969b63f423d2984e Mon Sep 17 00:00:00 2001 From: fulopdaniel Date: Tue, 19 May 2026 12:17:39 +0200 Subject: [PATCH 6/8] fix lint --- .../src/components/AssetInput/AssetInputUploadInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx b/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx index 6dbe1c5e9a..99f1c06e54 100644 --- a/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx +++ b/packages/components/src/components/AssetInput/AssetInputUploadInput.tsx @@ -1,7 +1,7 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ import { IconArrowCircleUp } from '@frontify/fondue-icons'; -import { ChangeEvent, useRef } from 'react'; +import { type ChangeEvent, useRef } from 'react'; import { useTranslation } from '#/hooks/useTranslation'; From 3f8d1eebbae804711c449f7d79449a4e9038a926 Mon Sep 17 00:00:00 2001 From: fulopdaniel Date: Tue, 19 May 2026 12:56:38 +0200 Subject: [PATCH 7/8] Refactor Root to use grid template areas --- .../AssetInput/AssetInput.metadata.json | 2 +- .../AssetInput/AssetInput.stories.tsx | 61 +++--- .../src/components/AssetInput/AssetInput.tsx | 2 + .../AssetInput/AssetInputMetadata.tsx | 4 +- .../AssetInput/AssetInputMetadataItem.tsx | 13 ++ .../components/AssetInput/AssetInputRoot.tsx | 56 +----- .../AssetInput/__tests__/AssetInput.ct.tsx | 40 ++-- .../AssetInput/styles/asset-input.module.scss | 188 +++++++++--------- 8 files changed, 180 insertions(+), 186 deletions(-) create mode 100644 packages/components/src/components/AssetInput/AssetInputMetadataItem.tsx diff --git a/packages/components/src/components/AssetInput/AssetInput.metadata.json b/packages/components/src/components/AssetInput/AssetInput.metadata.json index 8e96d9f097..fe02d965c5 100644 --- a/packages/components/src/components/AssetInput/AssetInput.metadata.json +++ b/packages/components/src/components/AssetInput/AssetInput.metadata.json @@ -1,7 +1,7 @@ { "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 one or more AssetInput.Metadata items. Control the open state with isOpen and onPress on AssetInput.Root, typically to toggle an attached Dropdown for asset operations.", + "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"], diff --git a/packages/components/src/components/AssetInput/AssetInput.stories.tsx b/packages/components/src/components/AssetInput/AssetInput.stories.tsx index 151f2f2f6d..381d9c1236 100644 --- a/packages/components/src/components/AssetInput/AssetInput.stories.tsx +++ b/packages/components/src/components/AssetInput/AssetInput.stories.tsx @@ -26,6 +26,7 @@ const meta = { PreviewLoading: AssetInput.PreviewLoading, Title: AssetInput.Title, Metadata: AssetInput.Metadata, + MetadataItem: AssetInput.MetadataItem, }, argTypes: { acceptFileType: { @@ -101,13 +102,15 @@ export const AssetInputRoot: Story = { foo1 - - - Uploaded - + + + + Uploaded + + + JPG + 2000 bytes - JPG - 2000 bytes ); }, @@ -129,13 +132,15 @@ export const AssetInputRootIcon: Story = { foo1 - - - Uploaded - + + + + Uploaded + + + JPG + 2000 bytes - JPG - 2000 bytes ); }, @@ -155,13 +160,15 @@ export const AssetInputRootLoading: Story = { foo1 - - - Uploaded - + + + + Uploaded + + + JPG + 2000 bytes - JPG - 2000 bytes ); }, @@ -185,7 +192,9 @@ export const AssetInputRootMultipleImages: Story = { 3 assets - 2 locations + + 2 locations + ); }, @@ -207,13 +216,15 @@ export const AssetInputAsDropdownTrigger: Story = { foo1 - - - Uploaded - + + + + Uploaded + + + JPG + 2000 bytes - JPG - 2000 bytes diff --git a/packages/components/src/components/AssetInput/AssetInput.tsx b/packages/components/src/components/AssetInput/AssetInput.tsx index ec25339140..a4708d11f8 100644 --- a/packages/components/src/components/AssetInput/AssetInput.tsx +++ b/packages/components/src/components/AssetInput/AssetInput.tsx @@ -4,6 +4,7 @@ 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'; @@ -25,5 +26,6 @@ export const AssetInput = { PreviewIcon: AssetInputPreviewIcon, Title: AssetInputTitle, Metadata: AssetInputMetadata, + MetadataItem: AssetInputMetadataItem, PreviewLoading: AssetInputPreviewLoading, }; diff --git a/packages/components/src/components/AssetInput/AssetInputMetadata.tsx b/packages/components/src/components/AssetInput/AssetInputMetadata.tsx index 14d2220578..8737c3e5df 100644 --- a/packages/components/src/components/AssetInput/AssetInputMetadata.tsx +++ b/packages/components/src/components/AssetInput/AssetInputMetadata.tsx @@ -4,10 +4,10 @@ import { type ReactNode } from 'react'; import styles from './styles/asset-input.module.scss'; -type AssetInputMetadataProps = { +export type AssetInputMetadataProps = { children: ReactNode; }; export const AssetInputMetadata = ({ children }: AssetInputMetadataProps) => { - return {children}; + 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/AssetInputRoot.tsx b/packages/components/src/components/AssetInput/AssetInputRoot.tsx index 5c4c887dd7..daf505fa85 100644 --- a/packages/components/src/components/AssetInput/AssetInputRoot.tsx +++ b/packages/components/src/components/AssetInput/AssetInputRoot.tsx @@ -1,19 +1,16 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ import { IconCaretDown } from '@frontify/fondue-icons'; -import { Children, isValidElement, useMemo, type ForwardedRef, type ReactNode } from 'react'; +import { type ForwardedRef, type ReactNode } from 'react'; -import { AssetInputMetadata } from './AssetInputMetadata'; -import { AssetInputPreview } from './AssetInputPreview'; -import { AssetInputTitle } from './AssetInputTitle'; import styles from './styles/asset-input.module.scss'; export type AssetInputOrientation = 'horizontal' | 'vertical'; export type AssetInputRootProps = { children: ReactNode; - orientation: AssetInputOrientation; - isOpen: boolean; + orientation?: AssetInputOrientation; + isOpen?: boolean; onPress?: () => void; }; @@ -21,37 +18,6 @@ export const AssetInputRoot = ( { children, orientation = 'horizontal', isOpen = false, onPress, ...props }: AssetInputRootProps, ref: ForwardedRef, ) => { - const { parts, metadata } = useMemo(() => { - const parts: Record<'preview' | 'title', ReactNode> = { - preview: null, - title: null, - }; - const metadataItems: { key: number; child: ReactNode }[] = []; - Children.forEach(children, (child, index) => { - if (!isValidElement(child)) { - return; - } - if (child.type === AssetInputPreview) { - parts.preview = child; - } - if (child.type === AssetInputTitle) { - parts.title = child; - } - if (child.type === AssetInputMetadata) { - metadataItems.push({ key: index, child }); - } - }); - const metadata = metadataItems.flatMap((meta, index) => - index === 0 - ? [meta.child] - : [
, meta.child], - ); - return { parts, metadata }; - }, [children]); - - const { title, preview } = parts; - const hasMetadata = metadata.length > 0; - return ( ); diff --git a/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx b/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx index d32600e78b..aa6536da86 100644 --- a/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx +++ b/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx @@ -86,13 +86,15 @@ for (const orientation of ['horizontal', 'vertical'] as const) { foo1 - - - Uploaded - + + + + Uploaded + + + JPG + 2000 bytes - JPG - 2000 bytes , ); const root = wrapper.getByRole('button', { name: /foo1/ }); @@ -116,9 +118,11 @@ for (const orientation of ['horizontal', 'vertical'] as const) { foo1 - Uploaded - JPG - 2000 bytes + + Uploaded + JPG + 2000 bytes + , ); const root = wrapper.getByRole('button', { name: /foo1/ }); @@ -141,13 +145,15 @@ for (const orientation of ['horizontal', 'vertical'] as const) { foo1 - - - Uploaded - + + + + Uploaded + + + JPG + 2000 bytes - JPG - 2000 bytes , ); const root = wrapper.getByRole('button', { name: /foo1/ }); @@ -170,7 +176,9 @@ for (const orientation of ['horizontal', 'vertical'] as const) { 3 assets - 2 locations + + 2 locations + , ); const root = wrapper.getByRole('button', { name: /3 assets/ }); diff --git a/packages/components/src/components/AssetInput/styles/asset-input.module.scss b/packages/components/src/components/AssetInput/styles/asset-input.module.scss index 1348243a00..d10f9c7da6 100644 --- a/packages/components/src/components/AssetInput/styles/asset-input.module.scss +++ b/packages/components/src/components/AssetInput/styles/asset-input.module.scss @@ -7,7 +7,7 @@ .root { width: 100%; overflow: hidden; - display: flex; + display: grid; align-items: center; font-family: var(--typography-font-family-primary); @@ -31,62 +31,66 @@ border-color: var(--color-line-strong); } - &[data-open='true'] { - .caret { - transform: rotate(180deg); - } + &[data-open='true'] .caret svg { + transform: rotate(180deg); } - &[data-orientation='vertical'] { - flex-direction: column; + &[data-orientation='horizontal'] { + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto; + grid-template-areas: + 'preview title caret' + 'preview metadata caret'; - .preview { - height: sizeToken.get(32); - width: 100%; - border-right: none; - border-bottom: var(--border-width-default) solid var(--color-line-mid); - } + &:has([data-multiple-parts='true']) { + .title { + font-size: var(--typography-font-size-large); + font-weight: 700; + } - &:has([data-multiple-parts='false']) { - .previewIcon { - svg { - width: sizeToken.get(8); - height: sizeToken.get(8); - } + .metadata { + font-size: var(--typography-font-size-small); + margin-top: sizeToken.get(0.5); } - } - &:has([data-multiple-parts='true']) { - .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; + .caret svg { + width: sizeToken.get(6); + height: sizeToken.get(6); } } } - &[data-orientation='horizontal']:has([data-multiple-parts='true']) { - .title { - font-size: var(--typography-font-size-large); - font-weight: 700; + &[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); } - .metadata { - font-size: var(--typography-font-size-small); - margin-top: sizeToken.get(0.5); + &:has([data-multiple-parts='false']) .previewIcon svg { + width: sizeToken.get(8); + height: sizeToken.get(8); } - .caret { - svg { - width: sizeToken.get(6); - height: sizeToken.get(6); - } + &:has([data-multiple-parts='true']) .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; @@ -136,81 +140,81 @@ } } -.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); - } +.title { + grid-area: title; + min-width: 0; + padding-left: sizeToken.get(4); + padding-right: sizeToken.get(4); + align-self: end; } -.mainContainer { +.metadata { + grid-area: metadata; display: flex; - width: 100%; + gap: sizeToken.get(1); align-items: center; -} - -.contentContainer { - display: flex; - flex-direction: column; + flex-shrink: 0; + min-width: 0; padding-left: sizeToken.get(4); padding-right: sizeToken.get(4); - min-width: 0; + white-space: nowrap; + overflow: hidden; + align-self: start; } -.caretContainer { - display: flex; +.metadataItem { + color: var(--color-secondary-default); + font-size: var(--typography-font-size-x-small); + display: inline-flex; align-items: center; - justify-content: center; - padding: sizeToken.get(4); - margin-left: auto; - flex-shrink: 0; - background-color: var(--color-surface-default); + + &: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 { - @include transitions.transition-transform; + 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); } } -.metadataContainer { - display: flex; - gap: sizeToken.get(1); - white-space: nowrap; - overflow: hidden; - align-items: center; - flex-shrink: 0; -} +.placeholder { + border: var(--border-width-default) dashed var(--color-line-subtle); + border-radius: var(--border-radius-medium); -.metadata { - color: var(--color-secondary-default); - font-size: var(--typography-font-size-x-small); -} + [data-asset-input-action='upload'] { + order: 0; + } -.metaSeparator { - width: sizeToken.get(1); - height: sizeToken.get(1); - background-color: var(--color-container-secondary-default); - border-radius: 50%; + [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); + } } From 79ac3bf5d65b248aaaeb8f0bca502f50f7401d30 Mon Sep 17 00:00:00 2001 From: fulopdaniel Date: Tue, 19 May 2026 13:52:00 +0200 Subject: [PATCH 8/8] Simplify preview --- .../AssetInput/AssetInputPreview.tsx | 47 +++++++------------ .../AssetInput/AssetInputPreviewIcon.tsx | 2 +- .../AssetInput/AssetInputPreviewImage.tsx | 2 +- .../AssetInput/AssetInputPreviewLoading.tsx | 2 +- .../AssetInput/__tests__/AssetInput.ct.tsx | 1 - .../AssetInput/styles/asset-input.module.scss | 10 ++-- 6 files changed, 26 insertions(+), 38 deletions(-) diff --git a/packages/components/src/components/AssetInput/AssetInputPreview.tsx b/packages/components/src/components/AssetInput/AssetInputPreview.tsx index a247d0369a..1b0d296757 100644 --- a/packages/components/src/components/AssetInput/AssetInputPreview.tsx +++ b/packages/components/src/components/AssetInput/AssetInputPreview.tsx @@ -1,43 +1,32 @@ /* (c) Copyright Frontify Ltd., all rights reserved. */ -import { Children, isValidElement, useMemo, type ReactNode } from 'react'; +import { Children, type ReactElement } from 'react'; -import { AssetInputPreviewIcon } from './AssetInputPreviewIcon'; -import { AssetInputPreviewImage } from './AssetInputPreviewImage'; -import { AssetInputPreviewLoading } from './AssetInputPreviewLoading'; +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: ReactNode; + children: AssetInputPreviewPart | AssetInputPreviewPart[]; }; export const AssetInputPreview = ({ children }: AssetInputPreviewProps) => { - const previewParts = useMemo(() => { - const previewParts: ReactNode[] = []; - Children.forEach(children, (child) => { - if (!isValidElement(child)) { - return; - } - if ( - child.type === AssetInputPreviewImage || - child.type === AssetInputPreviewIcon || - child.type === AssetInputPreviewLoading - ) { - previewParts.push(child); - } - }); - if (previewParts.length > 1 && previewParts.length < 4) { - const emptyParts = Array.from({ length: 4 - previewParts.length }, (_, i) => ( -
- )); - previewParts.push(...emptyParts); - } - return previewParts.slice(0, 4); - }, [children]); + const parts = Children.toArray(children).slice(0, MAX_PREVIEW_PARTS); + const emptySlotCount = parts.length > 1 ? MAX_PREVIEW_PARTS - parts.length : 0; return ( -
1} className={styles.preview}> - {previewParts} +
+ {parts} + {Array.from({ length: emptySlotCount }, (_, index) => ( +
+ ))}
); }; diff --git a/packages/components/src/components/AssetInput/AssetInputPreviewIcon.tsx b/packages/components/src/components/AssetInput/AssetInputPreviewIcon.tsx index 17ce61c87a..86f303538f 100644 --- a/packages/components/src/components/AssetInput/AssetInputPreviewIcon.tsx +++ b/packages/components/src/components/AssetInput/AssetInputPreviewIcon.tsx @@ -4,7 +4,7 @@ import { type ReactNode } from 'react'; import styles from './styles/asset-input.module.scss'; -type AssetInputPreviewIconProps = { +export type AssetInputPreviewIconProps = { children: ReactNode; }; diff --git a/packages/components/src/components/AssetInput/AssetInputPreviewImage.tsx b/packages/components/src/components/AssetInput/AssetInputPreviewImage.tsx index 17603a7346..6412364a64 100644 --- a/packages/components/src/components/AssetInput/AssetInputPreviewImage.tsx +++ b/packages/components/src/components/AssetInput/AssetInputPreviewImage.tsx @@ -2,7 +2,7 @@ import styles from './styles/asset-input.module.scss'; -type AssetInputPreviewImageProps = { +export type AssetInputPreviewImageProps = { src: string; alt?: string; }; diff --git a/packages/components/src/components/AssetInput/AssetInputPreviewLoading.tsx b/packages/components/src/components/AssetInput/AssetInputPreviewLoading.tsx index 08e44f2b83..29ad65a760 100644 --- a/packages/components/src/components/AssetInput/AssetInputPreviewLoading.tsx +++ b/packages/components/src/components/AssetInput/AssetInputPreviewLoading.tsx @@ -4,7 +4,7 @@ import { LoadingCircle, type LoadingCircleProps } from '../LoadingCircle/Loading import styles from './styles/asset-input.module.scss'; -type AssetInputPreviewLoadingProps = { +export type AssetInputPreviewLoadingProps = { size?: LoadingCircleProps['size']; }; diff --git a/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx b/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx index aa6536da86..663e2592c5 100644 --- a/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx +++ b/packages/components/src/components/AssetInput/__tests__/AssetInput.ct.tsx @@ -187,6 +187,5 @@ for (const orientation of ['horizontal', 'vertical'] as const) { await expect(root).toContainText('2 locations'); await expect(root.locator('img')).toHaveCount(2); await expect(root.getByTestId('fondue-loading-circle-content')).toBeVisible(); - await expect(root.locator('[data-multiple-parts="true"]')).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 index d10f9c7da6..2703b19555 100644 --- a/packages/components/src/components/AssetInput/styles/asset-input.module.scss +++ b/packages/components/src/components/AssetInput/styles/asset-input.module.scss @@ -42,7 +42,7 @@ 'preview title caret' 'preview metadata caret'; - &:has([data-multiple-parts='true']) { + &:has(.preview > :nth-child(2)) { .title { font-size: var(--typography-font-size-large); font-weight: 700; @@ -75,12 +75,12 @@ border-bottom: var(--border-width-default) solid var(--color-line-mid); } - &:has([data-multiple-parts='false']) .previewIcon svg { + &:not(:has(.preview > :nth-child(2))) .previewIcon svg { width: sizeToken.get(8); height: sizeToken.get(8); } - &:has([data-multiple-parts='true']) .preview { + &: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); @@ -101,7 +101,7 @@ align-items: center; justify-content: center; - &[data-multiple-parts='false'] { + &:not(:has(> :nth-child(2))) { width: sizeToken.get(14); .previewImage { @@ -109,7 +109,7 @@ } } - &[data-multiple-parts='true'] { + &:has(> :nth-child(2)) { display: grid; grid-template-columns: sizeToken.get(11) sizeToken.get(11); grid-template-rows: sizeToken.get(11) sizeToken.get(11);