diff --git a/docs/app/views/examples/components/upload_card/_preview.html.erb b/docs/app/views/examples/components/upload_card/_preview.html.erb index 7bfa6b0b46..6070416a89 100644 --- a/docs/app/views/examples/components/upload_card/_preview.html.erb +++ b/docs/app/views/examples/components/upload_card/_preview.html.erb @@ -1,42 +1,109 @@ -

Initial State

+<% + dropdown_menu_items = [ + { + value: "Upload file", + }, { + value: "Select recent file", + }, { + style: "danger", + modifiers: ["border-before"], + value: "Remove image", + } + ] +%> + +

Default State (no file selected)

+ <%= sage_component SageUploadCard, { - selection_label: "Select a file", - selection_subtext: "Recommended dimensions or file size requirements", -} %> + id: "upload-card-default", + selection_label: "Select file", +} do %> + <% content_for :sage_upload_card_instructions do %> +

Upload a JPEG

+ <% end %> +<% end %> + +

Selected File State

-

Selected File State

+

Restrict accepted file types using accepted_file_types.

<%= sage_component SageUploadCard, { accepted_files: [ - {name: "contacts.csv"}, + {name: "my-image-file.jpg"}, ], file_selected: true, + accepted_file_types: ["image/jpg"], + selection_preview: "https://placekitten.com/360", selection_label: "Replace file", - selection_subtext: "Recommended dimensions or file size requirements", -} %> + id: "upload-card-selected", +} do %> + <% content_for :sage_upload_card_instructions do %> +

Upload a JPEG this is a test

+ <% end %> +<% end %> -

Error State

+ +

Vertical (stacked) layout

+

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.

<%= sage_component SageUploadCard, { - has_error: true, - selection_label: "Select a file", - selection_subtext: "Recommended dimensions or file size requirements", - errors: [ - {text: "This is the error message."}, - ] -} %> + accepted_files: [ + {name: "my-image-file.jpg"}, + ], + file_selected: true, + selection_preview: "https://placekitten.com/360", + stack_layout: true, + id: "upload-card-stack", + selection_label: "Edit file", +} do %> + <% content_for :sage_upload_card_instructions do %> +

Recommended dimensions or file size requirements

+ <% end %> +<% end %> -

Selected Error State

+

Custom content areas

+

Instructions area

+

The sage_upload_card_instructions slot provides an area for extended instructions and custom markup.

+

Actions area

+

Use the sage_upload_card_actions slot to replace the default button and display custom components, such as dropdowns.

+

NOTE: a file input field and label are included by default in the base component, as seen in the examples above. When applying a custom file input with `sage_upload_card_actions`, set custom_file_input_field to true to remove these defaults.

<%= sage_component SageUploadCard, { accepted_files: [ - {name: "contacts.csv"}, + {name: "fluffy-kitteh.jpg"}, ], + custom_file_input_field: true, file_selected: true, + selection_preview: "https://placekitten.com/360", + id: "upload-card-dropdown" +} do %> + <% content_for :sage_upload_card_actions do %> + <%= sage_component SageDropdown, { items: dropdown_menu_items } do %> + <% content_for :sage_dropdown_trigger, flush: true do %> + <%= sage_component SageButton, { + style: "secondary", + icon: { name: "caret-down", style: "right" }, + value: "Edit file", + } %> + <% end %> + <% end %> + <% end %> +<% end %> + + +

Error State

+ +<%= sage_component SageUploadCard, { has_error: true, - selection_label: "Replace file", - selection_subtext: "Recommended dimensions or file size requirements", + id: "upload-card-error", + selection_label: "Select a file", errors: [ - {text: "This is the error message."}, + {text: "This is an error message."}, ] -} %> \ No newline at end of file +} do %> + <% content_for :sage_upload_card_instructions do %> +

Recommended dimensions or file size requirements

+ <% end %> +<% end %> diff --git a/docs/lib/sage_rails/app/sage_components/sage_upload_card.rb b/docs/lib/sage_rails/app/sage_components/sage_upload_card.rb index 965b0dfacf..44b0bbf6b6 100644 --- a/docs/lib/sage_rails/app/sage_components/sage_upload_card.rb +++ b/docs/lib/sage_rails/app/sage_components/sage_upload_card.rb @@ -4,12 +4,20 @@ class SageUploadCard < SageComponent name: [:optional, NilClass, String], size: [:optional, NilClass, String], ]]], + accepted_file_types: [:optional, NilClass, Array], + custom_file_input_field: [:optional, NilClass, TrueClass], errors: [:optional, NilClass, [[ text: [:optional, NilClass, String], ]]], file_selected: [:optional, NilClass, TrueClass], has_error: [:optional, NilClass, TrueClass], + id: String, + name: [:optional, NilClass, String], + selection_preview: [:optional, NilClass, String], selection_label: [:optional, NilClass, String], - selection_subtext: [:optional, NilClass, String], + stack_layout:[:optional, NilClass, TrueClass], }) + def sections + %w(upload_card_instructions upload_card_preview upload_card_actions) + end end diff --git a/docs/lib/sage_rails/app/views/sage_components/_sage_upload_card.html.erb b/docs/lib/sage_rails/app/views/sage_components/_sage_upload_card.html.erb index 11bc66ec1f..da53152a94 100644 --- a/docs/lib/sage_rails/app/views/sage_components/_sage_upload_card.html.erb +++ b/docs/lib/sage_rails/app/views/sage_components/_sage_upload_card.html.erb @@ -1,42 +1,56 @@ -
-> +<% + upload_card_classes = ['sage-upload-card'] + upload_card_classes << 'sage-upload-card--stack-only' if component.stack_layout == true + upload_card_classes << 'sage-upload-card--selected' if component.file_selected.present? + upload_card_classes << 'sage-upload-card--error' if component.has_error.present? + upload_card_classes << component.generated_css_classes + upload_card_input_label = component.selection_label.present? ? component.selection_label : "Select a file" + upload_card_image_types = component.accepted_file_types.present? ? component.accepted_file_types.join(",") : "image/*" + + upload_card_preview_image_options = { + class: "sage-upload-card__preview", + alt: "" + } +%> +
" <%= component.generated_html_attributes.html_safe %>>
- - <% if component.file_selected.present? %> + <% unless component.custom_file_input_field %> + + <% end %> + <% if component.selection_preview.present? %> + <%= image_tag(component.selection_preview, upload_card_preview_image_options) %> + <% elsif content_for? :sage_upload_card_preview %> + <%= content_for :sage_upload_card_preview %> + <% else %> <%= sage_component SageIconCard, { color: "draft", css_classes: "sage-upload-card__preview", icon: "image", - label: "Upload graphic", size: "2xl", } %> -
-

<%= component.accepted_files[0][:name] %>

- <%= sage_component SageButton, { - style: "primary", - subtle: true, - value: component.selection_label - } %> -

<%= component.selection_subtext %>

-
- <% else %> -
- - <%= sage_component SageButton, { - style: "primary", - subtle: true, - value: component.selection_label - } %> -

<%= component.selection_subtext %>

-
<% end %> +
+
+ <% if component.file_selected.present? || component.selection_preview.present? %> +

+ <%= component.accepted_files[0][:name] %> +

+ <% end %> + <% if content_for? :sage_upload_card_instructions %> +
+ <%= content_for :sage_upload_card_instructions %> +
+ <% end %> +
+ <% if component.selection_label.present? && !component.custom_file_input_field %> + + <% elsif content_for? :sage_upload_card_actions %> + <% if !component.custom_file_input_field %> + + <% end %> + <%= content_for :sage_upload_card_actions %> + <% end %> +
<% if component.errors.present? %>
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 ? ( + {previewImage.alt} + ) : ( + + ) + ); + + 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 ; + }; + 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}

- -

- {`File size: ${size}B`} -

-
- ); - })} + return ( + +

+ {name} +

+ {renderLabel()} +
+ ); + })} +
) : ( -
- - -

- {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: ( + + ), + 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: ( + + ), + 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, };