diff --git a/src/tedi/components/content/text-group/index.ts b/src/tedi/components/content/text-group/index.ts new file mode 100644 index 00000000..db9a2801 --- /dev/null +++ b/src/tedi/components/content/text-group/index.ts @@ -0,0 +1,2 @@ +export * from './text-group'; +export * from './text-group-list/text-group-list'; diff --git a/src/tedi/components/content/text-group/text-group-list/text-group-list.tsx b/src/tedi/components/content/text-group/text-group-list/text-group-list.tsx new file mode 100644 index 00000000..92730b66 --- /dev/null +++ b/src/tedi/components/content/text-group/text-group-list/text-group-list.tsx @@ -0,0 +1,134 @@ +import cn from 'classnames'; +import React from 'react'; + +import { BreakpointSupport, useBreakpointProps } from '../../../../helpers'; +import { Label } from '../../label/label'; +import styles from '../text-group.module.scss'; + +type TextAlign = 'left' | 'right'; + +type TextGroupListBreakpointProps = + | { + /** + * Type of text group layout. + */ + type?: 'horizontal'; + /** + * Alignment for the label text. + * @default 'left' + */ + labelAlign?: TextAlign; + /** + * Width for the label column (e.g., `'200px'`, `'30%'`, or a `number` + * interpreted as a percent). + * @default 'auto' + */ + labelWidth?: string | number; + } + | { + /** + * Type of text group layout. + */ + type: 'vertical'; + /** + * Alignment for the label text. Vertical layout only supports left + * alignment — pass `'right'` only with `type: 'horizontal'`. + * @default 'left' + */ + labelAlign?: 'left'; + /** + * Width for the label column (e.g., `'200px'`, `'30%'`, or a `number` + * interpreted as a percent). + * @default 'auto' + */ + labelWidth?: string | number; + }; + +export interface TextGroupListItem { + /** + * Label rendered as the `
` for this row. Strings are auto-wrapped in + * `
` for this row. + */ + value: React.ReactNode | React.ReactNode[]; + /** + * Per-row override of the list-level `labelAlign`. Falls back to the list's + * value when omitted. + */ + labelAlign?: TextAlign; + /** + * Per-row override of the list-level `labelWidth`. Falls back to the list's + * value when omitted. + */ + labelWidth?: string | number; +} + +export type TextGroupListProps = BreakpointSupport & { + /** + * Label / value pairs rendered together inside a **single** `
` element, + * preserving the definition-list semantics that stacking N individual + * ``s would break. + */ + items: TextGroupListItem[]; + /** + * Additional class name(s) to apply to the root `
` element. + */ + className?: string; +}; + +const renderLabelContent = (label: React.ReactNode): React.ReactNode => + typeof label === 'string' ? : label; + +const resolveLabelWidth = (labelWidth: string | number): string => + typeof labelWidth === 'number' ? `${labelWidth}%` : labelWidth; + +/** + * Multi-row variant of `TextGroup`. Visually identical to stacking N + * `` rows, but wraps every label / value pair in **one** semantic + * `
` — so screen readers announce them as one definition list, not N + * fragments. Reuse the same `type` / `labelWidth` / `labelAlign` knobs as the + * single-pair component; per-row overrides are available via `items[i]`. + */ +export const TextGroupList = (props: TextGroupListProps): JSX.Element => { + const { getCurrentBreakpointProps } = useBreakpointProps(props.defaultServerBreakpoint); + const { + items, + labelWidth = 'auto', + className, + type = 'vertical', + labelAlign = 'left', + } = getCurrentBreakpointProps(props); + + const listBEM = cn( + styles['tedi-text-group'], + styles['tedi-text-group--list'], + styles[`tedi-text-group--${type}`], + className + ); + const listLabelWidth = resolveLabelWidth(labelWidth); + + return ( +
+ {items.map((item, index) => { + const rowLabelAlign = item.labelAlign ?? labelAlign; + const rowStyle: React.CSSProperties | undefined = + item.labelWidth !== undefined + ? ({ '--label-width': resolveLabelWidth(item.labelWidth) } as React.CSSProperties) + : undefined; + return ( +
+
+ {renderLabelContent(item.label)} +
+
{item.value}
+
+ ); + })} +
+ ); +}; + +TextGroupList.displayName = 'TextGroup.List'; diff --git a/src/tedi/components/content/text-group/text-group.module.scss b/src/tedi/components/content/text-group/text-group.module.scss index b27f4f30..ac29648e 100644 --- a/src/tedi/components/content/text-group/text-group.module.scss +++ b/src/tedi/components/content/text-group/text-group.module.scss @@ -26,4 +26,22 @@ &--align-right { justify-content: flex-end; } + + &--list { + display: flex; + flex-direction: column; + gap: var(--tedi-dimensions-03); + margin: 0; + } + + &__row { + display: flex; + flex-direction: column; + } + + &--list#{&}--horizontal > &__row { + flex-direction: row; + gap: 1rem; + align-items: flex-start; + } } diff --git a/src/tedi/components/content/text-group/text-group.spec.tsx b/src/tedi/components/content/text-group/text-group.spec.tsx index f7867afb..31b4f167 100644 --- a/src/tedi/components/content/text-group/text-group.spec.tsx +++ b/src/tedi/components/content/text-group/text-group.spec.tsx @@ -1,9 +1,20 @@ -import { render } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; +import { useBreakpointProps } from '../../../helpers'; import { TextGroup } from './text-group'; import '@testing-library/jest-dom'; +jest.mock('../../../helpers', () => ({ + useBreakpointProps: jest.fn(), +})); + +beforeEach(() => { + (useBreakpointProps as jest.Mock).mockImplementation(() => ({ + getCurrentBreakpointProps: >(props: T): T => ({ ...props }), + })); +}); + describe('TextGroup component', () => { it('renders with default props', () => { const { container } = render(); @@ -134,3 +145,122 @@ describe('TextGroup component', () => { expect(dt?.querySelector('.tedi-label')).not.toBeInTheDocument(); }); }); + +describe('TextGroup.List', () => { + it('renders a single
with N
/
pairs', () => { + render( + + ); + + // Three semantic `term` (
) and three `definition` (
) entries grouped + // under the same definition list. RTL maps
→ "term" and
→ + // "definition" via dom-accessibility-api, so we can assert structure + // through accessible roles instead of DOM selectors. + const terms = screen.getAllByRole('term'); + const definitions = screen.getAllByRole('definition'); + expect(terms).toHaveLength(3); + expect(definitions).toHaveLength(3); + + expect(terms.map((dt) => dt.textContent?.trim())).toEqual(['Patient', 'Address', 'Vaccine']); + expect(definitions.map((dd) => dd.textContent?.trim())).toEqual([ + 'Mari Maasikas', + 'Tulbi tn 4, Tallinn', + 'COVID-19 mRNA', + ]); + + // All terms share the same parent
, so there's exactly one definition + // list wrapping the whole content. + const dl = terms[0].closest('dl'); + expect(dl).not.toBeNull(); + expect(terms.every((dt) => dt.closest('dl') === dl)).toBe(true); + expect(dl).toHaveClass('tedi-text-group'); + expect(dl).toHaveClass('tedi-text-group--list'); + }); + + it('applies the horizontal modifier when type="horizontal"', () => { + render( + + ); + + const dl = screen.getByText('A').closest('dl'); + expect(dl).toHaveClass('tedi-text-group--horizontal'); + expect(dl).toHaveStyle('--label-width: 200px'); + }); + + it('honors per-row labelAlign overrides', () => { + render( + + ); + + expect(screen.getByText('Subtotal').closest('dt')).toHaveClass('tedi-text-group--align-left'); + expect(screen.getByText('Total').closest('dt')).toHaveClass('tedi-text-group--align-right'); + }); + + it('honors per-row labelWidth overrides via inline --label-width', () => { + render( + + ); + + // Each row is the
's parent
; semantically "the group containing + // this label". Resolve it via the visible label text and walk up to the + // group rather than poking at a class name. + const rowOf = (labelName: string) => screen.getByText(labelName).closest('dt')?.parentElement as HTMLElement; + expect(rowOf('Default')).not.toHaveAttribute('style'); + expect(rowOf('Custom')).toHaveStyle('--label-width: 240px'); + expect(rowOf('Percent')).toHaveStyle('--label-width: 25%'); + }); + + it('renders string labels via