diff --git a/package-lock.json b/package-lock.json index 23ca38b7d..58c508208 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@helpscout/hsds-react", - "version": "3.13.0", + "version": "3.14.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index d21685433..07c1f2d71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@helpscout/hsds-react", - "version": "3.13.0", + "version": "3.14.0", "private": false, "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/src/components/Attachment/Attachment.css.js b/src/components/Attachment/Attachment.css.js index b880ff381..84366f632 100644 --- a/src/components/Attachment/Attachment.css.js +++ b/src/components/Attachment/Attachment.css.js @@ -73,6 +73,11 @@ export const AttachmentUI = styled.a` ${d400Effect} } + &:focus { + outline: none; + box-shadow: 0 0 0 2px ${getColor('blue.500')}; + } + &.has-image { padding: 3px; } @@ -111,23 +116,44 @@ export const AttachmentUI = styled.a` ${bem.element('closeButton')} { ${cardStyles()}; display: block; - clip: rect(0, 0, 0, 0); border-radius: 9999px !important; position: absolute; right: 0; top: 0; transform: translate(50%, -50%); z-index: 1; + opacity: 0; + + transition: all 200ms linear; + will-change: box-shadow, color, opacity; + + box-shadow: inset 0 0 0 1px ${getColor('grey.700')}; + color: ${getColor('charcoal.300')}; + + & .c-Icon { + &, + &:focus, + &:hover { + opacity: 1; + } + } } &:hover, &:focus { ${bem.element('closeButton')} { - clip: unset; + opacity: 1; } } ${bem.element('closeButton')}:focus { - clip: unset; + opacity: 1; + box-shadow: 0 0 0 2px ${getColor('blue.500')}; + } + + ${bem.element('closeButton')}:hover { + box-shadow: inset 0 0 0 1px ${getColor('red.500')}; + + color: ${getColor('red.500')}; } ` diff --git a/src/components/Attachment/Attachment.stories.mdx b/src/components/Attachment/Attachment.stories.mdx index b1f8d0817..e8892ae28 100644 --- a/src/components/Attachment/Attachment.stories.mdx +++ b/src/components/Attachment/Attachment.stories.mdx @@ -98,3 +98,20 @@ A Provider component provides child components with props accessible as `context + +#### Preview theme with image + + + +
+ + + +
+
+
diff --git a/src/components/Avatar/Avatar.Image.jsx b/src/components/Avatar/Avatar.Image.jsx index eceda750b..16c3d542b 100644 --- a/src/components/Avatar/Avatar.Image.jsx +++ b/src/components/Avatar/Avatar.Image.jsx @@ -10,7 +10,7 @@ import getValidProps from '@helpscout/react-utils/dist/getValidProps' import VisuallyHidden from '../VisuallyHidden' import { classNames } from '../../utilities/classNames' import { noop } from '../../utilities/other' -import { ImageWrapperUI, ImageUI, TitleUI } from './Avatar.css' +import { ImageWrapperUI, ImageUI, InitialsUI } from './Avatar.css' import { getAnimationProps } from './Avatar.utils' let cache = {} @@ -184,15 +184,15 @@ export class AvatarImage extends React.PureComponent { } } - getTitleMarkup() { - const { light, title } = this.props + renderInitials() { + const { light, initials } = this.props const componentClassName = classNames( - 'c-Avatar__title', + 'c-Avatar__initials', light && 'is-light' ) - return {title} + return {initials} } render() { @@ -228,7 +228,7 @@ export class AvatarImage extends React.PureComponent { ) - return hasImage ? contentMarkup : this.getTitleMarkup() + return hasImage ? contentMarkup : this.renderInitials() } } @@ -237,12 +237,12 @@ AvatarImage.defaultProps = { animationDuration: 160, animationEasing: 'ease', 'data-cy': 'AvatarImage', - src: null, + initials: null, + light: false, + name: null, onError: noop, onLoad: noop, - name: null, - title: null, - light: false, + src: null, } AvatarImage.propTypes = { @@ -251,12 +251,12 @@ AvatarImage.propTypes = { animationEasing: PropTypes.string, /** Data attr for Cypress tests. */ 'data-cy': PropTypes.string, - src: PropTypes.any, + initials: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), light: PropTypes.bool, name: PropTypes.string, onError: PropTypes.func, onLoad: PropTypes.func, - title: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + src: PropTypes.any, } export default AvatarImage diff --git a/src/components/Avatar/Avatar.css.js b/src/components/Avatar/Avatar.css.js index 929a51047..ccfab030a 100644 --- a/src/components/Avatar/Avatar.css.js +++ b/src/components/Avatar/Avatar.css.js @@ -155,7 +155,7 @@ export const ImageUI = styled('div')` width: 100%; ` -export const TitleUI = styled('div')` +export const InitialsUI = styled('div')` color: white; line-height: 1; text-transform: uppercase; diff --git a/src/components/Avatar/Avatar.jsx b/src/components/Avatar/Avatar.jsx index 93ae125fc..a9c624498 100644 --- a/src/components/Avatar/Avatar.jsx +++ b/src/components/Avatar/Avatar.jsx @@ -114,7 +114,7 @@ export class Avatar extends React.PureComponent { const hasImage = this.src.length > 0 && !this.state.imageFailed - const title = this.getTitle() + const initials = this.getInitials() return ( )} @@ -182,7 +182,7 @@ export class Avatar extends React.PureComponent { ) } - getTitle() { + getInitials() { const { count, initials, name } = this.props return count || initials || nameToInitials(name) diff --git a/src/components/Avatar/Avatar.test.js b/src/components/Avatar/Avatar.test.js index fe8d580c9..a47800cbb 100644 --- a/src/components/Avatar/Avatar.test.js +++ b/src/components/Avatar/Avatar.test.js @@ -16,7 +16,7 @@ const ui = { borderAnimation: '.c-Avatar__borderAnimation', image: '.c-Avatar__image', imageWrapper: '.c-Avatar__imageWrapper', - initials: '.c-Avatar__title', + initials: '.c-Avatar__initials', action: '.c-Avatar__action', } diff --git a/src/components/DropList/DropList.stories.mdx b/src/components/DropList/DropList.stories.mdx index 2888adef1..3791a462b 100644 --- a/src/components/DropList/DropList.stories.mdx +++ b/src/components/DropList/DropList.stories.mdx @@ -711,6 +711,29 @@ The `toggler` prop accepts any React component, so you can provide your own cust /> + +
+ { + console.log('Clicked from story') + }} + error={'Some error'} + /> + } + /> +
+
{ }) }) + describe('SelectTag error state', () => { + test('Should show error when error provided to toggler', async () => { + const error = 'Some error' + const { getByLabelText, getByTitle } = render( + } /> + ) + + await waitFor(() => { + expect(getByTitle('alert')).toBeInTheDocument() + }) + expect(getByLabelText('toggle menu')).toHaveClass('is-error') + }) + + test('Should not show error when none', async () => { + const { getByLabelText, queryByTitle } = render( + } /> + ) + + await waitFor(() => { + expect(getByLabelText('toggle menu')).not.toHaveClass('is-error') + }) + expect(queryByTitle('alert')).not.toBeInTheDocument() + }) + }) + test('Should run action click callback on a SplittedButton', async () => { const onActionClick = jest.fn() const { getByText } = render( diff --git a/src/components/DropList/DropList.togglers.jsx b/src/components/DropList/DropList.togglers.jsx index ea6319d98..97ef64bd3 100644 --- a/src/components/DropList/DropList.togglers.jsx +++ b/src/components/DropList/DropList.togglers.jsx @@ -6,6 +6,8 @@ import { noop } from '../../utilities/other' import ControlGroup from '../ControlGroup' import HSDSButton from '../Button' import Icon from '../Icon' +import { STATES } from '../../constants' +import Tooltip from '../Tooltip' export const SimpleButton = forwardRef( ( @@ -196,14 +198,33 @@ const SplitButtonTogglerUI = styled(HSDSButton)` } ` +const ErrorTooltipIcon = ({ error }) => { + return ( + + + + ) +} + export const SelectTag = forwardRef( - ({ isActive = false, text = '', onClick = noop, ...rest }, ref) => { + ({ isActive = false, text = '', onClick = noop, error, ...rest }, ref) => { + const className = classNames( + 'DropListToggler SelectTagToggler', + error && 'is-error' + ) return ( {text} + {error && ( + // avoid list open/close when clicked on error icon + e.stopPropagation()}> + + + )} ) } @@ -235,6 +262,11 @@ const SelectUI = styled('button')` font-size: 13px; color: ${getColor('charcoal.600')}; + &.is-error { + box-shadow: inset 0 0 0 2px ${getColor('red.500')}; + padding-right: 10px; + } + &:focus { outline: 0; box-shadow: inset 0 0 0 2px ${getColor('blue.500')}; @@ -242,6 +274,7 @@ const SelectUI = styled('button')` ` const SelectArrowsUI = styled('div')` + margin-left: auto; width: 7px; height: 14px; position: relative; @@ -266,6 +299,10 @@ const SelectArrowsUI = styled('div')` } ` +const SelectErrorTooltipIconUI = styled('div')` + margin-left: 8px; +` + const KebabUI = styled('button')` width: 24px; height: 24px; diff --git a/src/components/Emoticon/Emoticon.css.js b/src/components/Emoticon/Emoticon.css.js index 4085e9e17..01a978540 100644 --- a/src/components/Emoticon/Emoticon.css.js +++ b/src/components/Emoticon/Emoticon.css.js @@ -1,10 +1,11 @@ import styled from 'styled-components' +import { getColor } from '../../styles/utilities/color' -// Note: colours here are not from the Help Scout palette +// Note: some colours here are not from the Help Scout palette export const reactionEmoticonsColours = { off: { head: '#E5E9EC', - face: '#A5B2BD', + face: getColor('charcoal.300'), }, on: { face: '#2E2D2E', diff --git a/src/components/Input/Input.BackdropV2.css.js b/src/components/Input/Input.BackdropV2.css.js index 0a8012ccd..954576bc0 100644 --- a/src/components/Input/Input.BackdropV2.css.js +++ b/src/components/Input/Input.BackdropV2.css.js @@ -139,8 +139,25 @@ export const FocusUI = styled('div')` &.is-stateful.is-focused { box-shadow: 0 0 0 ${config.focusOutlineWidth}px ${config.focusOutlineColor}; } + + ${makeStateFocusStyles()} ` +function makeStateFocusStyles() { + return forEach( + STATES, + state => ` + &.is-${state} { + box-shadow: 0 0 0 2px ${getColor( + 'state', + state, + 'borderColor' + )}, 0 0 0 6px ${getColor('state', state, 'backgroundColor')}; + } + ` + ) +} + function makeStateStyles() { return forEach( STATES, diff --git a/src/components/Input/Input.stories.mdx b/src/components/Input/Input.stories.mdx index 587f899c2..2b9de7e4e 100644 --- a/src/components/Input/Input.stories.mdx +++ b/src/components/Input/Input.stories.mdx @@ -75,17 +75,29 @@ An Input component is an enhanced version of the default HTML ``. Input c

- +
- +
(visible ? '1' : '0')}; + transform: translateY(${({ visible }) => (visible ? '0' : '12px')}); + transition: ${({ withAnimation }) => + withAnimation ? `all 300ms ease-in-out` : 'none'}; +` + export const TitleUI = styled(Heading)` ${fontFamily}; line-height: 22px !important; @@ -76,14 +83,23 @@ export const SubtitleUI = styled(Heading)` const editorHtmlFontSize = 14 +export const ContentUI = styled.div` + margin-top: ${({ withMargin }) => (withMargin ? '20px' : '0')}; + overflow: auto; + padding: 0 20px; + display: flex; + flex-direction: column; + + & > * + * { + margin-top: 20px; + } +` + export const BodyUI = styled.div` - margin-top: ${({ withMargin }) => (withMargin ? '12px' : '0')}; color: ${getColor('charcoal.700')}; font-size: ${editorHtmlFontSize}px; line-height: 22px; - padding: 0 20px; flex: 1 1 100%; - overflow: auto; p { font-size: ${editorHtmlFontSize}px; @@ -258,7 +274,6 @@ export const BodyUI = styled.div` ` export const ActionUI = styled('div')` - margin-bottom: -5px; margin-top: 20px; padding: 0 20px; flex: 0 0 auto; @@ -282,11 +297,5 @@ export const ImageContainerUI = styled('div')` display: flex; justify-content: center; width: 100%; - margin-top: 20px; - padding: 0 10px; max-height: ${MAX_IMAGE_SIZE}px; - - &:last-child { - margin-bottom: -15px; - } ` diff --git a/src/components/MessageCard/MessageCard.jsx b/src/components/MessageCard/MessageCard.jsx index 699eeeaa2..ac11338c9 100644 --- a/src/components/MessageCard/MessageCard.jsx +++ b/src/components/MessageCard/MessageCard.jsx @@ -1,187 +1,104 @@ -import React from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import PropTypes from 'prop-types' import getValidProps from '@helpscout/react-utils/dist/getValidProps' import MessageCardButton from './MessageCard.Button' import { classNames } from '../../utilities/classNames' import { noop } from '../../utilities/other' -import Animate from '../Animate' -import { - ActionUI, - BodyUI, - ImageContainerUI, - ImageUI, - MAX_IMAGE_SIZE, - MessageCardUI, - SubtitleUI, - TitleUI, -} from './MessageCard.css' -import Truncate from '../Truncate' - -const sizeWithRatio = (recalculatedSide, otherSide, defaultValue) => - // Check if other side is smaller than max size to not recalculate unnecessarily this side as it doesn't need any scaling - // other condition checks that the image fits the boundaries - otherSide < MAX_IMAGE_SIZE - ? defaultValue - : (recalculatedSide / otherSide) * MAX_IMAGE_SIZE - -export class MessageCard extends React.PureComponent { - static className = 'c-MessageCard' - static Button = MessageCardButton - - getClassName() { - const { align, className, isMobile, isWithBoxShadow } = this.props - return classNames( - MessageCard.className, - align && `is-align-${align}`, - className, - isMobile && 'is-mobile', - isWithBoxShadow && `is-with-box-shadow` - ) - } - - getTruncatedText(text, limit) { - return ( - - {text} - - ) - } - - renderTitle() { - const { title } = this.props - return title ? ( - - {this.getTruncatedText(title, 110)} - - ) : null - } - - renderSubtitle() { - const { subtitle } = this.props - return subtitle ? ( - - {this.getTruncatedText(subtitle, 110)} - - ) : null - } - - renderBody() { - const { onBodyClick, title, subtitle } = this.props - let { body } = this.props - const withMargin = title || subtitle - - // if there is no html in the string, transform new line to paragraph - if (body && !/<\/?[a-z][\s\S]*>/i.test(body)) { - body = body.split('\n').join('
') - } - - return body ? ( - -
- - ) : null - } - - renderImage() { - const { image } = this.props - - if (!image) { - return null - } - - const { height, width } = this.calculateSize(image) - - return ( - - - - ) - } - - // Calculate size of image to keep the original aspect ratio, but fit within 278x278 square for image - calculateSize = image => { - if (!image.width || !image.height) { - return {} - } - const width = parseInt(image.width) - const height = parseInt(image.height) - - // Not necessary to recalculate if it fits within boundaries - if (width < MAX_IMAGE_SIZE && height < MAX_IMAGE_SIZE) { - return { width, height } - } - - if (width > height) { - return { - height: sizeWithRatio(height, width, height), - width: Math.min(width, MAX_IMAGE_SIZE), - } - } else { - return { - width: sizeWithRatio(width, height, MAX_IMAGE_SIZE), - height: Math.min(height, MAX_IMAGE_SIZE), +import { MessageCardUI, MessageCardWrapperUI } from './MessageCard.css' +import { MessageCardTitle } from './components/MessageCard.Title' +import { MessageCardSubtitle } from './components/MessageCard.Subtitle' +import { MessageCardImage } from './components/MessageCard.Image' +import { MessageCardAction } from './components/MessageCard.Action' +import { MessageCardBody } from './components/MessageCard.Body' +import { MessageCardContent } from './components/MessageCard.Content' + +export const MessageCard = React.memo( + React.forwardRef( + ( + { + onShow, + in: inProp, + image, + action, + animationDuration, + animationEasing, + animationSequence, + children, + title, + subtitle, + onBodyClick, + body, + withAnimation, + className, + align, + isMobile, + isWithBoxShadow, + ...rest + }, + ref + ) => { + const [visible, setVisible] = useState(false) + const isShown = useRef(false) + + const hasImage = useCallback(() => image && image.url, [image]) + + useEffect(() => { + if (inProp && !hasImage() && !isShown.current) { + makeMessageVisible() + } + }, [inProp, hasImage]) + + useEffect(() => { + if (visible && !isShown.current) { + onShow() + isShown.current = true + } + }, [visible, onShow]) + + const makeMessageVisible = () => { + setTimeout(() => { + setVisible(true) + }, 0) } - } - } - - renderAction() { - const { action } = this.props - return action ? ( - {action()} - ) : null - } - render() { - const { - action, - animationDuration, - animationEasing, - animationSequence, - children, - innerRef, - in: inProp, - title, - ...rest - } = this.props + const getClassName = () => { + return classNames( + MessageCard.className, + align && `is-align-${align}`, + className, + isMobile && 'is-mobile', + isWithBoxShadow && `is-with-box-shadow` + ) + } - return ( - - - {this.renderTitle()} - {this.renderSubtitle()} - {this.renderBody()} - {this.renderImage()} - {children} - {this.renderAction()} - - - ) - } -} + + + + + + + {children} + + + + + ) : null + } + ) +) MessageCard.defaultProps = { align: 'right', @@ -192,6 +109,8 @@ MessageCard.defaultProps = { isMobile: false, isWithBoxShadow: true, onBodyClick: noop, + onShow: noop, + withAnimation: false, } MessageCard.propTypes = { @@ -209,7 +128,10 @@ MessageCard.propTypes = { body: PropTypes.string, /** The className of the component. */ className: PropTypes.string, - innerRef: PropTypes.func, + ref: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + ]), /** Programatically triggering the animation. */ in: PropTypes.bool, /** Adds mobile styles */ @@ -231,6 +153,13 @@ MessageCard.propTypes = { }), /** Data attr for Cypress tests. */ 'data-cy': PropTypes.string, + /** Callback invoked when the MessageCard is show to the user. */ + onShow: PropTypes.func, + /** Enable animations when showing the Message. */ + withAnimation: PropTypes.bool, } +MessageCard.className = 'c-MessageCard' +MessageCard.Button = MessageCardButton + export default MessageCard diff --git a/src/components/MessageCard/MessageCard.stories.mdx b/src/components/MessageCard/MessageCard.stories.mdx index 370828acb..3c878f016 100644 --- a/src/components/MessageCard/MessageCard.stories.mdx +++ b/src/components/MessageCard/MessageCard.stories.mdx @@ -24,12 +24,11 @@ This component renders a Message Card Notification with (optional) Title, Subtit + + + + +# With image and action + + + This sentence has five words.

')} image={{ - height: '230', - width: '800', + height: '700', + width: '1050', url: - 'http://matthewjamestaylor.com/img/illustrations/large/how-to-convert-a-liquid-layout-to-fixed-width.jpg', + 'https://images.unsplash.com/photo-1620607812709-f7c4a88baf11?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=1050&q=80', }} + action={() => Click here} />
diff --git a/src/components/MessageCard/MessageCard.test.js b/src/components/MessageCard/MessageCard.test.js index c46b8c5c0..d94dd3baf 100644 --- a/src/components/MessageCard/MessageCard.test.js +++ b/src/components/MessageCard/MessageCard.test.js @@ -1,16 +1,15 @@ import React from 'react' import { mount, render } from 'enzyme' -import { MessageCard } from './MessageCard' +import MessageCard from './MessageCard' import { TitleUI, SubtitleUI, BodyUI, ActionUI, ImageUI, - ImageContainerUI, } from './MessageCard.css' -import { Animate } from '../index' import { MessageCardButton as Button } from './MessageCard.Button' +import { act } from 'react-dom/test-utils' describe('className', () => { test('Has default className', () => { @@ -61,27 +60,106 @@ describe('Align', () => { }) }) -describe('Animation', () => { - test('Can customize animationSequence', () => { - const wrapper = mount() - const o = wrapper.find(Animate) +describe('Visibility', () => { + jest.useFakeTimers() + + test('Should be visible by default if there is no image', () => { + const onShowSpy = jest.fn() + const wrapper = mount() + + expect(cardWrapperVisible(wrapper)).toEqual(false) + expect(onShowSpy).not.toHaveBeenCalled() - expect(o.prop('sequence')).toBe('scale') + act(() => { + jest.runAllTimers() + wrapper.update() + }) + + expect(cardWrapperVisible(wrapper)).toEqual(true) + expect(onShowSpy).toHaveBeenCalled() }) - test('Can customize animationEasing', () => { - const wrapper = mount() - const o = wrapper.find(Animate) + test('Should not be visible by default if there is an image, but become visible when image loads', () => { + const onShowSpy = jest.fn() + const wrapper = mount( + + ) + + expect(cardWrapperVisible(wrapper)).toEqual(false) + expect(onShowSpy).not.toHaveBeenCalled() - expect(o.prop('easing')).toBe('linear') + jest.runAllTimers() + wrapper.update() + + expect(cardWrapperVisible(wrapper)).toEqual(false) + expect(onShowSpy).not.toHaveBeenCalled() + + wrapper.find('img').simulate('load') + + act(() => { + jest.runAllTimers() + wrapper.update() + }) + + expect(wrapper.find('img')).toHaveLength(1) + expect(cardWrapperVisible(wrapper)).toEqual(true) + expect(onShowSpy).toHaveBeenCalled() }) - test('Can customize animationDuration', () => { - const wrapper = mount() - const o = wrapper.find(Animate) + test('Should become visible without image if image fails to load', () => { + const onShowSpy = jest.fn() + const wrapper = mount( + + ) - expect(o.prop('duration')).toBe(123) + expect(cardWrapperVisible(wrapper)).toEqual(false) + expect(onShowSpy).not.toHaveBeenCalled() + + jest.runAllTimers() + wrapper.update() + + expect(cardWrapperVisible(wrapper)).toEqual(false) + expect(onShowSpy).not.toHaveBeenCalled() + + wrapper.find('img').simulate('error') + + act(() => { + jest.runAllTimers() + wrapper.update() + }) + + expect(wrapper.find('img')).toHaveLength(0) + expect(cardWrapperVisible(wrapper)).toEqual(true) + expect(onShowSpy).toHaveBeenCalled() + }) + + function cardWrapperVisible(wrapper) { + return wrapper.find('.c-MessageCardWrapper').at(0).prop('visible') + } +}) + +describe('Animation', () => { + test('Should have no animation by default', () => { + const wrapper = mount() + + expect(cardWrapperAnimation(wrapper)).toEqual(false) }) + + test('Should have animation if withAnimation is true', () => { + const wrapper = mount() + + expect(cardWrapperAnimation(wrapper)).toEqual(true) + }) + + function cardWrapperAnimation(wrapper) { + return wrapper.find('.c-MessageCardWrapper').at(0).prop('withAnimation') + } }) describe('Body', () => { @@ -208,8 +286,8 @@ describe('image', () => { ) const image = wrapper.find(ImageUI) - expect(image.prop('height')).toEqual('104.25px') - expect(image.prop('width')).toEqual('278px') + expect(image.prop('height')).toEqual('96.75px') + expect(image.prop('width')).toEqual('258px') }) test('Scales size of image when larger than fits and height is bigger', () => { @@ -224,8 +302,8 @@ describe('image', () => { ) const image = wrapper.find(ImageUI) - expect(image.prop('height')).toEqual('278px') - expect(image.prop('width')).toEqual('104.25px') + expect(image.prop('height')).toEqual('258px') + expect(image.prop('width')).toEqual('96.75px') }) test('Sets default size of image when not provided', () => { diff --git a/src/components/MessageCard/MessageCard.utils.js b/src/components/MessageCard/MessageCard.utils.js new file mode 100644 index 000000000..21bd37a5c --- /dev/null +++ b/src/components/MessageCard/MessageCard.utils.js @@ -0,0 +1,55 @@ +import { MAX_IMAGE_SIZE } from './MessageCard.css' +import Truncate from '../Truncate' +import React from 'react' + +const sizeWithRatio = (recalculatedSide, otherSide, defaultValue) => + // Check if other side is smaller than max size to not recalculate unnecessarily this side as it doesn't need any scaling + // other condition checks that the image fits the boundaries + otherSide < MAX_IMAGE_SIZE + ? defaultValue + : (recalculatedSide / otherSide) * MAX_IMAGE_SIZE + +/** + @param text text to truncate + @param limit limit to truncate to + @return element with text truncated to the given limit. + */ +export const getTruncatedText = (text, limit) => { + return ( + + {text} + + ) +} + +/** + Calculate size of image to keep the original aspect ratio, but fit within MAX_IMAGE_SIZExMAX_IMAGE_SIZE square for image + + @param image image to calculate size for. Must have width and height defined. + + @return size Object of type {width: number, height: number} that defines recalculated image's size. Empty object in case of no width or height in parameter image. + */ +export const calculateSize = image => { + if (!image.width || !image.height) { + return {} + } + const width = parseInt(image.width) + const height = parseInt(image.height) + + // Not necessary to recalculate if it fits within boundaries + if (width < MAX_IMAGE_SIZE && height < MAX_IMAGE_SIZE) { + return { width, height } + } + + if (width > height) { + return { + height: sizeWithRatio(height, width, height), + width: Math.min(width, MAX_IMAGE_SIZE), + } + } else { + return { + width: sizeWithRatio(width, height, MAX_IMAGE_SIZE), + height: Math.min(height, MAX_IMAGE_SIZE), + } + } +} diff --git a/src/components/MessageCard/components/MessageCard.Action.jsx b/src/components/MessageCard/components/MessageCard.Action.jsx new file mode 100644 index 000000000..a798b5a1b --- /dev/null +++ b/src/components/MessageCard/components/MessageCard.Action.jsx @@ -0,0 +1,14 @@ +import { ActionUI } from '../MessageCard.css' +import React from 'react' +import PropTypes from 'prop-types' + +export const MessageCardAction = ({ action }) => { + return action ? ( + {action()} + ) : null +} + +MessageCardAction.propTypes = { + /** Action to be called */ + action: PropTypes.func, +} diff --git a/src/components/MessageCard/components/MessageCard.Body.jsx b/src/components/MessageCard/components/MessageCard.Body.jsx new file mode 100644 index 000000000..bb2acf842 --- /dev/null +++ b/src/components/MessageCard/components/MessageCard.Body.jsx @@ -0,0 +1,39 @@ +import { BodyUI } from '../MessageCard.css' +import React from 'react' +import PropTypes from 'prop-types' +import { noop } from '../../../utilities/other' + +export const MessageCardBody = ({ withMargin, body, onClick }) => { + const getBodyToRender = () => { + // if there is no html in the string, transform new line to paragraph + if (body && !/<\/?[a-z][\s\S]*>/i.test(body)) { + return body.split('\n').join('
') + } + return body + } + + const bodyToRender = getBodyToRender() + + return bodyToRender ? ( + +
+ + ) : null +} + +MessageCardBody.propTypes = { + /** Body content */ + body: PropTypes.string, + /** Indicate if should add margin above the body */ + withMargin: PropTypes.string, + /** Callback when body clicked */ + onClick: PropTypes.func, +} + +MessageCardBody.defaultProps = { + onClick: noop, +} diff --git a/src/components/MessageCard/components/MessageCard.Content.jsx b/src/components/MessageCard/components/MessageCard.Content.jsx new file mode 100644 index 000000000..4cbfa6fbe --- /dev/null +++ b/src/components/MessageCard/components/MessageCard.Content.jsx @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types' +import { ContentUI } from '../MessageCard.css' +import React from 'react' + +export const MessageCardContent = ({ children, withMargin, render }) => { + if (!render) { + return null + } + return {children} +} + +MessageCardContent.propTypes = { + children: PropTypes.any, + /** Indicates if should add margin to the top */ + withMargin: PropTypes.bool, + /** Indicates if should render the content at all */ + render: PropTypes.bool, +} diff --git a/src/components/MessageCard/components/MessageCard.Image.jsx b/src/components/MessageCard/components/MessageCard.Image.jsx new file mode 100644 index 000000000..be677e422 --- /dev/null +++ b/src/components/MessageCard/components/MessageCard.Image.jsx @@ -0,0 +1,48 @@ +import { calculateSize } from '../MessageCard.utils' +import { ImageContainerUI, ImageUI } from '../MessageCard.css' +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { noop } from '../../../utilities/other' + +export const MessageCardImage = ({ image, onLoad }) => { + const [imageError, setImageError] = useState(false) + const onImageError = () => { + setImageError(true) + onLoad() + } + + if (!image || imageError) { + return null + } + + const { height, width } = calculateSize(image) + + return ( + + + + ) +} + +MessageCardImage.propTypes = { + /** Image to render */ + image: PropTypes.shape({ + url: PropTypes.string.isRequired, + altText: PropTypes.string, + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + }), + /** Callback when image loaded */ + onLoad: PropTypes.func, +} + +MessageCardImage.defaultProps = { + onLoad: noop, +} diff --git a/src/components/MessageCard/components/MessageCard.Subtitle.jsx b/src/components/MessageCard/components/MessageCard.Subtitle.jsx new file mode 100644 index 000000000..3e019d203 --- /dev/null +++ b/src/components/MessageCard/components/MessageCard.Subtitle.jsx @@ -0,0 +1,17 @@ +import { SubtitleUI } from '../MessageCard.css' +import { getTruncatedText } from '../MessageCard.utils' +import React from 'react' +import PropTypes from 'prop-types' + +export const MessageCardSubtitle = ({ subtitle }) => { + return subtitle ? ( + + {getTruncatedText(subtitle, 110)} + + ) : null +} + +MessageCardSubtitle.propTypes = { + /** Subtitle of a Message */ + subtitle: PropTypes.string, +} diff --git a/src/components/MessageCard/components/MessageCard.Title.jsx b/src/components/MessageCard/components/MessageCard.Title.jsx new file mode 100644 index 000000000..6cb7068c0 --- /dev/null +++ b/src/components/MessageCard/components/MessageCard.Title.jsx @@ -0,0 +1,17 @@ +import { TitleUI } from '../MessageCard.css' +import { getTruncatedText } from '../MessageCard.utils' +import React from 'react' +import PropTypes from 'prop-types' + +export const MessageCardTitle = ({ title }) => { + return title ? ( + + {getTruncatedText(title, 110)} + + ) : null +} + +MessageCardTitle.propTypes = { + /** Title of a Message */ + title: PropTypes.string, +} diff --git a/src/utilities/pkg.js b/src/utilities/pkg.js index b1f56b8ba..7e1971154 100644 --- a/src/utilities/pkg.js +++ b/src/utilities/pkg.js @@ -1,3 +1,3 @@ export default { - version: '3.13.0', + version: '3.14.0', }