Skip to content

Commit 0e69a7d

Browse files
prestonwebdevclaude
andcommitted
feat(mosaic): add Section.Frame and Section.Footer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent cf438ed commit 0e69a7d

6 files changed

Lines changed: 164 additions & 1 deletion

File tree

‎.changeset/mosaic-section-frame.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/mosaic': minor
3+
---
4+
5+
New `Section.Frame` and `Section.Footer` parts wrap a section card and a footer strip in one faded shell, with a single outer edge and nothing drawn between the card and the footer.

‎packages/mosaic/src/components/section/section.styles.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,29 @@ export const styles = stylex.create({
5454
flexDirection: 'column',
5555
width: 'auto',
5656
},
57+
frame: {
58+
padding: '1px',
59+
borderRadius: radiusVars['--cl-radius-xl'],
60+
backgroundColor: colorVars['--cl-color-surface-subtle'],
61+
display: 'flex',
62+
flexDirection: 'column',
63+
width: 'auto',
64+
},
65+
framedBody: {
66+
borderRadius: 'inherit',
67+
borderWidth: '0px',
68+
},
69+
footer: {
70+
paddingBlock: space['3'],
71+
paddingInline: space['4'],
72+
alignItems: 'center',
73+
color: colorVars['--cl-color-foreground-secondary'],
74+
columnGap: space['3'],
75+
display: 'flex',
76+
fontSize: typeScaleVars['--cl-text-sm-size'],
77+
justifyContent: 'space-between',
78+
lineHeight: typeScaleVars['--cl-text-sm-leading'],
79+
},
5780
row: {
5881
paddingBlock: space['4'],
5982
borderBlockStartColor: colorVars['--cl-color-border'],

‎packages/mosaic/src/components/section/section.test.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,24 @@ describe('Section', () => {
392392
expect(note.parentElement).toBe(screen.getByTestId('item'));
393393
});
394394

395+
it('renders a frame with its theme class', () => {
396+
render(
397+
<Section.Frame data-testid='frame'>
398+
<Section.Body data-testid='body' />
399+
</Section.Frame>,
400+
);
401+
402+
expect(screen.getByTestId('frame')).toHaveClass('cl-section-frame');
403+
expect(screen.getByTestId('frame')).toContainElement(screen.getByTestId('body'));
404+
});
405+
406+
it('renders a footer with its theme class', () => {
407+
render(<Section.Footer data-testid='footer'>Changing plans</Section.Footer>);
408+
409+
expect(screen.getByTestId('footer')).toHaveClass('cl-section-footer');
410+
expect(screen.getByTestId('footer')).toHaveTextContent('Changing plans');
411+
});
412+
395413
it('leaves out the glyph slot when the note carries no icon', () => {
396414
render(<Section.Note data-testid='note'>Managed by Acme SSO</Section.Note>);
397415

‎packages/mosaic/src/components/section/section.tsx‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export type SectionGroupProps = MosaicComponentProps<'div'>;
2222
export type SectionHeaderProps = MosaicComponentProps<'div'>;
2323
export type SectionTitleProps = Omit<HeadingProps, 'size'>;
2424
export type SectionBodyProps = MosaicComponentProps<'div'>;
25+
export type SectionFrameProps = MosaicComponentProps<'div'>;
26+
export type SectionFooterProps = MosaicComponentProps<'div'>;
2527
export type SectionRowProps = MosaicComponentProps<'div'>;
2628
export type SectionItemsProps = MosaicComponentProps<'ul'>;
2729
export type SectionItemProps = MosaicComponentProps<'div'> & { wrap?: boolean };
@@ -45,6 +47,7 @@ const SectionGroupContext = React.createContext<React.Dispatch<React.SetStateAct
4547
const SectionHeaderContext = React.createContext(false);
4648
const SectionItemsContext = React.createContext(false);
4749
const SectionItemWrapContext = React.createContext(false);
50+
const SectionFrameContext = React.createContext(false);
4851

4952
const Root = React.forwardRef<HTMLElement, SectionRootProps>(function SectionRoot({ render, xstyle, ...rest }, ref) {
5053
return useRender({
@@ -121,11 +124,52 @@ const Title = React.forwardRef<HTMLHeadingElement, SectionTitleProps>(function S
121124
});
122125

123126
const Body = React.forwardRef<HTMLDivElement, SectionBodyProps>(function SectionBody({ render, xstyle, ...rest }, ref) {
127+
const framed = React.useContext(SectionFrameContext);
128+
129+
return useRender({
130+
defaultTagName: 'div',
131+
render,
132+
ref,
133+
props: mergeStyleProps(
134+
themeProps('section-body'),
135+
stylex.props(reset.base, styles.body, framed && styles.framedBody, xstyle),
136+
rest,
137+
),
138+
});
139+
});
140+
141+
/**
142+
* Wraps a `Section.Body` and a `Section.Footer` in one faded shell. The shell's color shows around
143+
* the body as its outline and behind the footer, so the pair has a single outer edge and nothing
144+
* drawn between them.
145+
*/
146+
const Frame = React.forwardRef<HTMLDivElement, SectionFrameProps>(function SectionFrame(
147+
{ render, xstyle, ...rest },
148+
ref,
149+
) {
150+
const element = useRender({
151+
defaultTagName: 'div',
152+
render,
153+
ref,
154+
props: mergeStyleProps(themeProps('section-frame'), stylex.props(reset.base, styles.frame, xstyle), rest),
155+
});
156+
157+
return <SectionFrameContext.Provider value>{element}</SectionFrameContext.Provider>;
158+
});
159+
160+
/**
161+
* A strip under `Section.Body` for a summary or a link that belongs to the card above it. Place it
162+
* directly after `Section.Body` inside a `Section.Frame`.
163+
*/
164+
const Footer = React.forwardRef<HTMLDivElement, SectionFooterProps>(function SectionFooter(
165+
{ render, xstyle, ...rest },
166+
ref,
167+
) {
124168
return useRender({
125169
defaultTagName: 'div',
126170
render,
127171
ref,
128-
props: mergeStyleProps(themeProps('section-body'), stylex.props(reset.base, styles.body, xstyle), rest),
172+
props: mergeStyleProps(themeProps('section-footer'), stylex.props(reset.base, styles.footer, xstyle), rest),
129173
});
130174
});
131175

@@ -358,6 +402,8 @@ export const Section = {
358402
Header,
359403
Title,
360404
Body,
405+
Frame,
406+
Footer,
361407
Row,
362408
Items,
363409
Item,

‎packages/swingset/src/stories/section.mdx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ title. See [Panel](/components/panel) and [Heading](/components/heading).
7171
| `Section.Header` | `<div>` | `.cl-section-header` | Content and optional actions. |
7272
| `Section.Title` | heading | `.cl-section-title` | The group's heading. |
7373
| `Section.Body` | `<div>` | `.cl-section-body` | The card under the header. |
74+
| `Section.Frame` | `<div>` | `.cl-section-frame` | Faded shell around a body and a footer. |
75+
| `Section.Footer` | `<div>` | `.cl-section-footer` | A strip under the body, on the frame. |
7476
| `Section.Row` | `<div>` | `.cl-section-row` | One setting. |
7577
| `Section.Items` | `<ul>` | `.cl-section-items` | A list of values. |
7678
| `Section.Item` | `<div>` | `.cl-section-item` | Media, content, and actions on one line. `<li>` inside `Items`. |
@@ -102,6 +104,15 @@ inside `Section.Items` reflect `data-nested`.
102104
storyModule={SectionStories}
103105
/>
104106

107+
### Footer
108+
109+
`Section.Frame` wraps a `Section.Body` and a `Section.Footer` in a shell of `--cl-color-surface-subtle`. The shell's color shows as a one-pixel outline around the body and behind the footer, so the pair has one outer edge and no line between them.
110+
111+
<Story
112+
name='Subscription'
113+
storyModule={SectionStories}
114+
/>
115+
105116
### Header description
106117

107118
<Story

‎packages/swingset/src/stories/section.stories.tsx‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Section } from '@clerk/mosaic/components/section';
77
import { Skeleton } from '@clerk/mosaic/components/skeleton';
88
import { VisuallyHidden } from '@clerk/mosaic/components/visually-hidden';
99
import { ArrowUpRight } from '@clerk/mosaic/icons/glyphs/arrow-up-right';
10+
import { Calendar } from '@clerk/mosaic/icons/glyphs/calendar';
1011
import { DeviceLaptop } from '@clerk/mosaic/icons/glyphs/device-laptop';
1112
import { DevicePhone } from '@clerk/mosaic/icons/glyphs/device-phone';
1213
import { EllipsisHorizontal } from '@clerk/mosaic/icons/glyphs/ellipsis-horizontal';
@@ -432,6 +433,65 @@ export function Destructive() {
432433
);
433434
}
434435

436+
export function Subscription() {
437+
return (
438+
<Section.Root xstyle={styles.rootMax}>
439+
<Section.Group>
440+
<Section.Header>
441+
<Section.Content>
442+
<Section.Title>Subscription</Section.Title>
443+
</Section.Content>
444+
</Section.Header>
445+
<Section.Frame>
446+
<Section.Body>
447+
<Section.Row>
448+
<Section.Item>
449+
<Section.Content>
450+
<Section.Label>
451+
Basic <Badge color='neutral'>Ends Oct 22</Badge>
452+
</Section.Label>
453+
<Section.Description>$12 / month</Section.Description>
454+
</Section.Content>
455+
<Section.Actions>
456+
<Button
457+
color='neutral'
458+
size='sm'
459+
variant='outline'
460+
>
461+
Change plan
462+
</Button>
463+
</Section.Actions>
464+
</Section.Item>
465+
</Section.Row>
466+
<Section.Row>
467+
<Section.Item>
468+
<Section.Content>
469+
<Section.Label>Next invoice</Section.Label>
470+
<Section.Description>Due October 22, 2026</Section.Description>
471+
</Section.Content>
472+
<Section.Actions>$199.00</Section.Actions>
473+
</Section.Item>
474+
</Section.Row>
475+
</Section.Body>
476+
<Section.Footer>
477+
<span style={{ alignItems: 'center', display: 'inline-flex', gap: 8 }}>
478+
<Icon glyph={Calendar} />
479+
Changing to the Basic plan on October 22, 2026
480+
</span>
481+
<Button
482+
color='neutral'
483+
size='sm'
484+
variant='ghost'
485+
>
486+
View details
487+
</Button>
488+
</Section.Footer>
489+
</Section.Frame>
490+
</Section.Group>
491+
</Section.Root>
492+
);
493+
}
494+
435495
export function ContainedHeader() {
436496
return (
437497
<>

0 commit comments

Comments
 (0)