diff --git a/packages/sage-assets/lib/stylesheets/components/_upload_card.scss b/packages/sage-assets/lib/stylesheets/components/_upload_card.scss
index 377b4a9582..7272556b91 100644
--- a/packages/sage-assets/lib/stylesheets/components/_upload_card.scss
+++ b/packages/sage-assets/lib/stylesheets/components/_upload_card.scss
@@ -7,69 +7,69 @@
$-upload-card-border-radius: sage-border(radius-large);
$-upload-card-border-width: 2;
-$-upload-card-selected-width: rem(200px);
+$-upload-card-error-color: sage-color(red, 300);
+$-upload-card-body-width: rem(200px);
+$-upload-card-body-width-stack: rem(340px);
+$-upload-card-preview-border-radius: sage-border(radius-medium);
$-upload-card-preview-width: rem(32px);
$-upload-card-preview-max-width: rem(190px);
-$-upload-card-preview-max-width-mobile: rem(304px);
+$-upload-card-preview-max-width-stack: rem(292px);
$-upload-card-background: sage-color(white);
+$-upload-card-mobile-breakpoint: 609px;
-.sage-upload-card {
- &:focus {
- outline: none;
- }
+:root {
+ --sage-upload-card-aspect-ratio: 190 / 107;
+ --sage-upload-card-aspect-ratio-stack: 19 / 12;
}
.sage-upload-card__body {
display: flex;
flex-flow: column;
- align-items: center;
- flex: 1;
-
- :not(.sage-upload-card--selected) & {
- justify-content: center;
- align-items: center;
- }
+ align-items: flex-start;
+ justify-content: flex-start;
+ flex: 1 1;
+ gap: sage-spacing();
+ color: sage-color(charcoal, 200);
.sage-upload-card--selected & {
- justify-content: flex-start;
- align-items: flex-start;
+ color: sage-color(charcoal, 300);
}
- .sage-upload-card--selected & {
- flex-basis: $-upload-card-selected-width;
+ .sage-upload-card--error & {
+ color: $-upload-card-error-color;
}
}
.sage-upload-card__dropzone {
display: flex;
+ flex-flow: row wrap;
align-items: center;
justify-content: center;
- gap: sage-spacing(xs);
+ gap: sage-spacing();
padding: sage-spacing(md);
background-color: $-upload-card-background;
border-radius: $-upload-card-border-radius;
border: sage-border(default);
- .sage-upload-card:not(.sage-upload-card--selected) & {
- flex-flow: column;
- }
-
- .sage-upload-card--selected & {
- flex-flow: row wrap;
- gap: sage-spacing();
- }
-
.sage-upload-card.sage-upload-card--error & {
- border-color: sage-color(red, 300);
+ border-color: $-upload-card-error-color;
}
.sage-upload-card.sage-upload-card--error & {
&:hover,
&:focus,
&:focus-within {
- border-color: sage-color(red, 300);
+ border-color: $-upload-card-error-color;
}
}
+
+ .sage-upload-card--stack-only & {
+ flex-flow: column;
+ align-items: flex-start;
+ max-width: $-upload-card-body-width-stack;
+ margin-left: auto;
+ margin-right: auto;
+ }
}
.sage-upload-card__errors {
@@ -78,7 +78,7 @@ $-upload-card-background: sage-color(white);
> p {
@extend %t-sage-body-med;
- color: sage-color(red, 300);
+ color: $-upload-card-error-color;
&:not(:last-child) {
margin-bottom: sage-spacing(2xs);
@@ -91,7 +91,7 @@ $-upload-card-background: sage-color(white);
&:focus,
&:hover {
- color: sage-color(red, 300);
+ color: $-upload-card-error-color;
text-decoration: underline;
outline: 0;
}
@@ -111,32 +111,36 @@ $-upload-card-background: sage-color(white);
@include visually-hidden;
}
+.sage-upload-card__input-label {
+ /* NOTE: label provides keyboard focus but does not allow form interaction */
+ @include sage-focus-ring;
+}
+
.sage-upload-card__preview {
- width: $-upload-card-preview-width;
- max-width: $-upload-card-preview-max-width;
+ width: 100%;
margin-right: 0;
text-align: center;
- border-radius: sage-border(radius-sm);
+ border-radius: $-upload-card-preview-border-radius;
+ aspect-ratio: var(--sage-upload-card-aspect-ratio-stack);
+ object-fit: cover;
- .sage-upload-card--selected & {
- width: 100%;
+ @media (min-width: 610px) {
+ max-width: $-upload-card-preview-max-width;
+ aspect-ratio: var(--sage-upload-card-aspect-ratio);
}
- @media (max-width: 609px) {
- max-width: $-upload-card-preview-max-width-mobile;
+ .sage-upload-card--stack-only & {
+ max-width: $-upload-card-preview-max-width-stack;
+ aspect-ratio: var(--sage-upload-card-aspect-ratio-stack);
}
}
-.sage-upload-card__subtext {
- @extend %t-sage-body-small;
+.sage-upload-card__description {
+ @extend %t-sage-body-med;
- margin-top: sage-spacing(2xs);
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: sage-spacing(sm);
color: sage-color(charcoal, 200);
}
-
-.sage-upload-card__text {
- @extend %t-sage-heading-6;
-
- margin-bottom: sage-spacing(2xs);
- color: sage-color(charcoal, 400);
-}
diff --git a/packages/sage-react/lib/UploadCard/UploadCard.jsx b/packages/sage-react/lib/UploadCard/UploadCard.jsx
index 7d0b026b51..296d17f5a4 100644
--- a/packages/sage-react/lib/UploadCard/UploadCard.jsx
+++ b/packages/sage-react/lib/UploadCard/UploadCard.jsx
@@ -1,20 +1,22 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
-import { Button } from '../Button';
-import { Icon } from '../Icon';
import { IconCard } from '../IconCard';
-import { SageTokens } from '../configs';
export const UploadCard = ({
+ actions,
acceptedFiles,
className,
+ customFileInputField,
errors,
+ id,
inputProps,
+ previewImage,
replaceLabel,
rootProps,
selectionLabel,
selectionSubtext,
+ stacked,
...rest
}) => {
const [filesSelected, updateFilesSelected] = useState(acceptedFiles && acceptedFiles.length > 0);
@@ -28,63 +30,87 @@ export const UploadCard = ({
{
'sage-upload-card--selected': filesSelected,
'sage-upload-card--error': errors,
+ 'sage-upload-card--stack-only': stacked,
}
);
+ const renderDefaultInputField = () => (
+ !customFileInputField && (
+
+ )
+ );
+
+ const renderPreviewImage = () => (
+ previewImage ? (
+
+ )
+ );
+
+ const renderLabel = () => {
+ let classnames = 'visually-hidden';
+ const CustomLabel = () => (
+ <>
+
+ >
+ );
+ if ((selectionLabel || replaceLabel) && customFileInputField === false) {
+ classnames = 'sage-upload-card__input-label sage-btn sage-btn--secondary';
+ } else if (actions !== null) {
+ if (customFileInputField === false) {
+ classnames = 'visually-hidden';
+ }
+ return actions;
+ }
+ return
-
+ {renderDefaultInputField()}
+ {renderPreviewImage()}
{filesSelected ? (
<>
-
- {acceptedFiles.map(({ name, size }, i) => {
- // Limit to one file for now
- if (i > 0) {
- return null;
- }
+
+ {acceptedFiles.map(({ name }, i) => {
+ // Limit to one file for now
+ if (i > 0) {
+ return null;
+ }
- return (
-
- {name}
-
- Replace file
-
-
- {`File size: ${size}B`}
-
-
- );
- })}
+ return (
+
+
+ {name}
+
+ {renderLabel()}
+
+ );
+ })}
+
>
) : (
-
-
-
- {selectionLabel}
-
-
- {selectionSubtext}
-
-
+ <>
+
+
+
+ {selectionSubtext}
+
+ {renderLabel()}
+
+
+ >
)}
{errors && (
@@ -100,25 +126,38 @@ export const UploadCard = ({
UploadCard.defaultProps = {
acceptedFiles: [],
+ actions: null,
className: null,
+ customFileInputField: false,
errors: null,
+ id: null,
inputProps: null,
+ previewImage: null,
replaceLabel: 'Replace file',
rootProps: null,
selectionLabel: 'Select a file',
selectionSubtext: null,
+ stacked: false,
};
UploadCard.propTypes = {
acceptedFiles: PropTypes.arrayOf(PropTypes.shape),
+ actions: PropTypes.node,
className: PropTypes.string,
+ customFileInputField: PropTypes.bool,
errors: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string,
message: PropTypes.string,
})),
+ id: PropTypes.string,
inputProps: PropTypes.shape({}),
+ previewImage: PropTypes.shape({
+ alt: PropTypes.string,
+ src: PropTypes.string
+ }),
replaceLabel: PropTypes.string,
rootProps: PropTypes.shape({}),
selectionLabel: PropTypes.string,
selectionSubtext: PropTypes.string,
+ stacked: PropTypes.bool
};
diff --git a/packages/sage-react/lib/UploadCard/UploadCard.spec.jsx b/packages/sage-react/lib/UploadCard/UploadCard.spec.jsx
new file mode 100644
index 0000000000..88eee32573
--- /dev/null
+++ b/packages/sage-react/lib/UploadCard/UploadCard.spec.jsx
@@ -0,0 +1,130 @@
+require('../test/testHelper');
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import { SageTokens } from '../configs';
+import { Button } from '../Button';
+import { UploadCard } from './UploadCard';
+
+describe('Sage Upload Card', () => {
+ it('renders with correct selection subtext when prop is set', () => {
+ const defaultProps = {
+ selectionSubtext: 'Upload a .csv up to 10KB',
+ };
+
+ render(
);
+
+ const selectionSubtext = document.querySelector('.sage-upload-card__filename');
+ expect(selectionSubtext).toHaveTextContent('Upload a .csv up to 10KB');
+ });
+
+ it('renders with correct selection label when prop is set', () => {
+ const defaultProps = {
+ selectionLabel: 'Select a file',
+ selectionSubtext: 'Upload a .csv up to 10KB',
+ };
+
+ render(
);
+
+ const selectionLabel = document.querySelector('.sage-upload-card__input-label');
+ expect(selectionLabel).toHaveTextContent('Select a file');
+ });
+
+ it('renders with correct replace label when prop is set', () => {
+ const defaultProps = {
+ replaceLabel: 'Replace file',
+ selectionSubtext: 'Upload a .csv up to 10KB',
+ };
+
+ render(
);
+
+ const replaceLabel = document.querySelector('.sage-upload-card__input-label');
+ expect(replaceLabel).toHaveTextContent('Select a file');
+ });
+
+ it('renders with replace label when both label props are set', () => {
+ const defaultProps = {
+ replaceLabel: 'Replace file',
+ selectionLabel: 'Select a file',
+ selectionSubtext: 'Upload a .csv up to 10KB',
+ };
+
+ render(
);
+
+ const replaceLabel = document.querySelector('.sage-upload-card__input-label');
+ expect(replaceLabel).toHaveTextContent('Select a file');
+ });
+
+ it('renders with preview image when prop is set', () => {
+ const defaultProps = {
+ previewImage: {
+ alt: 'cat',
+ src: 'https://placekitten.com/360',
+ },
+ selectionLabel: 'Select a file',
+ selectionSubtext: 'Upload a .csv up to 10KB',
+ };
+
+ render(
);
+
+ const previewImage = document.querySelector('img');
+ expect(previewImage).not.toBeNull();
+ expect(previewImage).toHaveAttribute('alt', 'cat');
+ expect(previewImage).toHaveAttribute('src', 'https://placekitten.com/360');
+ });
+
+ it('renders with actions when prop is set', () => {
+ const defaultProps = {
+ actions: (
+
+ Select a file
+
+ ),
+ customFileInputField: true,
+ selectionSubtext: 'Upload a .csv up to 10KB',
+ };
+
+ render(
);
+
+ const actions = document.querySelector('.sage-btn');
+ expect(actions).not.toBeNull();
+ });
+
+ it('renders with errors when prop is set', () => {
+ const defaultProps = {
+ errors: [
+ {
+ code: '1',
+ message: 'This is the error message.',
+ },
+ ],
+ previewImage: {
+ alt: 'cat',
+ src: 'https://placekitten.com/360',
+ },
+ selectionSubtext: 'Upload a .csv up to 10KB',
+ };
+
+ render(
);
+
+ const errors = document.querySelector('.sage-upload-card__errors');
+ expect(errors).not.toBeNull();
+ expect(errors).toHaveTextContent('This is the error message.');
+ });
+
+ it('renders in stacked layout when prop is set', () => {
+ const defaultProps = {
+ stacked: true,
+ selectionSubtext: 'Upload a .csv up to 10KB',
+ };
+
+ render(
);
+
+ const uploadCard = document.querySelector('.sage-upload-card');
+ expect(uploadCard).toHaveClass('sage-upload-card--stack-only');
+ });
+});
diff --git a/packages/sage-react/lib/UploadCard/UploadCard.story.jsx b/packages/sage-react/lib/UploadCard/UploadCard.story.jsx
index 2515dc6d28..7e3048b6e6 100644
--- a/packages/sage-react/lib/UploadCard/UploadCard.story.jsx
+++ b/packages/sage-react/lib/UploadCard/UploadCard.story.jsx
@@ -1,5 +1,6 @@
import React from 'react';
-import { Grid } from '..';
+import { Button } from '../Button';
+import { Grid, SageTokens } from '..';
import { UploadCard } from './UploadCard';
export default {
@@ -33,20 +34,54 @@ Default.decorators = [
];
export const DefaultWithError = Template.bind({});
+DefaultWithError.parameters = {
+ docs: {
+ description: {
+ story: 'The Upload Card will display an error message if the `errors` prop is set, with the appropriate styling.'
+ },
+ },
+};
DefaultWithError.args = {
errors: [
{
message: 'This is the error message.'
},
- ]
+ ],
+ previewImage: {
+ alt: 'cat',
+ src: 'https://placekitten.com/360'
+ }
};
-export const SelectedWithError = Template.bind({});
-SelectedWithError.args = {
- acceptedFiles: [{ name: '.csv', size: '1 M' }],
- errors: [
- {
- message: 'This is the error message.'
+export const CustomActions = Template.bind({});
+CustomActions.parameters = {
+ docs: {
+ description: {
+ story: 'The `actions` prop is a slot to allow for custom labels and inputs, buttons, dropdowns, etc. to be used in place of the default input field when the `customFileInputField` prop is set to `true.`'
},
- ]
+ },
+};
+CustomActions.args = {
+ actions: (
+
+ Select a file
+
+ ),
+ customFileInputField: true,
+};
+
+export const Stacked = Template.bind({});
+Stacked.parameters = {
+ docs: {
+ description: {
+ story: 'The default layout will adjust from a vertical (stacked) orientation to horizontal depending on available space and/or screen size. Setting `stack_layout` to `true` locks the component to the vertical orientation.'
+ },
+ },
+};
+Stacked.args = {
+ stacked: true,
};