diff --git a/components/src/atoms/ListButton/ListButton.stories.tsx b/components/src/atoms/ListButton/ListButton.stories.tsx new file mode 100644 index 00000000000..5b0adf37e5c --- /dev/null +++ b/components/src/atoms/ListButton/ListButton.stories.tsx @@ -0,0 +1,133 @@ +import * as React from 'react' + +import { ListButton as ListButtonComponent } from './index' +import { + ListButtonAccordion, + ListButtonAccordionContainer, + ListButtonRadioButton, +} from './ListButtonChildren/index' +import { StyledText } from '../..' +import type { Meta, StoryObj } from '@storybook/react' + +const meta: Meta = { + title: 'Library/Atoms/ListButton', + component: ListButtonComponent, + argTypes: { + type: { + control: { + type: 'select', + options: ['noActive', 'success', 'warning'], + }, + }, + }, +} + +export default meta + +type Story = StoryObj +type ListButtonComponentProps = React.ComponentProps + +const Template = (args: ListButtonComponentProps): JSX.Element => { + const [containerExpand, setContainerExpand] = React.useState(false) + const [buttonValue, setButtonValue] = React.useState(null) + const [nestedButtonValue, setNestedButtonValue] = React.useState< + string | null + >(null) + + return ( + { + setContainerExpand(!containerExpand) + }} + > + + + <> + { + e.stopPropagation() + setButtonValue('radio button nested') + }} + /> + + {buttonValue === 'radio button nested' ? ( + + + <> + { + setNestedButtonValue('radio button1') + }} + /> + {nestedButtonValue === 'radio button1' ? ( + + Nested button option + + ) : null} + + { + setNestedButtonValue('radio button2') + }} + /> + { + setNestedButtonValue('radio button3') + }} + /> + + + ) : null} + + <> + { + setButtonValue('radio button non nest') + }} + /> + + {buttonValue === 'radio button non nest' ? ( + + Non nested button option + + ) : null} + + + + + ) +} +export const ListButton: Story = { + render: Template, + args: { + type: 'noActive', + }, +} diff --git a/components/src/atoms/ListButton/ListButtonChildren/ListButtonAccordion.tsx b/components/src/atoms/ListButton/ListButtonChildren/ListButtonAccordion.tsx new file mode 100644 index 00000000000..f08a4460e14 --- /dev/null +++ b/components/src/atoms/ListButton/ListButtonChildren/ListButtonAccordion.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' +import { Flex } from '../../../primitives' +import { DIRECTION_COLUMN } from '../../../styles' +import { SPACING } from '../../../ui-style-constants' +import { StyledText } from '../../StyledText' + +interface ListButtonAccordionProps { + headline: string + children: React.ReactNode + // determines if the accordion is expanded or not + isExpanded?: boolean + // is it nested into another accordion? + isNested?: boolean + // optional main headline for the top level accordion + mainHeadline?: string +} + +/* + To be used with ListButton, ListButtonAccordion and ListButtonRadioButton + This is the accordion component to use both as just an accordion or nested accordion +**/ +export function ListButtonAccordion( + props: ListButtonAccordionProps +): JSX.Element { + const { + headline, + children, + mainHeadline, + isExpanded = false, + isNested = false, + } = props + + return ( + + {mainHeadline != null ? ( + + + {mainHeadline} + + + ) : null} + {isExpanded ? ( + + + + {headline} + + + {children} + + ) : null} + + ) +} diff --git a/components/src/atoms/ListButton/ListButtonChildren/ListButtonAccordionContainer.tsx b/components/src/atoms/ListButton/ListButtonChildren/ListButtonAccordionContainer.tsx new file mode 100644 index 00000000000..7a51b2cee16 --- /dev/null +++ b/components/src/atoms/ListButton/ListButtonChildren/ListButtonAccordionContainer.tsx @@ -0,0 +1,29 @@ +import * as React from 'react' +import { Flex } from '../../../primitives' +import { DIRECTION_COLUMN } from '../../../styles' + +interface ListButtonAccordionContainerProps { + children: React.ReactNode + id: string +} +/* + To be used with ListButtonAccordion to stop propagation since multiple + layers have a CTA +**/ +export function ListButtonAccordionContainer( + props: ListButtonAccordionContainerProps +): JSX.Element { + const { id, children } = props + + return ( + { + e.stopPropagation() + }} + > + {children} + + ) +} diff --git a/components/src/atoms/ListButton/ListButtonChildren/ListButtonRadioButton.tsx b/components/src/atoms/ListButton/ListButtonChildren/ListButtonRadioButton.tsx new file mode 100644 index 00000000000..75161baa1a6 --- /dev/null +++ b/components/src/atoms/ListButton/ListButtonChildren/ListButtonRadioButton.tsx @@ -0,0 +1,84 @@ +import * as React from 'react' +import styled, { css } from 'styled-components' +import { SPACING } from '../../../ui-style-constants' +import { BORDERS, COLORS } from '../../../helix-design-system' +import { Flex } from '../../../primitives' +import { StyledText } from '../../StyledText' + +import type { StyleProps } from '../../../primitives' + +interface ListButtonRadioButtonProps extends StyleProps { + buttonText: string + buttonValue: string | number + onChange: React.ChangeEventHandler + disabled?: boolean + isSelected?: boolean + id?: string +} + +// used for helix and as a child button to ListButtonAccordion +export function ListButtonRadioButton( + props: ListButtonRadioButtonProps +): JSX.Element { + const { + buttonText, + buttonValue, + isSelected = false, + onChange, + disabled = false, + id = buttonText, + } = props + + const SettingButton = styled.input` + display: none; + ` + + const AVAILABLE_BUTTON_STYLE = css` + background: ${COLORS.white}; + color: ${COLORS.black90}; + + &:hover { + background-color: ${COLORS.grey10}; + } + ` + + const SELECTED_BUTTON_STYLE = css` + background: ${COLORS.blue50}; + color: ${COLORS.white}; + + &:active { + background-color: ${COLORS.blue60}; + } + ` + + const DISABLED_STYLE = css` + color: ${COLORS.grey40}; + background-color: ${COLORS.grey10}; + ` + + const SettingButtonLabel = styled.label` + border-radius: ${BORDERS.borderRadius8}; + cursor: pointer; + padding: 14px ${SPACING.spacing12}; + width: 100%; + + ${isSelected ? SELECTED_BUTTON_STYLE : AVAILABLE_BUTTON_STYLE} + ${disabled && DISABLED_STYLE} + ` + + return ( + + + + {buttonText} + + + ) +} diff --git a/components/src/atoms/ListButton/ListButtonChildren/index.ts b/components/src/atoms/ListButton/ListButtonChildren/index.ts new file mode 100644 index 00000000000..5f4d1e85031 --- /dev/null +++ b/components/src/atoms/ListButton/ListButtonChildren/index.ts @@ -0,0 +1,3 @@ +export * from './ListButtonAccordion' +export * from './ListButtonAccordionContainer' +export * from './ListButtonRadioButton' diff --git a/components/src/atoms/ListButton/__tests__/ListButton.test.tsx b/components/src/atoms/ListButton/__tests__/ListButton.test.tsx new file mode 100644 index 00000000000..6af0feddaee --- /dev/null +++ b/components/src/atoms/ListButton/__tests__/ListButton.test.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import '@testing-library/jest-dom/vitest' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../testing/utils' +import { COLORS } from '../../../helix-design-system' + +import { ListButton } from '..' + +const render = (props: React.ComponentProps) => + renderWithProviders() + +describe('ListButton', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + type: 'noActive', + children:
mock ListButton content
, + onClick: vi.fn(), + } + }) + + it('should render correct style - noActive', () => { + render(props) + const listButton = screen.getByTestId('ListButton_noActive') + expect(listButton).toHaveStyle(`backgroundColor: ${COLORS.grey30}`) + }) + it('should render correct style - connected', () => { + props.type = 'connected' + render(props) + const listButton = screen.getByTestId('ListButton_connected') + expect(listButton).toHaveStyle(`backgroundColor: ${COLORS.green30}`) + }) + it('should render correct style - notConnected', () => { + props.type = 'notConnected' + render(props) + const listButton = screen.getByTestId('ListButton_notConnected') + expect(listButton).toHaveStyle(`backgroundColor: ${COLORS.yellow30}`) + }) + it('should call on click when pressed', () => { + render(props) + fireEvent.click(screen.getByText('mock ListButton content')) + expect(props.onClick).toHaveBeenCalled() + }) +}) diff --git a/components/src/atoms/ListButton/__tests__/ListButtonAccordion.test.tsx b/components/src/atoms/ListButton/__tests__/ListButtonAccordion.test.tsx new file mode 100644 index 00000000000..c90b09d651d --- /dev/null +++ b/components/src/atoms/ListButton/__tests__/ListButtonAccordion.test.tsx @@ -0,0 +1,35 @@ +import * as React from 'react' +import { describe, it, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../../testing/utils' + +import { ListButtonAccordion } from '..' + +const render = (props: React.ComponentProps) => + renderWithProviders() + +describe('ListButtonAccordion', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + children:
mock ListButtonAccordion content
, + headline: 'mock headline', + isExpanded: true, + } + }) + + it('should render non nested accordion', () => { + render(props) + screen.getByText('mock headline') + screen.getByText('mock ListButtonAccordion content') + }) + it('should render non nested accordion with main headline', () => { + props.isNested = true + props.mainHeadline = 'mock main headline' + render(props) + screen.getByText('mock main headline') + screen.getByText('mock headline') + screen.getByText('mock ListButtonAccordion content') + }) +}) diff --git a/components/src/atoms/ListButton/__tests__/ListButtonRadioButton.test.tsx b/components/src/atoms/ListButton/__tests__/ListButtonRadioButton.test.tsx new file mode 100644 index 00000000000..2f3bfb95d5b --- /dev/null +++ b/components/src/atoms/ListButton/__tests__/ListButtonRadioButton.test.tsx @@ -0,0 +1,27 @@ +import * as React from 'react' +import { describe, it, beforeEach, vi, expect } from 'vitest' +import { fireEvent, screen } from '@testing-library/react' +import { renderWithProviders } from '../../../testing/utils' + +import { ListButtonRadioButton } from '..' + +const render = (props: React.ComponentProps) => + renderWithProviders() + +describe('ListButtonRadioButton', () => { + let props: React.ComponentProps + + beforeEach(() => { + props = { + buttonText: 'mock text', + buttonValue: 'mockValue', + onChange: vi.fn(), + } + }) + + it('should render non nested accordion', () => { + render(props) + fireEvent.click(screen.getByText('mock text')) + expect(props.onChange).toHaveBeenCalled() + }) +}) diff --git a/components/src/atoms/ListButton/index.tsx b/components/src/atoms/ListButton/index.tsx new file mode 100644 index 00000000000..3445254949e --- /dev/null +++ b/components/src/atoms/ListButton/index.tsx @@ -0,0 +1,70 @@ +import * as React from 'react' +import { css } from 'styled-components' +import { Flex } from '../../primitives' +import { SPACING } from '../../ui-style-constants' +import { BORDERS, COLORS } from '../../helix-design-system' +import type { StyleProps } from '../../primitives' + +export * from './ListButtonChildren/index' + +export type ListButtonType = 'noActive' | 'connected' | 'notConnected' + +interface ListButtonProps extends StyleProps { + type: ListButtonType + children: React.ReactNode + disabled?: boolean + onClick?: () => void +} + +const LISTBUTTON_PROPS_BY_TYPE: Record< + ListButtonType, + { backgroundColor: string; hoverBackgroundColor: string } +> = { + noActive: { + backgroundColor: COLORS.grey30, + hoverBackgroundColor: COLORS.grey35, + }, + connected: { + backgroundColor: COLORS.green30, + hoverBackgroundColor: COLORS.green35, + }, + notConnected: { + backgroundColor: COLORS.yellow30, + hoverBackgroundColor: COLORS.yellow35, + }, +} + +/* + ListButton is used in helix + TODO(ja, 8/12/24): shuld be used in ODD as well and need to add + odd stylings +**/ +export function ListButton(props: ListButtonProps): JSX.Element { + const { type, children, disabled, onClick, ...styleProps } = props + const listButtonProps = LISTBUTTON_PROPS_BY_TYPE[type] + + const LIST_BUTTON_STYLE = css` + cursor: pointer; + background-color: ${disabled + ? COLORS.grey35 + : listButtonProps.backgroundColor}; + max-width: 26.875rem; + padding: ${SPACING.spacing20} ${SPACING.spacing24}; + border-radius: ${BORDERS.borderRadius16}; + + &:hover { + background-color: ${listButtonProps.hoverBackgroundColor}; + } + ` + + return ( + + {children} + + ) +}