From ff01901eddf646fa8eaa2f6a739049bd854cd8c4 Mon Sep 17 00:00:00 2001 From: Airike Jaska <95303654+airikej@users.noreply.github.com> Date: Wed, 13 May 2026 13:28:41 +0300 Subject: [PATCH 1/3] feat(text-group): add TextGroupList feature #81 --- .../components/content/text-group/index.ts | 2 + .../text-group-list/text-group-list.tsx | 134 ++++++++++++++++++ .../content/text-group/text-group.module.scss | 18 +++ .../content/text-group/text-group.spec.tsx | 99 +++++++++++++ .../content/text-group/text-group.stories.tsx | 66 +++++++++ .../content/text-group/text-group.tsx | 21 ++- src/tedi/index.ts | 2 +- 7 files changed, 337 insertions(+), 5 deletions(-) create mode 100644 src/tedi/components/content/text-group/index.ts create mode 100644 src/tedi/components/content/text-group/text-group-list/text-group-list.tsx 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 000000000..db9a2801f --- /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 000000000..92730b661 --- /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 b27f4f30c..ac29648e3 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 f7867afb0..5a336b107 100644 --- a/src/tedi/components/content/text-group/text-group.spec.tsx +++ b/src/tedi/components/content/text-group/text-group.spec.tsx @@ -134,3 +134,102 @@ describe('TextGroup component', () => { expect(dt?.querySelector('.tedi-label')).not.toBeInTheDocument(); }); }); + +describe('TextGroup.List', () => { + it('renders a single
with N
/
pairs', () => { + const { container } = render( + + ); + + const lists = container.querySelectorAll('dl'); + expect(lists).toHaveLength(1); + expect(lists[0]).toHaveClass('tedi-text-group'); + expect(lists[0]).toHaveClass('tedi-text-group--list'); + + expect(container.querySelectorAll('dt')).toHaveLength(3); + expect(container.querySelectorAll('dd')).toHaveLength(3); + + const labels = Array.from(container.querySelectorAll('dt')).map((dt) => dt.textContent?.trim()); + expect(labels).toEqual(['Patient', 'Address', 'Vaccine']); + const values = Array.from(container.querySelectorAll('dd')).map((dd) => dd.textContent?.trim()); + expect(values).toEqual(['Mari Maasikas', 'Tulbi tn 4, Tallinn', 'COVID-19 mRNA']); + }); + + it('applies the horizontal modifier when type="horizontal"', () => { + const { container } = render( + + ); + + const dl = container.querySelector('dl'); + expect(dl).toHaveClass('tedi-text-group--horizontal'); + expect(dl).toHaveStyle('--label-width: 200px'); + }); + + it('honors per-row labelAlign overrides', () => { + const { container } = render( + + ); + + const dts = container.querySelectorAll('dt'); + expect(dts[0]).toHaveClass('tedi-text-group--align-left'); + expect(dts[1]).toHaveClass('tedi-text-group--align-right'); + }); + + it('honors per-row labelWidth overrides via inline --label-width', () => { + const { container } = render( + + ); + + const rows = container.querySelectorAll('.tedi-text-group__row'); + expect(rows[0]).not.toHaveAttribute('style'); + expect(rows[1]).toHaveStyle('--label-width: 240px'); + expect(rows[2]).toHaveStyle('--label-width: 25%'); + }); + + it('renders string labels via