diff --git a/packages/ui/src/components/CubeStatus/CubeStatus.test.tsx b/packages/ui/src/components/CubeStatus/CubeStatus.test.tsx new file mode 100644 index 0000000..e271ed5 --- /dev/null +++ b/packages/ui/src/components/CubeStatus/CubeStatus.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react' +import { axe } from 'jest-axe' +import { CubeStatus } from './CubeStatus' + +describe('CubeStatus', () => { + it.each([ + ['neutral', 'Neutral'], + ['success', 'Success'], + ['warning', 'Warning'], + ])('renders the translated text for the %s status', (status, text) => { + render() + + expect(screen.getByText(text)).toBeInTheDocument() + }) + + it('formats an unrecognized status into display text', () => { + render() + + expect(screen.getByText('Anything-else')).toBeInTheDocument() + }) + + it('renders the custom message instead of the translated status text', () => { + render() + + expect(screen.getByText('Custom message')).toBeInTheDocument() + expect(screen.queryByText('Success')).not.toBeInTheDocument() + }) + + it('renders its skeleton via the compound API', () => { + const { container } = render() + + expect(container.querySelector('span')).not.toBeInTheDocument() + }) + + it('has no accessibility violations', async () => { + const { container } = render() + + expect(await axe(container)).toHaveNoViolations() + }) +}) diff --git a/packages/ui/src/components/CubeStatus/CubeStatus.tsx b/packages/ui/src/components/CubeStatus/CubeStatus.tsx new file mode 100644 index 0000000..721daf6 --- /dev/null +++ b/packages/ui/src/components/CubeStatus/CubeStatus.tsx @@ -0,0 +1,43 @@ +import { twMerge } from 'tailwind-merge' +import { + computeStatusType, + formatOtherStatusText, + type CubeStatusValue, +} from './cubeStatusUtils' +import { status as statusStyles } from './cubeStatusStyles' +import { useCubeStatusTranslation } from './useCubeStatusTranslation' +import { CubeStatusSkeleton } from './CubeStatusSkeleton' + +export type CubeStatusProps = { + status: CubeStatusValue + /** + * Custom display text for the status text. + * If not specified, the text will be automatically generated + * based on the built-in logic and i18n translations. + */ + message?: string +} + +export const CubeStatus = (props: CubeStatusProps) => { + const { status, message } = props + + const type = computeStatusType(status) + + const translationMap = useCubeStatusTranslation() + + const getMessage = () => { + if (message) { + return message + } + + if (status in translationMap) { + return translationMap[status] + } + + return formatOtherStatusText(status) + } + + return {getMessage()} +} + +CubeStatus.Skeleton = CubeStatusSkeleton diff --git a/packages/ui/src/components/CubeStatus/CubeStatusSkeleton.tsx b/packages/ui/src/components/CubeStatus/CubeStatusSkeleton.tsx new file mode 100644 index 0000000..c75ae45 --- /dev/null +++ b/packages/ui/src/components/CubeStatus/CubeStatusSkeleton.tsx @@ -0,0 +1,5 @@ +import { CubeSkeleton } from '../CubeSkeleton' + +export const CubeStatusSkeleton = () => { + return +} diff --git a/packages/ui/src/components/CubeStatus/cubeStatusStyles.tsx b/packages/ui/src/components/CubeStatus/cubeStatusStyles.tsx new file mode 100644 index 0000000..ceb4656 --- /dev/null +++ b/packages/ui/src/components/CubeStatus/cubeStatusStyles.tsx @@ -0,0 +1,20 @@ +import { cva } from 'class-variance-authority' +import type { ClassValue } from 'class-variance-authority/types' +import type { CubeStatusType } from './cubeStatusUtils' + +export const status = cva( + [ + 'flex h-[19px] w-fit cursor-default items-center rounded-[20px] border px-2.5', + 'secondary-body6 whitespace-nowrap font-semibold', + ], + { + variants: { + type: { + neutral: 'border-cosmos-primary text-cosmos-primary', + success: 'border-status-positive text-status-positive', + warning: 'border-status-negative text-status-negative', + others: 'border-functional-text-light text-functional-text-light', + } satisfies Record, + }, + }, +) diff --git a/packages/ui/src/components/CubeStatus/cubeStatusUtils.ts b/packages/ui/src/components/CubeStatus/cubeStatusUtils.ts new file mode 100644 index 0000000..f73e4c9 --- /dev/null +++ b/packages/ui/src/components/CubeStatus/cubeStatusUtils.ts @@ -0,0 +1,67 @@ +const neutralStatuses = ['neutral', 'in-use', 'finished'] as const + +const successStatuses = [ + 'ok', + 'success', + 'active', + 'available', + 'powering on', +] as const + +const warningStatuses = [ + 'warning', + 'error', + 'fail', + 'failed', + 'stopped', + 'powering off', +] as const + +export const cubeStatusKnownValues = [ + ...neutralStatuses, + ...successStatuses, + ...warningStatuses, +] as const + +type CubeStatusKnownValue = (typeof cubeStatusKnownValues)[number] + +// Use `string & {}` alongside the known literals to allow those specific +// strings to autocomplete while still accepting any string. +// Reference: https://stackoverflow.com/a/61048124/19772349 +export type CubeStatusValue = (string & {}) | CubeStatusKnownValue + +export type CubeStatusType = 'neutral' | 'success' | 'warning' | 'others' + +const neutralStatusSet = new Set(neutralStatuses) +const successStatusSet = new Set(successStatuses) +const warningStatusSet = new Set(warningStatuses) + +export const computeStatusType = (status: string): CubeStatusType => { + if (neutralStatusSet.has(status)) { + return 'neutral' + } else if (successStatusSet.has(status)) { + return 'success' + } else if (warningStatusSet.has(status)) { + return 'warning' + } + return 'others' +} + +/** + * Split a string using the `-` character and capitalize the first substring. + */ +export const formatOtherStatusText = (status: string): string => { + const substrings = status.split('-').filter((text) => !!text) + if (!substrings.length) { + // This should not happen. + return status + } + + // Capitalize the first substring. `substrings.length` is checked above, so + // the first element is always defined here. + const [firstSubstring = '', ...otherSubstrings] = substrings + const capitalizedFirstSubstring = + firstSubstring.charAt(0).toUpperCase() + firstSubstring.substring(1) + + return [capitalizedFirstSubstring, ...otherSubstrings].join('-') +} diff --git a/packages/ui/src/components/CubeStatus/index.ts b/packages/ui/src/components/CubeStatus/index.ts new file mode 100644 index 0000000..3395f44 --- /dev/null +++ b/packages/ui/src/components/CubeStatus/index.ts @@ -0,0 +1,3 @@ +export type { CubeStatusValue } from './cubeStatusUtils' + +export { type CubeStatusProps, CubeStatus } from './CubeStatus' diff --git a/packages/ui/src/components/CubeStatus/useCubeStatusTranslation.ts b/packages/ui/src/components/CubeStatus/useCubeStatusTranslation.ts new file mode 100644 index 0000000..30a5e3d --- /dev/null +++ b/packages/ui/src/components/CubeStatus/useCubeStatusTranslation.ts @@ -0,0 +1,22 @@ +import { useCubeUiTranslation } from '@i18n/useCubeUiTranslation' + +export const useCubeStatusTranslation = (): Record => { + const { t } = useCubeUiTranslation() + + return { + neutral: t('component.status.host.neutral'), + 'in-use': t('component.status.host.inUse'), + finished: t('component.status.host.finished'), + ok: t('component.status.host.ok'), + success: t('component.status.host.success'), + active: t('component.status.host.active'), + available: t('component.status.host.available'), + 'powering on': t('component.status.host.poweringOn'), + warning: t('component.status.host.warning'), + error: t('component.status.host.error'), + fail: t('component.status.host.fail'), + failed: t('component.status.host.failed'), + stopped: t('component.status.host.stopped'), + 'powering off': t('component.status.host.poweringOff'), + } +} diff --git a/packages/ui/src/components/CubeStatusReaction/CubeStatusReaction.test.tsx b/packages/ui/src/components/CubeStatusReaction/CubeStatusReaction.test.tsx new file mode 100644 index 0000000..24f58a5 --- /dev/null +++ b/packages/ui/src/components/CubeStatusReaction/CubeStatusReaction.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react' +import { axe } from 'jest-axe' +import { CubeStatusReaction } from './CubeStatusReaction' +import { + cubeStatusReactionStatuses, + type CubeStatusReactionStatus, +} from './cubeStatusReactionUtils' + +describe('CubeStatusReaction', () => { + it.each(cubeStatusReactionStatuses)( + 'renders the %s status without crashing', + (status: CubeStatusReactionStatus) => { + const { container } = render() + + expect(container.querySelector('svg')).toBeInTheDocument() + }, + ) + + it('renders the translated text for a status', () => { + render() + + expect(screen.getByText('Success')).toBeInTheDocument() + }) + + it('renders the custom message instead of the translated status text', () => { + render() + + expect(screen.getByText('Custom message')).toBeInTheDocument() + expect(screen.queryByText('Success')).not.toBeInTheDocument() + }) + + it('renders its skeleton via the compound API', () => { + const { container } = render() + + expect(container.querySelector('svg')).not.toBeInTheDocument() + }) + + it('has no accessibility violations', async () => { + const { container } = render() + + expect(await axe(container)).toHaveNoViolations() + }) +}) diff --git a/packages/ui/src/components/CubeStatusReaction/CubeStatusReaction.tsx b/packages/ui/src/components/CubeStatusReaction/CubeStatusReaction.tsx new file mode 100644 index 0000000..8024907 --- /dev/null +++ b/packages/ui/src/components/CubeStatusReaction/CubeStatusReaction.tsx @@ -0,0 +1,57 @@ +import { upperFirst } from 'lodash' +import { twMerge } from 'tailwind-merge' +import { + MonochromeCheckmarkBold, + MonochromeCheckmarkCircleFill, + MonochromeCrossFill, +} from '@icons' +import type { SvgComponent } from '../CubeIcon/cubeIconTypes' +import type { + CubeStatusReactionStatus, + CubeStatusReactionType, +} from './cubeStatusReactionUtils' +import { computeStatusType } from './cubeStatusReactionUtils' +import { statusReaction } from './cubeStatusReactionStyles' +import { useCubeStatusReactionTranslation } from './useCubeStatusReactionTranslation' +import { CubeStatusReactionSkeleton } from './CubeStatusReactionSkeleton' + +const iconMap: Record = { + neutral: MonochromeCheckmarkBold, + success: MonochromeCheckmarkCircleFill, + warning: MonochromeCrossFill, +} + +export type CubeStatusReactionProps = { + status: CubeStatusReactionStatus + message?: string +} + +export const CubeStatusReaction = (props: CubeStatusReactionProps) => { + const { status, message } = props + + const type = computeStatusType(status) + const Icon = iconMap[type] + + const translationMap = useCubeStatusReactionTranslation() + + const getMessage = () => { + if (message) { + return message + } + + if (status in translationMap) { + return translationMap[status] + } + + return upperFirst(status) + } + + return ( + + + {getMessage()} + + ) +} + +CubeStatusReaction.Skeleton = CubeStatusReactionSkeleton diff --git a/packages/ui/src/components/CubeStatusReaction/CubeStatusReactionSkeleton.tsx b/packages/ui/src/components/CubeStatusReaction/CubeStatusReactionSkeleton.tsx new file mode 100644 index 0000000..ff88fa4 --- /dev/null +++ b/packages/ui/src/components/CubeStatusReaction/CubeStatusReactionSkeleton.tsx @@ -0,0 +1,11 @@ +import { CubeSkeleton } from '../CubeSkeleton' +import { baseClass } from './cubeStatusReactionStyles' + +export const CubeStatusReactionSkeleton = () => { + return ( + + + + + ) +} diff --git a/packages/ui/src/components/CubeStatusReaction/cubeStatusReactionStyles.tsx b/packages/ui/src/components/CubeStatusReaction/cubeStatusReactionStyles.tsx new file mode 100644 index 0000000..2e17b53 --- /dev/null +++ b/packages/ui/src/components/CubeStatusReaction/cubeStatusReactionStyles.tsx @@ -0,0 +1,18 @@ +import { cva } from 'class-variance-authority' +import type { ClassValue } from 'class-variance-authority/types' +import type { CubeStatusReactionType } from './cubeStatusReactionUtils' + +export const baseClass = 'inline-flex w-fit items-center gap-x-2 px-2 py-[5px]' + +export const statusReaction = cva( + [baseClass, 'secondary-body3 whitespace-nowrap font-semibold'], + { + variants: { + type: { + neutral: 'text-status-neutral', + success: 'text-status-positive', + warning: 'text-status-negative', + } satisfies Record, + }, + }, +) diff --git a/packages/ui/src/components/CubeStatusReaction/cubeStatusReactionUtils.ts b/packages/ui/src/components/CubeStatusReaction/cubeStatusReactionUtils.ts new file mode 100644 index 0000000..200c497 --- /dev/null +++ b/packages/ui/src/components/CubeStatusReaction/cubeStatusReactionUtils.ts @@ -0,0 +1,33 @@ +const neutralStatuses = ['neutral'] as const + +const successStatuses = ['success', 'available', 'done'] as const + +const warningStatuses = ['error', 'duplicate', 'failed'] as const + +export const cubeStatusReactionStatuses = [ + ...neutralStatuses, + ...successStatuses, + ...warningStatuses, +] as const + +export type CubeStatusReactionStatus = + (typeof cubeStatusReactionStatuses)[number] + +export type CubeStatusReactionType = 'neutral' | 'success' | 'warning' + +const neutralStatusSet = new Set(neutralStatuses) +const successStatusSet = new Set(successStatuses) +const warningStatusSet = new Set(warningStatuses) + +export const computeStatusType = ( + status: CubeStatusReactionStatus, +): CubeStatusReactionType => { + if (neutralStatusSet.has(status)) { + return 'neutral' + } else if (successStatusSet.has(status)) { + return 'success' + } else if (warningStatusSet.has(status)) { + return 'warning' + } + throw new Error(`Status ${status} is not defined in CubeStatusReaction`) +} diff --git a/packages/ui/src/components/CubeStatusReaction/index.ts b/packages/ui/src/components/CubeStatusReaction/index.ts new file mode 100644 index 0000000..6e98a30 --- /dev/null +++ b/packages/ui/src/components/CubeStatusReaction/index.ts @@ -0,0 +1,6 @@ +export type { CubeStatusReactionStatus } from './cubeStatusReactionUtils' + +export { + type CubeStatusReactionProps, + CubeStatusReaction, +} from './CubeStatusReaction' diff --git a/packages/ui/src/components/CubeStatusReaction/useCubeStatusReactionTranslation.ts b/packages/ui/src/components/CubeStatusReaction/useCubeStatusReactionTranslation.ts new file mode 100644 index 0000000..76540e2 --- /dev/null +++ b/packages/ui/src/components/CubeStatusReaction/useCubeStatusReactionTranslation.ts @@ -0,0 +1,19 @@ +import { useCubeUiTranslation } from '@i18n/useCubeUiTranslation' +import type { CubeStatusReactionStatus } from './cubeStatusReactionUtils' + +export const useCubeStatusReactionTranslation = (): Record< + CubeStatusReactionStatus, + string +> => { + const { t } = useCubeUiTranslation() + + return { + neutral: t('component.status.reaction.neutral'), + success: t('component.status.reaction.success'), + available: t('component.status.reaction.available'), + done: t('component.status.reaction.done'), + error: t('component.status.reaction.error'), + duplicate: t('component.status.reaction.duplicate'), + failed: t('component.status.reaction.failed'), + } +} diff --git a/packages/ui/src/i18n/resources/en-US.json b/packages/ui/src/i18n/resources/en-US.json index ee893c9..dbb1a9c 100644 --- a/packages/ui/src/i18n/resources/en-US.json +++ b/packages/ui/src/i18n/resources/en-US.json @@ -1,4 +1,25 @@ { "component.common.loading": "Loading", - "component.common.noData": "No Data" + "component.common.noData": "No Data", + "component.status.host.neutral": "Neutral", + "component.status.host.inUse": "In-Use", + "component.status.host.finished": "Finished", + "component.status.host.ok": "Ok", + "component.status.host.success": "Success", + "component.status.host.active": "Active", + "component.status.host.available": "Available", + "component.status.host.poweringOn": "Powering-On", + "component.status.host.warning": "Warning", + "component.status.host.error": "Error", + "component.status.host.fail": "Fail", + "component.status.host.failed": "Failed", + "component.status.host.stopped": "Stopped", + "component.status.host.poweringOff": "Powering-Off", + "component.status.reaction.neutral": "Neutral", + "component.status.reaction.success": "Success", + "component.status.reaction.available": "Available", + "component.status.reaction.done": "Done", + "component.status.reaction.error": "Error", + "component.status.reaction.duplicate": "Duplicate", + "component.status.reaction.failed": "Failed" } diff --git a/packages/ui/src/i18n/resources/zh-TW.json b/packages/ui/src/i18n/resources/zh-TW.json index ac1fa71..8496ab7 100644 --- a/packages/ui/src/i18n/resources/zh-TW.json +++ b/packages/ui/src/i18n/resources/zh-TW.json @@ -1,4 +1,25 @@ { "component.common.loading": "載入中", - "component.common.noData": "沒有資料" + "component.common.noData": "沒有資料", + "component.status.host.neutral": "中性", + "component.status.host.inUse": "使用中", + "component.status.host.finished": "已完成", + "component.status.host.ok": "正常", + "component.status.host.success": "成功", + "component.status.host.active": "啟用", + "component.status.host.available": "可用", + "component.status.host.poweringOn": "啟動中", + "component.status.host.warning": "警告", + "component.status.host.error": "錯誤", + "component.status.host.fail": "失敗", + "component.status.host.failed": "已失敗", + "component.status.host.stopped": "已停止", + "component.status.host.poweringOff": "關機中", + "component.status.reaction.neutral": "中性", + "component.status.reaction.success": "成功", + "component.status.reaction.available": "可用", + "component.status.reaction.done": "完成", + "component.status.reaction.error": "錯誤", + "component.status.reaction.duplicate": "重複", + "component.status.reaction.failed": "失敗" } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 0412754..120fdad 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -8,6 +8,8 @@ export * from './components/CubeLoadingSpinner' export * from './components/CubePasswordInput' export * from './components/CubeRadioButton' export * from './components/CubeSkeleton' +export * from './components/CubeStatus' +export * from './components/CubeStatusReaction' export * from './components/CubeStroke' export * from './components/CubeTabs' export * from './components/CubeTag' diff --git a/packages/ui/src/stories/components/CubeStatus/CubeStatus.stories.tsx b/packages/ui/src/stories/components/CubeStatus/CubeStatus.stories.tsx new file mode 100644 index 0000000..138ba95 --- /dev/null +++ b/packages/ui/src/stories/components/CubeStatus/CubeStatus.stories.tsx @@ -0,0 +1,128 @@ +import type { ComponentType } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { + Controls, + Description, + Primary, + Title, +} from '@storybook/addon-docs/blocks' +import { formatUnionType } from '@internals/utils/formatType' +import { StoryLayout } from '@internals/components/StoryLayout/StoryLayout' +import { PlaygroundLayout } from '@internals/components/StoryLayout/PlaygroundLayout' +import { CubeStatus } from '@components/CubeStatus/CubeStatus' +import type { CubeStatusProps } from '@components/CubeStatus' +import { cubeStatusKnownValues } from '@components/CubeStatus/cubeStatusUtils' +import { StatusRow } from './StatusRow' + +const CubeStatusDocs = () => { + return ( + <> + + + + + > + ) +} + +const meta: Meta = { + title: 'Atoms/Status', + component: CubeStatus as ComponentType, + tags: ['autodocs'], + parameters: { + controls: { expanded: true }, + docs: { + description: { + component: + 'A compact badge that shows a resource or process status, with built-in text and coloring for a set of well-known status values.', + }, + page: CubeStatusDocs, + }, + }, + argTypes: { + status: { + description: + 'The status value. A set of well-known values get built-in translated text and coloring; any other string falls back to a generated "Others" style label.', + table: { type: { summary: formatUnionType(cubeStatusKnownValues) } }, + control: 'select', + options: cubeStatusKnownValues, + }, + message: { + description: + 'Custom display text. When omitted, text is derived from `status`.', + table: { type: { summary: 'string' } }, + control: 'text', + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Playground: Story = { + tags: ['!dev'], + decorators: [ + (Story) => ( + + + + ), + ], + args: { + status: 'success', + }, + render: (args) => , +} + +export const Gallery: Story = { + tags: ['!autodocs'], + parameters: { + actions: { disable: true }, + controls: { disable: true }, + interactions: { disable: true }, + options: { + rightPanelWidth: 0, + bottomPanelHeight: 0, + }, + }, + render: function Render() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + }, +} diff --git a/packages/ui/src/stories/components/CubeStatus/StatusRow.tsx b/packages/ui/src/stories/components/CubeStatus/StatusRow.tsx new file mode 100644 index 0000000..6bdaa2b --- /dev/null +++ b/packages/ui/src/stories/components/CubeStatus/StatusRow.tsx @@ -0,0 +1,17 @@ +import type { PropsWithChildren } from 'react' +import { SubHeading } from '@internals/components/SubHeading' + +export type StatusRowProps = PropsWithChildren<{ + title: string +}> + +export const StatusRow = (props: StatusRowProps) => { + const { children, title } = props + + return ( + + {title} + {children} + + ) +} diff --git a/packages/ui/src/stories/components/CubeStatusReaction/CubeStatusReaction.stories.tsx b/packages/ui/src/stories/components/CubeStatusReaction/CubeStatusReaction.stories.tsx new file mode 100644 index 0000000..24f5299 --- /dev/null +++ b/packages/ui/src/stories/components/CubeStatusReaction/CubeStatusReaction.stories.tsx @@ -0,0 +1,116 @@ +import type { ComponentType } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { + Controls, + Description, + Primary, + Title, +} from '@storybook/addon-docs/blocks' +import { formatUnionType } from '@internals/utils/formatType' +import { StoryLayout } from '@internals/components/StoryLayout/StoryLayout' +import { PlaygroundLayout } from '@internals/components/StoryLayout/PlaygroundLayout' +import { CubeStatusReaction } from '@components/CubeStatusReaction/CubeStatusReaction' +import type { CubeStatusReactionProps } from '@components/CubeStatusReaction' +import { cubeStatusReactionStatuses } from '@components/CubeStatusReaction/cubeStatusReactionUtils' +import { StatusReactionRow } from './StatusReactionRow' + +const CubeStatusReactionDocs = () => { + return ( + <> + + + + + > + ) +} + +const meta: Meta = { + title: 'Atoms/Status Reaction', + component: CubeStatusReaction as ComponentType, + tags: ['autodocs'], + parameters: { + controls: { expanded: true }, + docs: { + description: { + component: + 'An icon-and-text reaction to the outcome of an action, such as a form submission or a task completion.', + }, + page: CubeStatusReactionDocs, + }, + }, + argTypes: { + status: { + description: 'The outcome the reaction represents.', + table: { type: { summary: formatUnionType(cubeStatusReactionStatuses) } }, + control: 'select', + options: cubeStatusReactionStatuses, + }, + message: { + description: + 'Custom display text. When omitted, text is derived from `status`.', + table: { type: { summary: 'string' } }, + control: 'text', + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Playground: Story = { + tags: ['!dev'], + decorators: [ + (Story) => ( + + + + ), + ], + args: { + status: 'success', + }, + render: (args) => , +} + +export const Gallery: Story = { + tags: ['!autodocs'], + parameters: { + actions: { disable: true }, + controls: { disable: true }, + interactions: { disable: true }, + options: { + rightPanelWidth: 0, + bottomPanelHeight: 0, + }, + }, + render: function Render() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ) + }, +} diff --git a/packages/ui/src/stories/components/CubeStatusReaction/StatusReactionRow.tsx b/packages/ui/src/stories/components/CubeStatusReaction/StatusReactionRow.tsx new file mode 100644 index 0000000..f522d93 --- /dev/null +++ b/packages/ui/src/stories/components/CubeStatusReaction/StatusReactionRow.tsx @@ -0,0 +1,17 @@ +import type { PropsWithChildren } from 'react' +import { SubHeading } from '@internals/components/SubHeading' + +export type StatusReactionRowProps = PropsWithChildren<{ + title: string +}> + +export const StatusReactionRow = (props: StatusReactionRowProps) => { + const { children, title } = props + + return ( + + {title} + {children} + + ) +}