Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rich-rice-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@frontify/fondue-components": patch
---

feat(AssetInput): add AssetInput component
Original file line number Diff line number Diff line change
@@ -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"]
}
251 changes: 251 additions & 0 deletions packages/components/src/components/AssetInput/AssetInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<StoryArgs>;
export default meta;

type Story = StoryObj<StoryArgs>;

export const Placeholder: Story = {
name: 'Placeholder',
parameters: {
controls: { include: ['acceptFileType'] },
},
render: ({ acceptFileType }) => (
<AssetInput.Placeholder>
<AssetInput.UploadInput acceptFileType={acceptFileType} onSelect={action('onFileChange')} />
<AssetInput.BrowseInput onBrowse={action('onBrowse')} />
</AssetInput.Placeholder>
),
};

export const PlaceholderOnlyUpload: Story = {
name: 'Placeholder - Upload Only',
parameters: {
controls: { include: ['acceptFileType'] },
},
render: ({ acceptFileType }) => (
<AssetInput.Placeholder>
<AssetInput.UploadInput acceptFileType={acceptFileType} onSelect={action('onFileChange')} />
</AssetInput.Placeholder>
),
};

export const PlaceholderOnlyBrowse: Story = {
name: 'Placeholder - Browse Only',
parameters: {
controls: { include: [] },
},
render: () => (
<AssetInput.Placeholder>
<AssetInput.BrowseInput onBrowse={action('onBrowse')} />
</AssetInput.Placeholder>
),
};

export const AssetInputRoot: Story = {
name: 'Input - Single - Image',
parameters: {
controls: { include: ['orientation'] },
},
render: ({ orientation }) => {
const [isOpen, setIsOpen] = useState(false);

Check warning on line 97 in packages/components/src/components/AssetInput/AssetInput.stories.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

React Hook "useState" is called in function "render" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use".

See more on https://sonarcloud.io/project/issues?id=Frontify_arcade&issues=AZ4hY41EQhU5v8xRbEZR&open=AZ4hY41EQhU5v8xRbEZR&pullRequest=2726
return (
<AssetInput.Root orientation={orientation} isOpen={isOpen} onPress={() => setIsOpen(!isOpen)}>
<AssetInput.Preview>
<AssetInput.PreviewImage src="https://picsum.photos/100/150" />
</AssetInput.Preview>
<AssetInput.Title>foo1</AssetInput.Title>
<AssetInput.Metadata>
<AssetInput.MetadataItem>
<Flex align="center" gap={0.5}>
<IconArrowCircleUp size="16" />
Uploaded
</Flex>
</AssetInput.MetadataItem>
<AssetInput.MetadataItem>JPG</AssetInput.MetadataItem>
<AssetInput.MetadataItem>2000 bytes</AssetInput.MetadataItem>
</AssetInput.Metadata>
</AssetInput.Root>
);
},
};

export const AssetInputRootIcon: Story = {
name: 'Input - Single - Icon',
parameters: {
controls: { include: ['orientation'] },
},
render: ({ orientation }) => {
const [isOpen, setIsOpen] = useState(false);

Check warning on line 125 in packages/components/src/components/AssetInput/AssetInput.stories.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

React Hook "useState" is called in function "render" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use".

See more on https://sonarcloud.io/project/issues?id=Frontify_arcade&issues=AZ4hY41EQhU5v8xRbEZS&open=AZ4hY41EQhU5v8xRbEZS&pullRequest=2726
return (
<AssetInput.Root orientation={orientation} isOpen={isOpen} onPress={() => setIsOpen(!isOpen)}>
<AssetInput.Preview>
<AssetInput.PreviewIcon>
<IconMusicNote />
</AssetInput.PreviewIcon>
</AssetInput.Preview>
<AssetInput.Title>foo1</AssetInput.Title>
<AssetInput.Metadata>
<AssetInput.MetadataItem>
<Flex align="center" gap={0.5}>
<IconArrowCircleUp size="16" />
Uploaded
</Flex>
</AssetInput.MetadataItem>
<AssetInput.MetadataItem>JPG</AssetInput.MetadataItem>
<AssetInput.MetadataItem>2000 bytes</AssetInput.MetadataItem>
</AssetInput.Metadata>
</AssetInput.Root>
);
},
};

export const AssetInputRootLoading: Story = {
name: 'Input - Single - Loading',
parameters: {
controls: { include: ['orientation'] },
},
render: ({ orientation }) => {
const [isOpen, setIsOpen] = useState(false);

Check warning on line 155 in packages/components/src/components/AssetInput/AssetInput.stories.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

React Hook "useState" is called in function "render" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use".

See more on https://sonarcloud.io/project/issues?id=Frontify_arcade&issues=AZ4hY41EQhU5v8xRbEZT&open=AZ4hY41EQhU5v8xRbEZT&pullRequest=2726
return (
<AssetInput.Root orientation={orientation} isOpen={isOpen} onPress={() => setIsOpen(!isOpen)}>
<AssetInput.Preview>
<AssetInput.PreviewLoading />
</AssetInput.Preview>
<AssetInput.Title>foo1</AssetInput.Title>
<AssetInput.Metadata>
<AssetInput.MetadataItem>
<Flex align="center" gap={0.5}>
<IconArrowCircleUp size="16" />
Uploaded
</Flex>
</AssetInput.MetadataItem>
<AssetInput.MetadataItem>JPG</AssetInput.MetadataItem>
<AssetInput.MetadataItem>2000 bytes</AssetInput.MetadataItem>
</AssetInput.Metadata>
</AssetInput.Root>
);
},
};

export const AssetInputRootMultipleImages: Story = {
name: 'Input - Multiple - Mixed',
parameters: {
controls: { include: ['orientation'] },
},
render: ({ orientation }) => {
const [isOpen, setIsOpen] = useState(false);

Check warning on line 183 in packages/components/src/components/AssetInput/AssetInput.stories.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

React Hook "useState" is called in function "render" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use".

See more on https://sonarcloud.io/project/issues?id=Frontify_arcade&issues=AZ4hY41EQhU5v8xRbEZU&open=AZ4hY41EQhU5v8xRbEZU&pullRequest=2726
return (
<AssetInput.Root orientation={orientation} isOpen={isOpen} onPress={() => setIsOpen(!isOpen)}>
<AssetInput.Preview>
<AssetInput.PreviewImage src="https://picsum.photos/100/150" />
<AssetInput.PreviewImage src="https://picsum.photos/101/150" />
<AssetInput.PreviewIcon>
<IconMusicNote />
</AssetInput.PreviewIcon>
<AssetInput.PreviewLoading />
</AssetInput.Preview>
<AssetInput.Title>3 assets</AssetInput.Title>
<AssetInput.Metadata>
<AssetInput.MetadataItem>2 locations</AssetInput.MetadataItem>
</AssetInput.Metadata>
</AssetInput.Root>
);
},
};

export const AssetInputAsDropdownTrigger: Story = {
name: 'Input as Dropdown Trigger',
parameters: {
controls: { include: ['orientation'] },
},
render: ({ orientation }) => {
const [isOpen, setIsOpen] = useState(false);

Check warning on line 209 in packages/components/src/components/AssetInput/AssetInput.stories.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

React Hook "useState" is called in function "render" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use".

See more on https://sonarcloud.io/project/issues?id=Frontify_arcade&issues=AZ4_poqpm9XCLaZ1jg1Y&open=AZ4_poqpm9XCLaZ1jg1Y&pullRequest=2726
return (
<Dropdown.Root open={isOpen} onOpenChange={setIsOpen}>
<Dropdown.Trigger asChild>
<AssetInput.Root orientation={orientation} isOpen={isOpen}>
<AssetInput.Preview>
<AssetInput.PreviewImage src="https://picsum.photos/100/150" />
</AssetInput.Preview>
<AssetInput.Title>foo1</AssetInput.Title>
<AssetInput.Metadata>
<AssetInput.MetadataItem>
<Flex align="center" gap={0.5}>
<IconArrowCircleUp size="16" />
Uploaded
</Flex>
</AssetInput.MetadataItem>
<AssetInput.MetadataItem>JPG</AssetInput.MetadataItem>
<AssetInput.MetadataItem>2000 bytes</AssetInput.MetadataItem>
</AssetInput.Metadata>
</AssetInput.Root>
</Dropdown.Trigger>
<Dropdown.Content align="start">
<Dropdown.Group>
<Dropdown.Item onSelect={action('replace')}>
<IconImageStack size={16} />
Replace with browse
</Dropdown.Item>
<Dropdown.Item onSelect={action('replace')}>
<IconArrowCircleUp size={16} />
Replace with upload
</Dropdown.Item>
</Dropdown.Group>
<Dropdown.Group>
<Dropdown.Item emphasis="danger" onSelect={action('remove')}>
<IconTrashBin size={16} />
Delete
</Dropdown.Item>
</Dropdown.Group>
</Dropdown.Content>
</Dropdown.Root>
);
},
};
31 changes: 31 additions & 0 deletions packages/components/src/components/AssetInput/AssetInput.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement, AssetInputRootProps>(AssetInputRoot),
Preview: AssetInputPreview,
PreviewImage: AssetInputPreviewImage,
PreviewIcon: AssetInputPreviewIcon,
Title: AssetInputTitle,
Metadata: AssetInputMetadata,
MetadataItem: AssetInputMetadataItem,
PreviewLoading: AssetInputPreviewLoading,
};
Original file line number Diff line number Diff line change
@@ -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 (
<Button onPress={onBrowse} emphasis="weak" hugWidth={false} data-asset-input-action="browse">
<IconImageStack size={20} />
{t('AssetInput_browse')}
</Button>
);
};
Original file line number Diff line number Diff line change
@@ -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 <div className={styles.metadata}>{children}</div>;
};
Original file line number Diff line number Diff line change
@@ -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 <span className={styles.metadataItem}>{children}</span>;
};
Original file line number Diff line number Diff line change
@@ -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<AssetInputUploadInputProps | AssetInputBrowseInputProps>;

export type AssetInputPlaceholderProps = {
children: AssetInputAction | [AssetInputAction, AssetInputAction];
};

export const AssetInputPlaceholder = ({ children }: AssetInputPlaceholderProps) => {
return (
<div className={styles.placeholder}>
<Flex gap={2} height="100%" p={3}>
Comment thread
fulopdaniel marked this conversation as resolved.
{children}
</Flex>
</div>
);
};
Loading