diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index 14fd5a2435..fe2956d838 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -36,8 +36,10 @@ exports[`Gamut Exported Keys 1`] = ` "DataTable", "DeprecatedToolTip", "Dialog", + "Disclosure", "Drawer", "ExpandControl", + "ExpandInCollapseOut", "FillButton", "FlexBox", "FloatingCard", diff --git a/packages/gamut/src/Animation/ExpandInCollapseOut.tsx b/packages/gamut/src/Animation/ExpandInCollapseOut.tsx new file mode 100644 index 0000000000..3a10c91ab4 --- /dev/null +++ b/packages/gamut/src/Animation/ExpandInCollapseOut.tsx @@ -0,0 +1,24 @@ +import { timingValues } from '@codecademy/gamut-styles'; +import { motion } from 'framer-motion'; + +import { WithChildrenProp } from '../utils'; + +export const ExpandInCollapseOut: React.FC = ({ + children, +}) => { + return ( + + {children} + + ); +}; diff --git a/packages/gamut/src/Animation/index.ts b/packages/gamut/src/Animation/index.ts index 1b046f6c86..bdeb739424 100644 --- a/packages/gamut/src/Animation/index.ts +++ b/packages/gamut/src/Animation/index.ts @@ -1 +1,2 @@ export * from './Rotation'; +export * from './ExpandInCollapseOut'; diff --git a/packages/gamut/src/Disclosure/DisclosureBody/index.tsx b/packages/gamut/src/Disclosure/DisclosureBody/index.tsx new file mode 100644 index 0000000000..587bb9e651 --- /dev/null +++ b/packages/gamut/src/Disclosure/DisclosureBody/index.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; + +import { Text } from '../../Typography'; +import { DisclosureBodyWrapper } from '../elements'; +import { getSpacing, renderButton } from '../helpers'; +import { DisclosureBodyProps } from '../types'; + +export const DisclosureBody: React.FC = ({ + body, + buttonType = 'TextButton', + buttonPlacement = 'right', + ctaCallback, + ctaText, + hasPanelBg = false, + href, + spacing = 'normal', +}) => { + const buttonRequirements = ctaText && ctaCallback; + + const getLineHeight = spacing === 'compact' ? 'title' : 'base'; + const { verticalSpacing, horizontalSpacing } = getSpacing(spacing); + return ( + + + {body} + + {buttonRequirements && + renderButton({ + buttonPlacement, + buttonType, + ctaCallback, + ctaText, + href, + })} + + ); +}; diff --git a/packages/gamut/src/Disclosure/DisclosureButton/index.tsx b/packages/gamut/src/Disclosure/DisclosureButton/index.tsx new file mode 100644 index 0000000000..2a4f1695bd --- /dev/null +++ b/packages/gamut/src/Disclosure/DisclosureButton/index.tsx @@ -0,0 +1,97 @@ +import { + ArrowChevronDownIcon, + MiniChevronDownIcon, +} from '@codecademy/gamut-icons'; +import * as React from 'react'; + +import { Rotation, Text } from '../..'; +import { Box, FlexBox } from '../../Box'; +import { DisclosureButtonWrapper } from '../elements'; +import { getRotationSize, getSpacing, getTitleSize } from '../helpers'; +import { DisclosureButtonProps } from '../types'; + +export const DisclosureButton: React.FC = ({ + disabled = false, + heading, + headingLevel = 'h3', + isExpanded, + overline, + setIsExpanded, + spacing = 'normal', + subheading, +}) => { + const handleClick = () => { + if (setIsExpanded) { + setIsExpanded((prev: boolean) => !prev); + } + }; + + const titleSize = getTitleSize(spacing); + const subheadingSize = spacing === 'normal' ? 'p-base' : 'p-small'; + const rotationSize = getRotationSize(spacing); + const { verticalSpacing, horizontalSpacing } = getSpacing(spacing); + + return ( + + + {overline && ( + + {overline} + + )} + + + {heading} + + + + + {spacing === 'normal' ? ( + + ) : ( + + )} + + + + {subheading && ( + + {subheading} + + )} + + + ); +}; diff --git a/packages/gamut/src/Disclosure/__tests__/Disclosure.test.tsx b/packages/gamut/src/Disclosure/__tests__/Disclosure.test.tsx new file mode 100644 index 0000000000..26ebdfc2a8 --- /dev/null +++ b/packages/gamut/src/Disclosure/__tests__/Disclosure.test.tsx @@ -0,0 +1,57 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import userEvent from '@testing-library/user-event'; + +import { Disclosure } from '..'; + +const ctaCallback = jest.fn(); + +const defaultProps = { + header: 'hi there!', + body:
This should render when expanded
, + withBackground: false, +}; + +const renderView = setupRtl(Disclosure, defaultProps); + +describe('Disclosure', () => { + it('renders the DisclosureBody when DisclosureButton is clicked', () => { + const { view } = renderView({ + initiallyExpanded: false, + }); + + const DisclosureButton = view.getByRole('button'); + let DisclosureBodyText = view.queryByText( + 'This should render when expanded' + ); + expect(DisclosureBodyText).toBeNull(); + expect(DisclosureButton.getAttribute('aria-expanded')).toBe('false'); + + userEvent.click(DisclosureButton); + + DisclosureBodyText = view.getByText('This should render when expanded'); + expect(DisclosureButton.getAttribute('aria-expanded')).toBe('true'); + }); + + it('renders the body when `initiallyExpanded` is set to true', () => { + const { view } = renderView({ + initiallyExpanded: true, + }); + + const DisclosureButton = view.getByRole('button'); + view.getByText('This should render when expanded'); + + expect(DisclosureButton.getAttribute('aria-expanded')).toBe('true'); + }); + + it("renders the DisclosureBody's button when supplied a `cta` and `ctaCallback` argument", () => { + const { view } = renderView({ + initiallyExpanded: true, + ctaText: 'click here', + ctaCallback, + }); + + const CTAButton = view.getByText('click here'); + userEvent.click(CTAButton); + expect(ctaCallback).toBeCalled(); + }); +}); diff --git a/packages/gamut/src/Disclosure/elements.tsx b/packages/gamut/src/Disclosure/elements.tsx new file mode 100644 index 0000000000..b47a269fe6 --- /dev/null +++ b/packages/gamut/src/Disclosure/elements.tsx @@ -0,0 +1,115 @@ +import { states, variant } from '@codecademy/gamut-styles'; +import { StyleProps } from '@codecademy/variance'; +import styled from '@emotion/styled'; + +import { Anchor } from '../Anchor'; +import { FlexBox } from '../Box'; +import { FillButton, StrokeButton, TextButton } from '../Button'; + +const disclosureWrapperVariants = variant({ + defaultVariant: 'default', + base: { + width: '100%', + maxHeight: 'fit-content', + }, + variants: { + default: { + bg: 'background', + }, + subtle: { + bg: 'background-selected', + }, + transparent: { + bg: 'background-current', + }, + }, +}); + +const disclosureWrapperStates = states({ + hasBorder: { + border: 1, + }, +}); + +type DisclosureWrapperStateProps = StyleProps; +type DisclosureWrapperVariantProps = StyleProps< + typeof disclosureWrapperVariants +>; +export type DisclosureWrapperStyles = DisclosureWrapperStateProps & + DisclosureWrapperVariantProps; + +export const DisclosureWrapper = styled(FlexBox)( + disclosureWrapperStates, + disclosureWrapperVariants +); + +export const DisclosureButtonWrapper = styled(Anchor)( + variant({ + prop: 'isWrapper', + defaultVariant: 'default', + variants: { + default: { + bg: 'inherit', + '&:hover': { + color: 'text', + bg: 'background-hover', + }, + '&:focus': { + color: 'text', + bg: 'background-selected', + }, + '&:disabled': { + color: 'text-disabled', + bg: 'background-disabled', + }, + }, + }, + }) +); + +const sharedVariants = { + left: { + alignSelf: 'flex-start', + justifyContent: 'left', + }, + right: { + alignSelf: 'flex-end', + justifyContent: 'right', + }, +}; + +export const StyledTextButton = styled(TextButton)( + variant({ + prop: 'placement', + variants: sharedVariants, + }) +); + +export const StyledStrokeButton = styled(StrokeButton)( + variant({ + prop: 'placement', + variants: sharedVariants, + }) +); + +export const StyledFillButton = styled(FillButton)( + variant({ + prop: 'placement', + variants: sharedVariants, + }) +); + +export type DisclosureBodyWrapperStyles = StyleProps< + typeof disclosureBodyWrapperStates +>; + +const disclosureBodyWrapperStates = states({ + hasPanelBg: { + bg: 'background-selected', + p: 8, + }, +}); + +export const DisclosureBodyWrapper = styled( + FlexBox +)(disclosureBodyWrapperStates); diff --git a/packages/gamut/src/Disclosure/helpers.tsx b/packages/gamut/src/Disclosure/helpers.tsx new file mode 100644 index 0000000000..a73a6122b2 --- /dev/null +++ b/packages/gamut/src/Disclosure/helpers.tsx @@ -0,0 +1,76 @@ +import { + StyledFillButton, + StyledStrokeButton, + StyledTextButton, +} from './elements'; + +export const getSpacing = (spacing: 'compact' | 'condensed' | 'normal') => { + const verticalSpacingMap = { + compact: 4, + condensed: 8, + normal: 16, + } as const; + + const horizontalSpacingMap = { + compact: 8, + condensed: 8, + normal: 16, + } as const; + + return { + verticalSpacing: verticalSpacingMap[spacing], + horizontalSpacing: horizontalSpacingMap[spacing], + }; +}; + +const titleVariantMap = { + compact: 'p-base', + condensed: 'title-xs', + normal: 'title-sm', +} as const; + +export const getTitleSize = (spacing: keyof typeof titleVariantMap) => { + return titleVariantMap[spacing]; +}; + +export const getRotationSize = ( + spacing: 'compact' | 'condensed' | 'normal' +) => { + return spacing === 'normal' ? 24 : 16; +}; + +export const renderButton = (buttonProps: { + buttonPlacement: 'left' | 'right'; + buttonType: 'FillButton' | 'StrokeButton' | 'TextButton'; + ctaCallback: () => void; + ctaText: string; + href?: string | undefined; +}) => { + const { + buttonPlacement, + buttonType, + ctaCallback, + ctaText, + href, + } = buttonProps; + const sharedProps = { + mt: 8 as const, + lineHeight: 'normal', + onClick: ctaCallback ? () => ctaCallback() : undefined, + textAlign: buttonPlacement, + href, + placement: buttonPlacement, + }; + switch (buttonType) { + case 'FillButton': + return {ctaText}; + case 'StrokeButton': + return ( + {ctaText} + ); + case 'TextButton': + return {ctaText}; + default: + return null; + } +}; diff --git a/packages/gamut/src/Disclosure/index.tsx b/packages/gamut/src/Disclosure/index.tsx new file mode 100644 index 0000000000..eae846c22b --- /dev/null +++ b/packages/gamut/src/Disclosure/index.tsx @@ -0,0 +1,68 @@ +import { AnimatePresence } from 'framer-motion'; +import { useState } from 'react'; +import * as React from 'react'; + +import { ExpandInCollapseOut } from '../Animation'; +import { DisclosureBody } from './DisclosureBody'; +import { DisclosureButton } from './DisclosureButton'; +import { DisclosureWrapper } from './elements'; +import { DisclosureProps } from './types'; + +export const Disclosure: React.FC = ({ + body, + buttonType: button, + buttonPlacement, + ctaCallback, + ctaText, + disabled = false, + heading, + headingLevel = 'h3', + href, + initiallyExpanded, + isListItem = false, + onClick, + overline, + hasBorder = true, + hasPanelBg, + spacing = 'normal', + subheading, + variant, +}) => { + const [isExpanded, setIsExpanded] = useState(initiallyExpanded); + return ( + onClick?.()} + as={isListItem ? 'li' : undefined} + > + + + {isExpanded && ( + + + + )} + + + ); +}; diff --git a/packages/gamut/src/Disclosure/types.ts b/packages/gamut/src/Disclosure/types.ts new file mode 100644 index 0000000000..62654c140e --- /dev/null +++ b/packages/gamut/src/Disclosure/types.ts @@ -0,0 +1,75 @@ +import { + DisclosureBodyWrapperStyles, + DisclosureWrapperStyles, +} from './elements'; + +export interface DisclosureButtonProps { + /** + * Renders the Disclosure unclickable. + */ + disabled?: boolean; + heading: string; + /** + * Set the heading to the proper semantic heading level (h2, h3, etc...) + * Default value is 'h3'. + */ + headingLevel?: 'h2' | 'h3' | 'h4' | 'h5'; + isExpanded?: boolean; + /** + * Provide a string value to render overline text appear above the heading. + */ + overline?: string; + /** + * Determines the size of the heading text and the space between text in the body. + */ + spacing?: 'normal' | 'condensed' | 'compact'; + setIsExpanded?: React.Dispatch>; + /** + * Provide a string value to appear below the heading. + */ + subheading?: string; +} + +export interface DisclosureBodyProps extends DisclosureBodyWrapperStyles { + /** + * If only providing text, set the max width of the text container to be `600px` for readability's sake. + */ + body: React.ReactNode; + /** + * These are pre-styled buttons that can be rendered in the body. + */ + buttonType?: 'FillButton' | 'StrokeButton' | 'TextButton'; + /** + * You can specify `"right"` (default) or `"left"` placement of the optional button at the bottom of the body. + */ + buttonPlacement?: 'left' | 'right'; + /** + * A callback function MUST be included when providing a `ctaButton` to render the optional button. + */ + ctaCallback?: () => void; + /** + * This string is the value displayed in the optional button rendered in the body. + */ + ctaText?: string; + href?: string; + /** + * Determines the size of the heading text and the space between text in the body. + */ + spacing?: 'normal' | 'condensed' | 'compact'; +} + +export interface DisclosureProps + extends DisclosureButtonProps, + DisclosureBodyProps, + DisclosureWrapperStyles { + /** + * Determines whether or not the Disclosure is expanded upon load. + * Default value is `false`. + */ + initiallyExpanded?: boolean; + /** + * Renders as a `li` if `true`. + */ + isListItem?: boolean; + onClick?: () => void; +} diff --git a/packages/gamut/src/index.tsx b/packages/gamut/src/index.tsx index 01628c9cde..18abc4a2a5 100644 --- a/packages/gamut/src/index.tsx +++ b/packages/gamut/src/index.tsx @@ -17,6 +17,7 @@ export * from './Card'; export * from './Coachmark'; export * from './ConnectedForm'; export * from './ContentContainer'; +export * from './Disclosure'; export * from './DataList'; export * from './Drawer'; export * from './FloatingCard/FloatingCard'; diff --git a/packages/styleguide/stories/Molecules/Disclosure/examples.tsx b/packages/styleguide/stories/Molecules/Disclosure/examples.tsx new file mode 100644 index 0000000000..ef8cee3b51 --- /dev/null +++ b/packages/styleguide/stories/Molecules/Disclosure/examples.tsx @@ -0,0 +1,66 @@ +import { + Disclosure, + FlexBox, + List, + Text, + WithChildrenProp, +} from '@codecademy/gamut'; +import { Background, BackgroundProps } from '@codecademy/gamut-styles'; + +export const ListDisclosureExample = () => { + return ( + + null} + buttonPlacement="right" + href="/" + isListItem + /> + null} + buttonPlacement="left" + href="/" + buttonType="StrokeButton" + isListItem + /> + null} + href="/" + buttonType="FillButton" + isListItem + /> + + ); +}; + +export const ConstrainedText = ( + + Lorem ipsum dolor sit, amet consectetur adipisicing elit. Quis tempore + voluptatum, hic ipsum cum commodi laudantium? Mollitia quod totam + consequuntur facere, praesentium cumque nesciunt debitis officiis, ipsa + sapiente recusandae iusto. + +); + +export const BackgroundWithPadding = ({ + bg, + children, +}: Pick & WithChildrenProp) => { + return ( + + {children} + + ); +}; diff --git a/packages/styleguide/stories/Molecules/Disclosure/index.stories.mdx b/packages/styleguide/stories/Molecules/Disclosure/index.stories.mdx new file mode 100644 index 0000000000..9c69888475 --- /dev/null +++ b/packages/styleguide/stories/Molecules/Disclosure/index.stories.mdx @@ -0,0 +1,177 @@ +import { Disclosure } from '@codecademy/gamut'; +import title from '@codecademy/macros/lib/title.macro'; +import { PropsTable } from '@codecademy/storybook-addon-variance'; +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; + +import { BackgroundWithPadding, ConstrainedText } from './examples'; + + + +{(args) => } + +## Usage + +Use a Disclosure to progressively reveal information, giving users the option to access additional details as needed. By initially hiding less immediately relevant information, it minimizes cognitive load and boosts content scannability. + +If you need to use multiple Disclosures, use the List component with `` for the time being. + +## Variants + +Disclosure has 3 variants for background color (`'default'`, `'subtle', 'transparent'`), a state of `hasBorder` to render a 1px solid black border, and 3 spacing options (`'normal'`, `'condensed'`, `'compact'`). Check the Figma design to apply the correct styling. + +### Background and Border variants + + + + {(args) => ( + + + + )} + + + + + ` set to `"background-primary"`.', + }} + > + {(args) => ( + + + + )} + + + + + + {(args) => ( + + + + )} + + + + + + {(args) => ( + + + + )} + + + +### Spacing Options + +`spacing` is an optional prop you can pass to ``. By default it's set to `"normal"`, though you can set it to `"condensed"` or `"compact"` as well. Spacing will determine the height of the heading, the size of the chevron icon, as well as the padding of both the heading and the body. + + + + {(args) => } + + + + + + {(args) => } + + + + + + {(args) => } + + + +## Code Playground + + + {}, + hasPanelBg: true, + initiallyExpanded: true, + }} + > + {(args) => } + + + +