Skip to content

Commit 710fff5

Browse files
feat(mosaic): add Reveal component (#10258)
1 parent a0d58ad commit 710fff5

10 files changed

Lines changed: 322 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Reveal } from './reveal';
2+
export type { RevealProps } from './reveal';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
3+
import { durationVars, easingVars } from '../../tokens.stylex';
4+
5+
export const reveal = stylex.create({
6+
root: {
7+
display: 'grid',
8+
gridTemplateRows: {
9+
default: '1fr',
10+
':where(:not([data-open]), [data-starting-style])': '0fr',
11+
},
12+
transitionDuration: durationVars['--cl-duration-slow'],
13+
transitionProperty: {
14+
default: 'grid-template-rows',
15+
'@media (prefers-reduced-motion: reduce)': 'none',
16+
},
17+
transitionTimingFunction: {
18+
default: easingVars['--cl-ease-enter'],
19+
':where([data-ending-style])': easingVars['--cl-ease-in-out'],
20+
},
21+
},
22+
clip: {
23+
overflow: 'clip',
24+
alignContent: 'end',
25+
display: 'grid',
26+
gridRowEnd: 'span 2',
27+
gridRowStart: '1',
28+
minHeight: 0,
29+
},
30+
});
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import * as stylex from '@stylexjs/stylex';
2+
import { act, render, screen } from '@testing-library/react';
3+
import React from 'react';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
6+
import { Reveal } from './reveal';
7+
8+
const atoms = stylex.create({
9+
spaced: { marginTop: '8px' },
10+
});
11+
12+
const restores: Array<() => void> = [];
13+
14+
afterEach(() => {
15+
restores.splice(0).forEach(restore => restore());
16+
});
17+
18+
function holdAnimations() {
19+
let finish = () => undefined as void;
20+
const finished = new Promise<void>(resolve => {
21+
finish = resolve;
22+
});
23+
const getAnimations = vi.fn(() => [{ finished }]);
24+
const original = Object.getOwnPropertyDescriptor(Element.prototype, 'getAnimations');
25+
Object.defineProperty(Element.prototype, 'getAnimations', { configurable: true, value: getAnimations });
26+
restores.push(() => {
27+
if (original) {
28+
Object.defineProperty(Element.prototype, 'getAnimations', original);
29+
} else {
30+
Reflect.deleteProperty(Element.prototype, 'getAnimations');
31+
}
32+
});
33+
return async () => {
34+
getAnimations.mockReturnValue([]);
35+
await act(async () => {
36+
finish();
37+
await new Promise(resolve => setTimeout(resolve, 0));
38+
});
39+
};
40+
}
41+
42+
async function nextFrame() {
43+
await act(async () => {
44+
await new Promise(resolve => requestAnimationFrame(resolve));
45+
});
46+
}
47+
48+
describe('Mosaic Reveal', () => {
49+
it('renders no children while closed on mount', () => {
50+
render(
51+
<Reveal
52+
open={false}
53+
data-testid='reveal'
54+
>
55+
Details
56+
</Reveal>,
57+
);
58+
expect(screen.getByTestId('reveal')).toBeInTheDocument();
59+
expect(screen.queryByText('Details')).toBeNull();
60+
});
61+
62+
it('renders its children without an enter animation when mounted open', () => {
63+
render(
64+
<Reveal
65+
open
66+
data-testid='reveal'
67+
>
68+
Details
69+
</Reveal>,
70+
);
71+
const root = screen.getByTestId('reveal');
72+
expect(screen.getByText('Details')).toBeInTheDocument();
73+
expect(root).toHaveAttribute('data-open');
74+
expect(root).not.toHaveAttribute('data-starting-style');
75+
expect(root.style.transition).toBe('');
76+
});
77+
78+
it('enters from the starting style when opened after mount', () => {
79+
const { rerender } = render(
80+
<Reveal
81+
open={false}
82+
data-testid='reveal'
83+
>
84+
Details
85+
</Reveal>,
86+
);
87+
rerender(
88+
<Reveal
89+
open
90+
data-testid='reveal'
91+
>
92+
Details
93+
</Reveal>,
94+
);
95+
const root = screen.getByTestId('reveal');
96+
expect(root).toHaveAttribute('data-open');
97+
expect(root).toHaveAttribute('data-starting-style');
98+
expect(screen.getByText('Details')).toBeInTheDocument();
99+
});
100+
101+
it('holds the last children, inert, through the exit, then unmounts them', async () => {
102+
const finish = holdAnimations();
103+
const { rerender } = render(
104+
<Reveal
105+
open
106+
data-testid='reveal'
107+
>
108+
<span>Old</span>
109+
</Reveal>,
110+
);
111+
await nextFrame();
112+
const root = screen.getByTestId('reveal');
113+
expect(screen.getByText('Old').closest('[inert]')).toBeNull();
114+
115+
rerender(
116+
<Reveal
117+
open={false}
118+
data-testid='reveal'
119+
>
120+
<span>New</span>
121+
</Reveal>,
122+
);
123+
expect(root).toHaveAttribute('data-ending-style');
124+
expect(root).not.toHaveAttribute('data-open');
125+
expect(screen.getByText('Old').closest('[inert]')).not.toBeNull();
126+
expect(screen.queryByText('New')).toBeNull();
127+
128+
await finish();
129+
expect(screen.queryByText('Old')).toBeNull();
130+
expect(screen.queryByText('New')).toBeNull();
131+
expect(root).toBeInTheDocument();
132+
});
133+
134+
it('carries the slot class, merges xstyle and forwards the ref', () => {
135+
const ref = React.createRef<HTMLDivElement>();
136+
render(
137+
<Reveal
138+
ref={ref}
139+
open
140+
data-testid='reveal'
141+
xstyle={atoms.spaced}
142+
>
143+
Details
144+
</Reveal>,
145+
);
146+
const root = screen.getByTestId('reveal');
147+
expect(ref.current).toBe(root);
148+
expect(root).toHaveClass('cl-reveal', stylex.props(atoms.spaced).className ?? '');
149+
});
150+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { inertProps } from '@clerk/shared/inert';
2+
import * as stylex from '@stylexjs/stylex';
3+
import React from 'react';
4+
5+
import { useTransition } from '../../primitives/hooks';
6+
import { Freeze, useRender } from '../../primitives/utils';
7+
import type { MosaicElementProps } from '../../props';
8+
import { mergeStyleProps, themeProps } from '../../props';
9+
import { reset } from '../../styles/reset.styles';
10+
import { reveal } from './reveal.styles';
11+
12+
export type RevealProps = MosaicElementProps<'div'> & {
13+
open: boolean;
14+
};
15+
16+
export const Reveal = React.forwardRef<HTMLDivElement, RevealProps>(function MosaicReveal(
17+
{ open, xstyle, children, ...rest },
18+
ref,
19+
) {
20+
const element = React.useRef<HTMLElement | null>(null);
21+
const { mounted, transitionProps } = useTransition({ open, ref: element });
22+
const [hasBeenClosed, setHasBeenClosed] = React.useState(!open);
23+
if (!open && !hasBeenClosed) {
24+
setHasBeenClosed(true);
25+
}
26+
const effectiveTransitionProps = hasBeenClosed
27+
? transitionProps
28+
: { ...transitionProps, 'data-starting-style': undefined, style: undefined };
29+
30+
return useRender({
31+
defaultTagName: 'div',
32+
ref: [ref, element],
33+
props: {
34+
...mergeStyleProps(
35+
themeProps('reveal'),
36+
stylex.props(reset.base, reveal.root, xstyle),
37+
{ ...effectiveTransitionProps },
38+
rest,
39+
),
40+
children: (
41+
<div
42+
{...inertProps(!open)}
43+
{...stylex.props(reset.base, reveal.clip)}
44+
>
45+
{mounted ? <Freeze frozen={!open}>{children}</Freeze> : null}
46+
</div>
47+
),
48+
},
49+
});
50+
});

‎packages/mosaic/src/styles/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ export type {
164164
} from '../components/popover';
165165
export { Panel } from '../components/panel';
166166
export type { PanelRootProps, PanelSectionsProps, PanelTitleProps } from '../components/panel';
167+
export { Reveal } from '../components/reveal';
168+
export type { RevealProps } from '../components/reveal';
167169
export { Profile } from '../components/profile';
168170
export type {
169171
ProfileContentProps,

‎packages/swingset/src/components/DocsViewer.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
9494
popover: dynamic(() => import('../stories/popover.component.mdx')),
9595
profile: dynamic(() => import('../stories/profile.component.mdx')),
9696
radio: dynamic(() => import('../stories/radio.mdx')),
97+
reveal: dynamic(() => import('../stories/reveal.component.mdx')),
9798
section: dynamic(() => import('../stories/section.mdx')),
9899
'segmented-control': dynamic(() => import('../stories/segmented-control.mdx')),
99100
select: dynamic(() => import('../stories/select.component.mdx')),

‎packages/swingset/src/lib/registry.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ import {
233233
Sizes as RadioSizes,
234234
States as RadioStates,
235235
} from '../stories/radio.stories';
236+
import { Default as RevealDefault, meta as revealMeta } from '../stories/reveal.component.stories';
236237
import {
237238
AuthenticatorOTP as ReverificationAuthenticatorOTP,
238239
BackupCode as ReverificationBackupCode,
@@ -643,6 +644,8 @@ const radioModule: StoryModule = {
643644
Sizes: RadioSizes,
644645
};
645646

647+
const revealModule: StoryModule = { meta: revealMeta, Default: RevealDefault };
648+
646649
const dataListModule: StoryModule = {
647650
meta: dataListMeta,
648651
Default: DataListDefault,
@@ -1088,6 +1091,7 @@ export const registry: StoryModule[] = [
10881091
popoverComponentModule,
10891092
profileComponentModule,
10901093
radioModule,
1094+
revealModule,
10911095
sectionModule,
10921096
segmentedControlModule,
10931097
selectComponentModule,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import * as RevealStories from './reveal.component.stories';
2+
3+
# Reveal
4+
5+
Shows and hides its children with a height animation. Children mount when it opens and unmount once it has closed; while it closes they keep their last content and are inert. Mounted already open, it shows its children without an entrance.
6+
7+
## Example
8+
9+
<Story
10+
name='Default'
11+
storyModule={RevealStories}
12+
/>
13+
14+
## Usage
15+
16+
```tsx
17+
import { Reveal } from '@clerk/mosaic/components/reveal';
18+
19+
<Reveal open={expanded}>
20+
<Details />
21+
</Reveal>;
22+
```
23+
24+
A closed `Reveal` takes no space, so put the spacing around the content inside it, as a margin on the revealed content, rather than as a gap on the parent.
25+
26+
## Parts
27+
28+
| Part | Stable slot class | Description |
29+
| -------- | ----------------- | ---------------------------------------------------------------------- |
30+
| `Reveal` | `.cl-reveal` | `div` whose grid row animates between closed and the content's height. |
31+
32+
## Styling
33+
34+
`Reveal` carries `data-open` while open and `data-closed` while closing, plus `data-starting-style` and `data-ending-style` for the frames an entrance starts from and an exit animates to. It reads `--cl-duration-slow`, `--cl-ease-enter` and `--cl-ease-in-out`, and snaps open and closed under `prefers-reduced-motion`. Override `.cl-reveal` from a CSS layer that wins over `@clerk/mosaic/styles.css`:
35+
36+
```css
37+
@import '@clerk/mosaic/styles.css' layer(components);
38+
39+
@layer overrides {
40+
.cl-reveal {
41+
transition-duration: 400ms;
42+
}
43+
}
44+
```
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Button } from '@clerk/mosaic/components/button';
2+
import { Reveal } from '@clerk/mosaic/components/reveal';
3+
import { Text } from '@clerk/mosaic/components/text';
4+
import { useState } from 'react';
5+
6+
import type { StoryMeta } from '@/lib/types';
7+
8+
// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
9+
// renders a code footer with its function's source. See `StoryModule.__source`.
10+
export { default as __source } from './reveal.component.stories?raw';
11+
12+
export const meta: StoryMeta = {
13+
group: 'Components',
14+
status: 'wip',
15+
title: 'Reveal',
16+
source: 'packages/mosaic/src/components/reveal/reveal.tsx',
17+
};
18+
19+
export function Default() {
20+
const [open, setOpen] = useState(false);
21+
return (
22+
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', width: '20rem' }}>
23+
<Button
24+
variant='outline'
25+
aria-expanded={open}
26+
onClick={() => setOpen(value => !value)}
27+
>
28+
{open ? 'Hide details' : 'Show details'}
29+
</Button>
30+
<Reveal open={open}>
31+
<div style={{ paddingBlockStart: '0.75rem' }}>
32+
<Text>Revealed content animates its height open and closed, and is removed once it has closed.</Text>
33+
</div>
34+
</Reveal>
35+
</div>
36+
);
37+
}

0 commit comments

Comments
 (0)