diff --git a/CODEOWNERS b/CODEOWNERS index c85f4dc1e7..3a004158c2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -24,6 +24,7 @@ /src/components/Menu @NikitaCG /src/components/Modal @amje /src/components/Pagination @jhoncool +/src/components/PinInput @amje /src/components/Popover @kseniya57 /src/components/Popup @amje /src/components/Portal @amje diff --git a/src/components/PinInput/PinInput.scss b/src/components/PinInput/PinInput.scss new file mode 100644 index 0000000000..dd554e23d7 --- /dev/null +++ b/src/components/PinInput/PinInput.scss @@ -0,0 +1,46 @@ +@use '../variables'; + +$block: '.#{variables.$ns}pin-input'; + +#{$block} { + display: inline-block; + + &__items { + display: inline-flex; + gap: var(--_--gap); + } + + &__item { + flex: 0 0 auto; + width: var(--_--item-width); + } + + &__control { + // stylelint-disable declaration-no-important + padding-inline: 0 !important; + text-align: center; + appearance: none; + } + + &_size { + &_s { + --_--item-width: 22px; + --_--gap: 6px; + } + + &_m { + --_--item-width: 26px; + --_--gap: 8px; + } + + &_l { + --_--item-width: 34px; + --_--gap: 10px; + } + + &_xl { + --_--item-width: 42px; + --_--gap: 12px; + } + } +} diff --git a/src/components/PinInput/PinInput.tsx b/src/components/PinInput/PinInput.tsx new file mode 100644 index 0000000000..8c8169e75b --- /dev/null +++ b/src/components/PinInput/PinInput.tsx @@ -0,0 +1,275 @@ +import React from 'react'; + +import {KeyCode} from '../../constants'; +import {useControlledState, useUniqId} from '../../hooks'; +import type {TextInputProps, TextInputSize} from '../controls'; +import {TextInput} from '../controls'; +import {OuterAdditionalContent} from '../controls/common/OuterAdditionalContent/OuterAdditionalContent'; +import {useDirection} from '../theme'; +import type {DOMProps, QAProps} from '../types'; +import {block} from '../utils/cn'; + +import './PinInput.scss'; + +export type PinInputSize = TextInputSize; +export type PinInputType = 'numeric' | 'alphanumeric'; + +export interface PinInputProps extends DOMProps, QAProps { + value?: string[]; + defaultValue?: string[]; + onUpdate?: (value: string[]) => void; + onUpdateComplete?: (value: string[]) => void; + length?: number; + size?: PinInputSize; + type?: PinInputType; + id?: string; + name?: string; + placeholder?: string; + disabled?: boolean; + autoFocus?: boolean; + otp?: boolean; + mask?: boolean; + note?: TextInputProps['note']; + validationState?: TextInputProps['validationState']; + errorMessage?: TextInputProps['errorMessage']; + 'aria-label'?: string; + 'aria-labelledby'?: string; + 'aria-describedby'?: string; +} + +const b = block('pin-input'); +const NUMERIC_REGEXP = /[0-9]+/; +const ALPHANUMERIC_REGEXP = /[0-9a-z]+/i; + +const validate = (type: PinInputType, newValue: string) => { + if (type === 'numeric') { + return NUMERIC_REGEXP.test(newValue); + } else { + return ALPHANUMERIC_REGEXP.test(newValue); + } +}; + +export const PinInput = React.forwardRef((props, ref) => { + const { + value, + defaultValue, + onUpdate, + onUpdateComplete, + length = 4, + size = 'm', + type = 'numeric', + id, + name, + placeholder, + disabled, + autoFocus, + otp, + mask, + note, + validationState, + errorMessage, + className, + style, + qa, + } = props; + const refs = React.useRef>({}); + const [activeIndex, setActiveIndex] = React.useState(0); + const [focusedIndex, setFocusedIndex] = React.useState(-1); + const updateCallback = React.useCallback( + (newValue: string[]) => { + if (onUpdate) { + onUpdate(newValue); + } + + if (onUpdateComplete && newValue.every((v) => Boolean(v))) { + onUpdateComplete(newValue); + } + }, + [onUpdate, onUpdateComplete], + ); + const [values, setValues] = useControlledState( + value, + defaultValue ?? Array.from({length}, () => ''), + updateCallback, + ); + const direction = useDirection(); + const errorMessageId = useUniqId(); + const noteId = useUniqId(); + const isErrorMsgVisible = validationState === 'invalid' && errorMessage; + const ariaDescribedBy = [ + props?.['aria-describedby'], + note ? noteId : undefined, + isErrorMsgVisible ? errorMessageId : undefined, + ] + .filter(Boolean) + .join(' '); + + const handleRef = (index: number, inputRef: HTMLInputElement | null) => { + refs.current[index] = inputRef; + }; + + const focus = (index: number) => { + setActiveIndex(index); + refs.current[index]?.focus(); + }; + + const focusPrev = (index: number) => { + if (index > 0) { + focus(index - 1); + } + }; + + const focusNext = (index: number) => { + if (index < length - 1) { + focus(index + 1); + } + }; + + const setValuesAtIndex = (index: number, nextValue: string) => { + // Normalize array size to length prop + const newValues = Array.from({length}, (__, i) => values[i] ?? ''); + + if (nextValue.length > 0) { + // Fill the subsequent inputs as well as the target input + for (let k = 0; k < nextValue.length && index + k < newValues.length; k++) { + newValues[index + k] = nextValue[k]; + } + } else { + newValues[index] = ''; + } + + // If values are the same then do not update + if (newValues.every((__, i) => newValues[i] === values[i])) { + return; + } + + setValues(newValues); + }; + + const handleInputChange = (i: number, event: React.ChangeEvent) => { + let nextValue = event.currentTarget.value; + const currentValue = values[i]; + + if (currentValue) { + // Remove the current value from the new value + if (currentValue === nextValue[0]) { + nextValue = nextValue.slice(1); + } else if (currentValue === nextValue[nextValue.length - 1]) { + nextValue = nextValue.slice(0, -1); + } + } + + if (!validate(type, nextValue)) { + return; + } + + // If value's length greater than 1, then it's a paste so inserting at the start + if (nextValue.length > 1) { + setValuesAtIndex(0, nextValue); + focusNext(nextValue.length - 1); + } else { + setValuesAtIndex(i, nextValue); + focusNext(i); + } + }; + + const handleInputKeyDown = (i: number, event: React.KeyboardEvent) => { + switch (event.code) { + case KeyCode.BACKSPACE: + event.preventDefault(); + + if (event.currentTarget.value) { + setValuesAtIndex(i, ''); + } else if (i > 0) { + setValuesAtIndex(i - 1, ''); + focusPrev(i); + } + + break; + case KeyCode.ARROW_LEFT: + case KeyCode.ARROW_UP: + event.preventDefault(); + + if (direction === 'rtl' && event.code === KeyCode.ARROW_LEFT) { + focusNext(i); + } else { + focusPrev(i); + } + + break; + case KeyCode.ARROW_RIGHT: + case KeyCode.ARROW_DOWN: + event.preventDefault(); + + if (direction === 'rtl' && event.code === KeyCode.ARROW_RIGHT) { + focusPrev(i); + } else { + focusNext(i); + } + + break; + } + }; + + const handleFocus = (index: number) => { + setFocusedIndex(index); + }; + + const handleBlur = () => { + setFocusedIndex(-1); + }; + + React.useEffect(() => { + if (autoFocus) { + focus(0); + } + // We only care about autofocus on initial render + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
+ {Array.from({length}).map((__, i) => ( +
+ +
+ ))} +
+ +
+ ); +}); + +PinInput.displayName = 'PinInput'; diff --git a/src/components/PinInput/README.md b/src/components/PinInput/README.md new file mode 100644 index 0000000000..01e68242f6 --- /dev/null +++ b/src/components/PinInput/README.md @@ -0,0 +1,186 @@ + + +# PinInput + + + +```tsx +import {PinInput} from '@gravity-ui/uikit'; +``` + +`PinInput` is a group of inputs to enter sequence of numeric or alphanumeric values quickly. The most common use case of the component +is entering OTP or confirmation codes received via SMS, email or authenticator app. + +Each input collects one character at time. When value is accepted, focus is moved to the next input, until all fields are filled. + +## Type + +By default, inputs accept only numeric values. To allow alphanumeric values set the `type` prop to `"alphanumeric"`: + + + + + +```tsx + +``` + + + +## Size + +The component comes in four sizes: `s`, `m`, `l`, `xl`. The default is `m`. + + + + + +```tsx + + + + +``` + + + +## State + +If you don't want the user to interact with the component set the `disabled` prop: + + + + + +```tsx + +``` + + + +To show an invalid state of the component use the `validationState` prop with the `"invalid"` value. Optionally set an error text +with the `errorMessage` prop: + + + + + +```tsx + +``` + + + +## Placeholder + +By default, there is no placeholder on inputs. You can set it with the `placeholder` prop: + + + + + +```tsx + +``` + + + +## Mask + +If you need to mask entered values use the `mask` prop. It's similar to `` behaviour. + + + + + +```tsx + +``` + + + +## OTP + +If you want the browser to suggest "one time codes" from the outer context (e.g. SMS) set the `otp` prop. + +## Properties + +| Name | Description | Type | Default | +| :--------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :---------: | +| aria-describedby | HTML `aria-describedby` attribute | `string` | | +| aria-label | HTML `aria-label` attribute | `string` | | +| aria-labelledby | HTML `aria-labelledby` attribute | `string` | | +| autoFocus | Whether or not to focus the first input on initial render | `boolean` | | +| className | HTML `class` attribute | `string` | | +| defaultValue | Initial value for uncontrolled component | `string[]` | | +| disabled | Toggles `disabled` state | `boolean` | | +| errorMessage | Error text placed under the bottom-start corner that shares space with the note container. Only visible when `validationState` is set to `"invalid"` | `React.ReactNode` | | +| id | HTML `id` attribute prefix for inputs. Resulting id will also contain `"-${index}"` part | `string` | | +| length | Number of input fields | `number` | `4` | +| mask | When set to `true` mask input values like password field | `boolean` | | +| name | HTML `name` attribute for inputs | `string` | | +| note | An element placed under the bottom-end corner that shares space with the error container | `React.ReactNode` | | +| onUpdate | Callback fired when any of inputs change | `(value: string[]) => void` | | +| onUpdateComplete | Callback fired when any of inputs change and all of them are filled | `(value: string[]) => void` | | +| otp | When set to `true` adds `autocomplete="one-time-code"` to inputs | `boolean` | | +| placeholder | Placeholder for inputs | `string` | | +| qa | HTML `data-qa` attribute, for test purposes | `string` | | +| size | Size of input fields | `"s"` `"m"` `"l"` `"xl"` | `"m"` | +| style | HTML `style` attribute | `React.CSSProperties` | | +| type | What type of input value is allowed | `"numeric"` `"alphanumeric"` | `"numeric"` | +| validationState | Validation state. Affect component's appearance | `"invalid"` | | +| value | Current value for controlled component | `string[]` | | diff --git a/src/components/PinInput/__stories__/Docs.mdx b/src/components/PinInput/__stories__/Docs.mdx new file mode 100644 index 0000000000..d9bf9dde21 --- /dev/null +++ b/src/components/PinInput/__stories__/Docs.mdx @@ -0,0 +1,7 @@ +import {Meta, Markdown} from '@storybook/addon-docs'; +import * as Stories from './PinInput.stories'; +import Readme from '../README.md?raw'; + + + +{Readme} diff --git a/src/components/PinInput/__stories__/PinInput.stories.tsx b/src/components/PinInput/__stories__/PinInput.stories.tsx new file mode 100644 index 0000000000..a15d54e8c0 --- /dev/null +++ b/src/components/PinInput/__stories__/PinInput.stories.tsx @@ -0,0 +1,116 @@ +import React from 'react'; + +import {action} from '@storybook/addon-actions'; +import type {Meta, StoryObj} from '@storybook/react'; + +import {Showcase} from '../../../demo/Showcase'; +import {ShowcaseItem} from '../../../demo/ShowcaseItem'; +import type {PinInputProps} from '../PinInput'; +import {PinInput} from '../PinInput'; + +export default { + title: 'Components/Inputs/PinInput', + component: PinInput, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + onUpdate: action('onUpdate'), + onUpdateComplete: action('onUpdateComplete'), + 'aria-label': 'PIN code', + }, +}; + +export const Length: Story = { + args: { + ...Default.args, + length: 6, + }, +}; + +export const Size: Story = { + render: (args) => ( + + + + + + + + + + + + + + + ), + args: Default.args, +}; + +export const Type: Story = { + render: (args) => ( + + + + + + + + + ), + args: Default.args, +}; + +export const InitialValue: Story = { + args: { + ...Default.args, + defaultValue: ['1', '2', '3', '4'], + }, +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true, + }, +}; + +export const Placeholder: Story = { + args: { + ...Default.args, + placeholder: '●', + }, +}; + +export const Mask: Story = { + args: { + ...Default.args, + mask: true, + }, +}; + +export const Invalid: Story = { + args: { + ...Default.args, + validationState: 'invalid', + }, +}; + +export const InvalidMessage: Story = { + args: { + ...Default.args, + validationState: 'invalid', + errorMessage: 'Incorrect PIN', + }, + name: 'Invalid + Message', +}; + +export const WithNote: Story = { + args: { + ...Default.args, + note: 'Additional info', + }, +}; diff --git a/src/components/PinInput/__tests__/PinInput.test.tsx b/src/components/PinInput/__tests__/PinInput.test.tsx new file mode 100644 index 0000000000..c0cc5ebcb7 --- /dev/null +++ b/src/components/PinInput/__tests__/PinInput.test.tsx @@ -0,0 +1,274 @@ +import React from 'react'; + +import userEvent from '@testing-library/user-event'; + +import {act, fireEvent, render, screen} from '../../../../test-utils/utils'; +import {PinInput} from '../PinInput'; + +describe('PinInput', () => { + let inputs: HTMLElement[]; + + function renderComponent(jsx: React.ReactElement) { + // eslint-disable-next-line testing-library/render-result-naming-convention + const result = render(jsx); + // eslint-disable-next-line testing-library/no-container,testing-library/no-node-access + inputs = [...result.container.querySelectorAll('input')]; + return result; + } + + function expectInputsHaveValues(value: string[]) { + inputs.forEach((input, i) => { + expect(input).toHaveValue(value[i]); + }); + } + + test('render defaults', () => { + renderComponent(); + expect(inputs.length).toBe(4); + }); + + test('set default value', () => { + renderComponent(); + expectInputsHaveValues(['t', 'e', 's', 't']); + }); + + test('set length', () => { + renderComponent(); + expect(inputs.length).toBe(6); + }); + + test('set size', () => { + const {container} = renderComponent(); + // eslint-disable-next-line testing-library/no-container,testing-library/no-node-access + const elements = container.querySelectorAll('.g-text-input_size_l'); + expect(elements.length).toBe(4); + }); + + test('set type=numeric', () => { + renderComponent(); + inputs.forEach((input) => expect(input).toHaveAttribute('inputmode', 'numeric')); + }); + + test('set type=alphanumeric', () => { + renderComponent(); + inputs.forEach((input) => expect(input).toHaveAttribute('inputmode', 'text')); + }); + + test('set disabled', () => { + renderComponent(); + inputs.forEach((input) => expect(input).toHaveAttribute('disabled')); + }); + + test('set placeholder', () => { + renderComponent(); + inputs.forEach((input) => expect(input).toHaveAttribute('placeholder', 'X')); + }); + + test('omit placeholder when focused', () => { + renderComponent(); + act(() => { + inputs[1].focus(); + }); + inputs.forEach((input, i) => { + if (i === 1) { + expect(input).not.toHaveAttribute('placeholder'); + } else { + expect(input).toHaveAttribute('placeholder', 'X'); + } + }); + }); + + test('set mask', () => { + const {container} = renderComponent(); + // eslint-disable-next-line testing-library/no-container,testing-library/no-node-access + const elements = container.querySelectorAll('input[type="password"]'); + expect(elements.length).toBeGreaterThan(0); + }); + + test('set otp', () => { + renderComponent(); + inputs.forEach((input) => expect(input).toHaveAttribute('autocomplete', 'one-time-code')); + }); + + test('set invalid state', () => { + renderComponent(); + inputs.forEach((input) => expect(input).toHaveAttribute('aria-invalid')); + }); + + test('set error message', () => { + renderComponent(); + const msg = screen.getByText('test error'); + expect(msg).toBeVisible(); + }); + + test('set note', () => { + renderComponent(); + const note = screen.getByText('test note'); + expect(note).toBeVisible(); + }); + + test('call onUpdate when any input changed', async () => { + const onUpdate = jest.fn(); + renderComponent(); + + fireEvent.change(inputs[1], {target: {value: '1'}}); + fireEvent.change(inputs[3], {target: {value: '2'}}); + + expect(onUpdate).toHaveBeenCalledTimes(2); + expect(onUpdate).toHaveBeenNthCalledWith(1, ['', '1', '', '']); + expect(onUpdate).toHaveBeenNthCalledWith(2, ['', '1', '', '2']); + }); + + test('call onUpdateComplete only when all inputs filled', async () => { + const onUpdateComplete = jest.fn(); + renderComponent(); + + fireEvent.change(inputs[0], {target: {value: '0'}}); + fireEvent.change(inputs[1], {target: {value: '1'}}); + fireEvent.change(inputs[2], {target: {value: '2'}}); + expect(onUpdateComplete).not.toHaveBeenCalled(); + fireEvent.change(inputs[3], {target: {value: '3'}}); + expect(onUpdateComplete).toHaveBeenCalledTimes(1); + expect(onUpdateComplete).toHaveBeenCalledWith(['0', '1', '2', '3']); + fireEvent.change(inputs[0], {target: {value: ''}}); + expect(onUpdateComplete).toHaveBeenCalledTimes(1); + }); + + test('call onUpdate only for valid value when type=numeric', () => { + const onUpdate = jest.fn(); + renderComponent(); + + fireEvent.change(inputs[0], {target: {value: '1'}}); + fireEvent.change(inputs[0], {target: {value: 'x'}}); + fireEvent.change(inputs[0], {target: {value: '@'}}); + + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(inputs[0]).toHaveValue('1'); + }); + + test('call onUpdate only for valid value when type=alphanumeric', () => { + const onUpdate = jest.fn(); + renderComponent(); + + fireEvent.change(inputs[0], {target: {value: '1'}}); + fireEvent.change(inputs[0], {target: {value: 'x'}}); + fireEvent.change(inputs[0], {target: {value: '@'}}); + + expect(onUpdate).toHaveBeenCalledTimes(2); + expect(inputs[0]).toHaveValue('x'); + }); + + xtest('replace current input value on change', () => {}); + + test('typing via keyboard', async () => { + const user = userEvent.setup(); + const onUpdate = jest.fn(); + renderComponent(); + + await user.click(inputs[0]); + await user.keyboard('123456'); + + expect(onUpdate).toHaveBeenNthCalledWith(1, ['1', '', '', '']); + expect(onUpdate).toHaveBeenNthCalledWith(2, ['1', '2', '', '']); + expect(onUpdate).toHaveBeenNthCalledWith(3, ['1', '2', '3', '']); + expect(onUpdate).toHaveBeenNthCalledWith(4, ['1', '2', '3', '4']); + expect(onUpdate).toHaveBeenNthCalledWith(5, ['1', '2', '3', '5']); + expect(onUpdate).toHaveBeenNthCalledWith(6, ['1', '2', '3', '6']); + expectInputsHaveValues(['1', '2', '3', '6']); + expect(inputs[3]).toHaveFocus(); + }); + + test('paste from clipboard', async () => { + const user = userEvent.setup(); + const onUpdate = jest.fn(); + renderComponent(); + + // Paste shorter string than input count + await user.click(inputs[0]); + await user.paste('123'); + expectInputsHaveValues(['1', '2', '3', '']); + expect(onUpdate).toHaveBeenLastCalledWith(['1', '2', '3', '']); + expect(inputs[3]).toHaveFocus(); + + // Paste longer string than input count + await user.click(inputs[0]); + await user.paste('123456'); + expectInputsHaveValues(['1', '2', '3', '4']); + expect(onUpdate).toHaveBeenLastCalledWith(['1', '2', '3', '4']); + expect(inputs[0]).toHaveFocus(); + + // Paste while focused at not the first input + await user.click(inputs[2]); + await user.paste('9999'); + expectInputsHaveValues(['9', '9', '9', '9']); + expect(onUpdate).toHaveBeenLastCalledWith(['9', '9', '9', '9']); + expect(inputs[2]).toHaveFocus(); + + // Paste one symbol + await user.click(inputs[1]); + await user.paste('0'); + expectInputsHaveValues(['9', '0', '9', '9']); + expect(onUpdate).toHaveBeenLastCalledWith(['9', '0', '9', '9']); + expect(inputs[2]).toHaveFocus(); + }); + + test('backspace full deletion', async () => { + const user = userEvent.setup(); + renderComponent(); + + expectInputsHaveValues(['1', '2', '3', '4']); + await user.click(inputs[3]); + // Pressing "backspace" more times than input count + await user.keyboard('{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}'); + expectInputsHaveValues(['', '', '', '']); + expect(inputs[0]).toHaveFocus(); + }); + + test('move focus via left/right arrows', async () => { + const user = userEvent.setup(); + renderComponent(); + + await user.click(inputs[0]); + expect(inputs[0]).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(inputs[1]).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(inputs[2]).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(inputs[3]).toHaveFocus(); + await user.keyboard('{ArrowRight}'); + expect(inputs[3]).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(inputs[2]).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(inputs[1]).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(inputs[0]).toHaveFocus(); + await user.keyboard('{ArrowLeft}'); + expect(inputs[0]).toHaveFocus(); + }); + + test('move focus via up/down arrows', async () => { + const user = userEvent.setup(); + renderComponent(); + + await user.click(inputs[0]); + expect(inputs[0]).toHaveFocus(); + await user.keyboard('{ArrowDown}'); + expect(inputs[1]).toHaveFocus(); + await user.keyboard('{ArrowDown}'); + expect(inputs[2]).toHaveFocus(); + await user.keyboard('{ArrowDown}'); + expect(inputs[3]).toHaveFocus(); + await user.keyboard('{ArrowDown}'); + expect(inputs[3]).toHaveFocus(); + await user.keyboard('{ArrowUp}'); + expect(inputs[2]).toHaveFocus(); + await user.keyboard('{ArrowUp}'); + expect(inputs[1]).toHaveFocus(); + await user.keyboard('{ArrowUp}'); + expect(inputs[0]).toHaveFocus(); + await user.keyboard('{ArrowUp}'); + expect(inputs[0]).toHaveFocus(); + }); +}); diff --git a/src/components/PinInput/index.ts b/src/components/PinInput/index.ts new file mode 100644 index 0000000000..8102b262c5 --- /dev/null +++ b/src/components/PinInput/index.ts @@ -0,0 +1 @@ +export * from './PinInput'; diff --git a/src/components/index.ts b/src/components/index.ts index 8a59a62982..56fbfb39e4 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -28,6 +28,7 @@ export * from './Modal'; export * from './Pagination'; export * from './Palette'; export * from './UserLabel'; +export * from './PinInput'; export * from './Popover'; export * from './Popup'; export * from './Portal'; diff --git a/src/constants.ts b/src/constants.ts index 97396f5e8a..a5cae9e00c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -8,4 +8,6 @@ export const KeyCode = { ESCAPE: 'Escape', ARROW_UP: 'ArrowUp', ARROW_DOWN: 'ArrowDown', + ARROW_LEFT: 'ArrowLeft', + ARROW_RIGHT: 'ArrowRight', }; diff --git a/src/demo/Showcase/Showcase.scss b/src/demo/Showcase/Showcase.scss index b8c47be1c2..d231159e4c 100644 --- a/src/demo/Showcase/Showcase.scss +++ b/src/demo/Showcase/Showcase.scss @@ -28,4 +28,9 @@ margin-block-start: var(--g-spacing-5); } } + + &_direction_column &__content { + flex-direction: column; + align-items: flex-start; + } } diff --git a/src/demo/Showcase/Showcase.tsx b/src/demo/Showcase/Showcase.tsx index 486c70ccf0..a81cac2d7f 100644 --- a/src/demo/Showcase/Showcase.tsx +++ b/src/demo/Showcase/Showcase.tsx @@ -7,14 +7,15 @@ import './Showcase.scss'; type Props = React.PropsWithChildren<{ title?: string; description?: React.ReactNode; + direction?: 'row' | 'column'; className?: string; }>; const b = cn('showcase'); -export function Showcase({title, description, className, children}: Props) { +export function Showcase({title, description, direction = 'row', className, children}: Props) { return ( -
+
{title &&
{title}
} {description &&
{description}
}
{children}