Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/tedi/components/content/text-group/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './text-group';
export * from './text-group-list/text-group-list';
Original file line number Diff line number Diff line change
@@ -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 `<dt>` for this row. Strings are auto-wrapped in
* `<Label>`; any other ReactNode is rendered as-is.
*/
label: React.ReactNode;
/**
* Value rendered as the `<dd>` 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<TextGroupListBreakpointProps> & {
/**
* Label / value pairs rendered together inside a **single** `<dl>` element,
* preserving the definition-list semantics that stacking N individual
* `<TextGroup>`s would break.
*/
items: TextGroupListItem[];
/**
* Additional class name(s) to apply to the root `<dl>` element.
*/
className?: string;
};

const renderLabelContent = (label: React.ReactNode): React.ReactNode =>
typeof label === 'string' ? <Label>{label}</Label> : label;

const resolveLabelWidth = (labelWidth: string | number): string =>
typeof labelWidth === 'number' ? `${labelWidth}%` : labelWidth;

/**
* Multi-row variant of `TextGroup`. Visually identical to stacking N
* `<TextGroup>` rows, but wraps every label / value pair in **one** semantic
* `<dl>` — 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<TextGroupListProps>(props);

const listBEM = cn(
styles['tedi-text-group'],
styles['tedi-text-group--list'],
styles[`tedi-text-group--${type}`],
className
);
const listLabelWidth = resolveLabelWidth(labelWidth);

return (
<dl className={listBEM} style={{ '--label-width': listLabelWidth } as React.CSSProperties}>
{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 (
<div key={index} className={styles['tedi-text-group__row']} style={rowStyle}>
<dt className={cn(styles['tedi-text-group__label'], styles[`tedi-text-group--align-${rowLabelAlign}`])}>
{renderLabelContent(item.label)}
</dt>
<dd className={cn(styles['tedi-text-group__value'])}>{item.value}</dd>
</div>
);
})}
</dl>
);
};

TextGroupList.displayName = 'TextGroup.List';
18 changes: 18 additions & 0 deletions src/tedi/components/content/text-group/text-group.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
136 changes: 135 additions & 1 deletion src/tedi/components/content/text-group/text-group.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
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';

// TextGroup + TextGroup.List both run their props through `useBreakpointProps`
// to support per-breakpoint overrides. Mock it once so layout / class
// assertions are deterministic and don't depend on the runtime viewport.
jest.mock('../../../helpers', () => ({
useBreakpointProps: jest.fn(),
}));

beforeEach(() => {
(useBreakpointProps as jest.Mock).mockImplementation(() => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getCurrentBreakpointProps: (props: any) => ({ ...props }),
}));
});

describe('TextGroup component', () => {
it('renders with default props', () => {
const { container } = render(<TextGroup label="Label" value="Value" />);
Expand Down Expand Up @@ -134,3 +149,122 @@ describe('TextGroup component', () => {
expect(dt?.querySelector('.tedi-label')).not.toBeInTheDocument();
});
});

describe('TextGroup.List', () => {
it('renders a single <dl> with N <dt>/<dd> pairs', () => {
render(
<TextGroup.List
items={[
{ label: 'Patient', value: 'Mari Maasikas' },
{ label: 'Address', value: 'Tulbi tn 4, Tallinn' },
{ label: 'Vaccine', value: 'COVID-19 mRNA' },
]}
/>
);

// Three semantic `term` (<dt>) and three `definition` (<dd>) entries grouped
// under the same definition list. RTL maps <dt> → "term" and <dd> →
// "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 <dl>, 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(
<TextGroup.List
type="horizontal"
labelWidth="200px"
items={[
{ label: 'A', value: '1' },
{ label: 'B', value: '2' },
]}
/>
);

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(
<TextGroup.List
labelAlign="left"
items={[
{ label: 'Subtotal', value: '€ 10' },
{ label: 'Total', value: '€ 12', labelAlign: 'right' },
]}
/>
);

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(
<TextGroup.List
labelWidth="100px"
items={[
{ label: 'Default', value: 'A' },
{ label: 'Custom', value: 'B', labelWidth: '240px' },
{ label: 'Percent', value: 'C', labelWidth: 25 },
]}
/>
);

// Each row is the <dt>'s parent <div>; 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 <Label>, JSX labels untouched', () => {
render(
<TextGroup.List
items={[
{ label: 'Plain', value: 'A' },
{ label: <strong>Bold</strong>, value: 'B' },
]}
/>
);

// String labels go through `<Label>` which renders an HTML <label>.
expect(screen.getByText('Plain').tagName.toLowerCase()).toBe('label');

// JSX labels are rendered verbatim — the <strong> stays as-is, no Label
// wrapping happens.
const boldText = screen.getByText('Bold');
expect(boldText.tagName.toLowerCase()).toBe('strong');
const boldTerm = boldText.closest('dt');
expect(
within(boldTerm as HTMLElement).queryByText((_, node) => node?.tagName?.toLowerCase() === 'label')
).toBeNull();
});

it('applies custom className to the root <dl>', () => {
render(<TextGroup.List className="custom-list" items={[{ label: 'A', value: '1' }]} />);
expect(screen.getByText('A').closest('dl')).toHaveClass('custom-list');
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
66 changes: 66 additions & 0 deletions src/tedi/components/content/text-group/text-group.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { TextGroup, TextGroupProps } from './text-group';
const meta: Meta<typeof TextGroup> = {
component: TextGroup,
title: 'Tedi-Ready/Content/TextGroup',
subcomponents: { 'TextGroup.List': TextGroup.List } as never,
parameters: {
status: {
type: [{ name: 'breakpointSupport', url: '?path=/docs/helpers-usebreakpointprops--usebreakpointprops' }],
Expand Down Expand Up @@ -249,3 +250,68 @@ export const CustomLabel: Story = {
</VerticalSpacing>
),
};

/**
* `TextGroup.List` renders multiple label / value pairs inside a **single**
* `<dl>` element instead of stacking N separate `<TextGroup>`s — so screen
* readers announce them as one definition list rather than N fragments. Use
* it whenever the rows describe the same entity (patient summary, document
* metadata, …). Each row supports the same `labelAlign` / `labelWidth`
* overrides as the single-pair component when you need per-row tweaks.
*/
export const WithList: Story = {
render: () => (
<VerticalSpacing size={1.5}>
<Text modifiers="bold">Vertical list (default)</Text>
<TextGroup.List
items={[
{ label: 'Patient', value: <Text>Mari Maasikas</Text> },
{ label: 'Address', value: <Text>Tulbi tn 4, Tallinn, 23562, Estonia</Text> },
{ label: 'Vaccine', value: <Text>COVID-19 mRNA</Text> },
{ label: 'Next vaccination', value: <Text>Immunization finished</Text> },
]}
/>

<Text modifiers="bold">Horizontal list with shared label column</Text>
<TextGroup.List
type="horizontal"
labelWidth="220px"
items={[
{
label: 'Patient',
value: (
<>
<Icon name="person" size={18} color="tertiary" />
<Text>Mari Maasikas</Text>
</>
),
},
{
label: 'Address',
value: (
<>
<Icon name="location_on" size={16} color="tertiary" />
<Text>Tulbi tn 4, Tallinn, 23562, Estonia</Text>
</>
),
},
{ label: 'Healthcare provider', value: <Text>SA Põhja-Eesti Regionaalhaigla</Text> },
{ label: 'Healthcare specialist', value: <Text>Mart Mets</Text> },
{ label: 'Document creation time', value: <Text>16.08.2023 14:51:48</Text> },
]}
/>

<Text modifiers="bold">Per-row labelAlign / labelWidth overrides</Text>
<TextGroup.List
type="horizontal"
labelWidth="160px"
items={[
{ label: 'Item', value: <Text>USB-C charging cable</Text> },
{ label: 'Quantity', value: <Text>2</Text> },
{ label: 'Unit price', value: <Text>€ 12.50</Text>, labelAlign: 'right', labelWidth: '220px' },
{ label: 'Total', value: <Text modifiers="bold">€ 25.00</Text>, labelAlign: 'right', labelWidth: '220px' },
]}
/>
</VerticalSpacing>
),
};
Loading
Loading