Skip to content
Closed
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 .changeset/section-contained-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
3 changes: 3 additions & 0 deletions packages/mosaic/src/components/section/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@ export type {
SectionContentProps,
SectionDescriptionProps,
SectionGroupProps,
SectionGroupVariant,
SectionHeaderProps,
SectionItemProps,
SectionItemsProps,
SectionLabelProps,
SectionMediaProps,
SectionMediaSize,
SectionRootProps,
SectionRowProps,
SectionSurfaceProps,
SectionTitleProps,
} from './section';
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import * as stylex from '@stylexjs/stylex';

export const sectionItemsMarker = stylex.defineMarker();
export const sectionNestedItemMarker = stylex.defineMarker();
62 changes: 29 additions & 33 deletions packages/mosaic/src/components/section/section.styles.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import * as stylex from '@stylexjs/stylex';

import { colorVars, fontWeightVars, radiusVars, space, typeScaleVars } from '../../tokens.stylex';
import { sectionItemsMarker } from './section.markers.stylex';
import { colorVars, fontWeightVars, radiusVars, sectionVars, space, typeScaleVars } from '../../tokens.stylex';
import { sectionNestedItemMarker } from './section.markers.stylex';

/* eslint-disable @stylexjs/no-lookahead-selectors -- Mosaic's supported browsers include :has();
the marker keeps this selector scoped to Section.Items. */
export const styles = stylex.create({
root: {
display: 'flex',
Expand All @@ -17,16 +15,22 @@ export const styles = stylex.create({
fontWeight: fontWeightVars['--cl-font-medium'],
},
group: {
display: 'flex',
flexDirection: 'column',
rowGap: space['3'],
width: '100%',
},
surface: {
borderColor: colorVars['--cl-color-border'],
borderRadius: radiusVars['--cl-radius-xl'],
borderStyle: 'solid',
borderWidth: '1px',
overflow: 'hidden',
backgroundColor: colorVars['--cl-color-background'],
width: '100%',
},
row: {
marginInline: space['4'],
paddingBlock: space['4'],
borderBlockStartColor: colorVars['--cl-color-border'],
borderBlockStartStyle: 'solid',
borderBlockStartWidth: {
Expand All @@ -35,48 +39,41 @@ export const styles = stylex.create({
},
display: 'flex',
flexDirection: 'column',
paddingBlockEnd: {
default: space['4'],
[stylex.when.descendant(':where(*)', sectionItemsMarker)]: 0,
},
paddingBlockStart: {
default: space['4'],
[stylex.when.descendant(':where(*)', sectionItemsMarker)]: space['3'],
},
rowGap: {
default: space['2'],
[stylex.when.descendant(':where(*)', sectionItemsMarker)]: 0,
},
minHeight: {
default: `calc(${space['18.5']} + 1px)`,
[stylex.when.descendant(':where(*)', sectionItemsMarker)]: 0,
},
rowGap: space['2'],
minHeight: sectionVars['--cl-section-row-min-height'],
width: 'auto',
},
header: {
marginInline: space['4'],
paddingBlock: space['3'],
borderBlockEndColor: colorVars['--cl-color-border'],
borderBlockEndStyle: 'solid',
borderBlockEndWidth: '1px',
width: 'auto',
},
items: {
paddingInline: space['4'],
display: 'flex',
flexDirection: 'column',
marginBlockStart: space['3'],
width: '100%',
},
item: {
paddingBlock: {
default: null,
[stylex.when.ancestor(':where(*)', sectionItemsMarker)]: space['4'],
},
alignItems: 'center',
borderBlockStartColor: colorVars['--cl-color-border'],
borderBlockStartStyle: 'solid',
borderBlockStartWidth: {
default: '0px',
[stylex.when.ancestor(':where(*)', sectionItemsMarker)]: '1px',
},
columnGap: space['3'],
display: 'flex',
flexWrap: 'nowrap',
justifyContent: 'space-between',
width: '100%',
},
nestedItem: {
paddingBlock: space['4'],
borderBlockStartColor: colorVars['--cl-color-border'],
borderBlockStartStyle: 'solid',
borderBlockStartWidth: {
default: '0px',
[stylex.when.siblingBefore(':where(*)', sectionNestedItemMarker)]: '1px',
},
},
mediaBase: {
alignItems: 'center',
alignSelf: 'center',
Expand Down Expand Up @@ -134,4 +131,3 @@ export const styles = stylex.create({
width: '100%',
},
});
/* eslint-enable @stylexjs/no-lookahead-selectors */
137 changes: 87 additions & 50 deletions packages/mosaic/src/components/section/section.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,34 @@ describe('Section', () => {
it('renders an accessible section and every compound part', () => {
render(
<Section.Root>
<Section.Title>Account</Section.Title>
<Section.Group data-testid='group'>
<Section.Row data-testid='row'>
<Section.Item data-testid='item'>
<Section.Media
size='lg'
data-testid='media'
>
Icon
</Section.Media>
<Section.Content data-testid='content'>
<Section.Label data-testid='label'>Name</Section.Label>
<Section.Description data-testid='description'>Shown throughout the application.</Section.Description>
</Section.Content>
<Section.Actions data-testid='actions'>Control</Section.Actions>
</Section.Item>
</Section.Row>
<Section.Title>Account</Section.Title>
<Section.Surface data-testid='surface'>
<Section.Row data-testid='row'>
<Section.Item data-testid='item'>
<Section.Media
size='lg'
data-testid='media'
>
Icon
</Section.Media>
<Section.Content data-testid='content'>
<Section.Label data-testid='label'>Name</Section.Label>
<Section.Description data-testid='description'>Shown throughout the application.</Section.Description>
</Section.Content>
<Section.Actions data-testid='actions'>Control</Section.Actions>
</Section.Item>
</Section.Row>
</Section.Surface>
</Section.Group>
</Section.Root>,
);

expect(screen.getByRole('region', { name: 'Account' })).toHaveClass('cl-section');
expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toHaveClass('cl-section-title');
expect(screen.getByTestId('group')).toHaveClass('cl-section-group');
expect(screen.getByTestId('group')).toContainElement(screen.getByRole('heading', { name: 'Account' }));
expect(screen.getByTestId('surface')).toHaveClass('cl-section-surface');
expect(screen.getByTestId('row')).toHaveClass('cl-section-row');
expect(screen.getByTestId('item')).toHaveClass('cl-section-item');
expect(screen.getByTestId('media')).toHaveClass('cl-section-media');
Expand Down Expand Up @@ -79,27 +83,29 @@ describe('Section', () => {
it('supports an explicit accessible name', () => {
render(
<Section.Root aria-label='Account preferences'>
<Section.Title>Account</Section.Title>
<Section.Group />
<Section.Group>
<Section.Title>Account</Section.Title>
<Section.Surface />
</Section.Group>
</Section.Root>,
);

expect(screen.getByRole('region', { name: 'Account preferences' })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 2, name: 'Account' })).toBeInTheDocument();
});

it('composes multiple items in one row', () => {
it('composes a header over a nested list', () => {
render(
<Section.Root>
<Section.Title>Profile</Section.Title>
<Section.Group>
<Section.Row data-testid='row'>
<Section.Item>
<Section.Title>Profile</Section.Title>
<Section.Surface>
<Section.Header data-testid='header'>
<Section.Content>
<Section.Label>Email</Section.Label>
</Section.Content>
<Section.Actions>Edit</Section.Actions>
</Section.Item>
</Section.Header>
<Section.Items data-testid='items'>
<Section.Item data-testid='nested-item'>
<Section.Content data-testid='nested-content'>
Expand All @@ -108,37 +114,64 @@ describe('Section', () => {
<Section.Actions>More</Section.Actions>
</Section.Item>
</Section.Items>
</Section.Row>
</Section.Surface>
</Section.Group>
</Section.Root>,
);

expect(screen.getByText('Email')).toBeInTheDocument();
expect(screen.getByText('ada@example.com')).toBeInTheDocument();
expect(screen.getAllByText(/Edit|More/)).toHaveLength(2);
expect(screen.getByTestId('header')).toHaveClass('cl-section-header');
expect(screen.getByTestId('items')).toHaveClass('cl-section-items');
expect(screen.getByTestId('row')).not.toHaveAttribute('data-variant');
expect(screen.getByTestId('header')).not.toHaveAttribute('data-nested');
expect(screen.getByTestId('items')).toHaveAttribute('data-nested');
expect(screen.getByTestId('nested-item')).toHaveAttribute('data-nested');
expect(screen.getByTestId('nested-content')).toHaveAttribute('data-nested');
});

it('retains public nesting hooks while deriving layout from the item collection structure', () => {
it('renders a contained group as a named group inside the one section', () => {
render(
<Section.Root>
<Section.Group data-testid='group'>
<Section.Title>Account</Section.Title>
<Section.Surface />
</Section.Group>
<Section.Group
variant='contained'
aria-label='Email'
data-testid='contained'
>
<Section.Surface />
</Section.Group>
</Section.Root>,
);

expect(screen.getAllByRole('region')).toHaveLength(1);
expect(screen.getByTestId('group')).toHaveAttribute('data-variant', 'default');
expect(screen.getByTestId('group')).not.toHaveAttribute('role');
expect(screen.getByRole('group', { name: 'Email' })).toBe(screen.getByTestId('contained'));
expect(screen.getByTestId('contained')).toHaveAttribute('data-variant', 'contained');
});

it('marks only items inside Section.Items as nested', () => {
render(
<Section.Root>
<Section.Group>
<Section.Row data-testid='row'>
<Section.Item>Email</Section.Item>
<Section.Surface>
<Section.Row>
<Section.Item>Name</Section.Item>
</Section.Row>
<Section.Items>
<Section.Item>one@example.com</Section.Item>
<Section.Item>two@example.com</Section.Item>
</Section.Items>
</Section.Row>
</Section.Surface>
</Section.Group>
</Section.Root>,
);

expect(screen.getByTestId('row')).not.toHaveAttribute('data-variant');
expect(screen.getByText('Name')).not.toHaveAttribute('data-nested');
expect(screen.getByText('one@example.com')).toHaveAttribute('data-nested');
expect(screen.getByText('two@example.com')).toHaveAttribute('data-nested');
});
Expand All @@ -156,22 +189,24 @@ describe('Section', () => {
render={props => <article {...props} />}
xstyle={overrides.root}
>
<Section.Title>Account</Section.Title>
<Section.Group
ref={groupRef}
xstyle={overrides.group}
>
<Section.Row>
<Section.Item
ref={itemRef}
xstyle={overrides.item}
>
<Section.Content ref={contentRef}>
<Section.Label xstyle={overrides.label}>Name</Section.Label>
</Section.Content>
<Section.Actions ref={actionsRef} />
</Section.Item>
</Section.Row>
<Section.Title>Account</Section.Title>
<Section.Surface>
<Section.Row>
<Section.Item
ref={itemRef}
xstyle={overrides.item}
>
<Section.Content ref={contentRef}>
<Section.Label xstyle={overrides.label}>Name</Section.Label>
</Section.Content>
<Section.Actions ref={actionsRef} />
</Section.Item>
</Section.Row>
</Section.Surface>
</Section.Group>
</Section.Root>,
);
Expand Down Expand Up @@ -201,14 +236,16 @@ describe('Section', () => {
render(
<Section.Root>
<Section.Group>
<Section.Row data-testid='row'>
<Section.Item data-testid='item'>
<Section.Content>
<Section.Label>Profile picture</Section.Label>
</Section.Content>
</Section.Item>
<Section.Error data-testid='error'>File type not supported.</Section.Error>
</Section.Row>
<Section.Surface>
<Section.Row data-testid='row'>
<Section.Item data-testid='item'>
<Section.Content>
<Section.Label>Profile picture</Section.Label>
</Section.Content>
</Section.Item>
<Section.Error data-testid='error'>File type not supported.</Section.Error>
</Section.Row>
</Section.Surface>
</Section.Group>
</Section.Root>,
);
Expand Down
Loading
Loading