Skip to content
Draft
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/connection-description-motion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
4 changes: 4 additions & 0 deletions packages/mosaic/docs/motion.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,10 @@ A swap while open plays out-then-in rather than overlapping: the old message fad
`fast`, and the new one's entrance delay holds it until that is done, while the row
eases between the two heights on `--cl-ease-enter`.

`Section.CollapsibleDescription` is the same recipe for a row's description line (the
connected account identifier, the wallet address), with the gap var set to
`Section.Content`'s.

### The fade on the clipped edge

A mask on the wrapper, not an element: it needs no background color, works on any
Expand Down
1 change: 1 addition & 0 deletions packages/mosaic/src/components/section/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { sectionCompactStyles } from './section.styles';
export type {
SectionActionsProps,
SectionBodyProps,
SectionCollapsibleDescriptionProps,
SectionContentProps,
SectionDescriptionProps,
SectionGroupProps,
Expand Down
8 changes: 8 additions & 0 deletions packages/mosaic/src/components/section/section.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,14 @@ export const styles = stylex.create({
overflowWrap: 'anywhere',
textWrap: 'balance',
},
collapsibleDescription: {
'--_cl-feedback-gap': space['0.5'],
gridTemplateRows: 'max-content',
},
collapsibleDescriptionText: {
display: 'block',
textWrap: null,
},
actions: {
alignItems: 'center',
display: 'grid',
Expand Down
108 changes: 106 additions & 2 deletions packages/mosaic/src/components/section/section.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { createDeferredPromise } from '@clerk/shared/utils';
import * as stylex from '@stylexjs/stylex';
import { render, screen, waitFor, within } from '@testing-library/react';
import { act, render, screen, waitFor, within } from '@testing-library/react';
import React from 'react';
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';

import { truncationStyles } from '../../styles/typography.styles';
import { HeadingLevelProvider } from '../heading';
import { Section } from './section';

type Animated = { getAnimations?: () => Animation[] };

function holdExits() {
const exit = createDeferredPromise();
(Element.prototype as Animated).getAnimations = () => [{ finished: exit.promise } as Animation];
return exit;
}

const overrides = stylex.create({
root: { containerType: 'inline-size' },
group: { borderWidth: 2 },
Expand All @@ -18,6 +28,10 @@ const atoms = (style: stylex.StyleXStyles) =>
(stylex.props(style).className ?? '').split(' ').filter(name => /^x[a-z0-9]+$/.test(name));

describe('Section', () => {
afterEach(() => {
delete (Element.prototype as Animated).getAnimations;
});

it('renders a card named by its heading, with every compound part', () => {
render(
<Section.Root data-testid='root'>
Expand Down Expand Up @@ -358,6 +372,96 @@ describe('Section', () => {
await waitFor(() => expect(screen.queryByTestId('error')).not.toBeInTheDocument());
});

describe('CollapsibleDescription', () => {
function Host({ value, truncate }: { value?: string; truncate?: boolean }) {
return (
<Section.Row>
<Section.Item>
<Section.Content>
<Section.Label>Google</Section.Label>
<Section.CollapsibleDescription
data-testid='description'
truncate={truncate}
title={value}
>
{value}
</Section.CollapsibleDescription>
</Section.Content>
</Section.Item>
</Section.Row>
);
}

it('renders nothing while it has no content', () => {
render(<Host />);

expect(screen.queryByTestId('description')).not.toBeInTheDocument();
});

it('shows content present at mount without an entrance', async () => {
render(<Host value='ada@example.com' />);

const description = screen.getByTestId('description');
expect(description).toHaveClass('cl-section-description');
expect(description).toHaveAttribute('data-collapsible');
expect(description).toHaveAttribute('data-open');
expect(description).not.toHaveAttribute('data-starting-style');
expect(description.getAttribute('style')).toContain('transition: none');
expect(description.getAttribute('style')).toContain('--_cl-feedback-height');
await waitFor(() => expect(description.getAttribute('style')).not.toContain('transition'));
});

it('opens from its starting state on content that arrives after mount', async () => {
const { rerender } = render(<Host />);
rerender(<Host value='ada@example.com' />);

const description = screen.getByTestId('description');
expect(description).toHaveAttribute('data-starting-style');
expect(description).toHaveTextContent('ada@example.com');
await waitFor(() => expect(description).not.toHaveAttribute('data-starting-style'));
expect(description).toHaveAttribute('data-open');
expect(description).not.toHaveAttribute('aria-hidden');
});

it('holds the last content through the exit, then leaves', async () => {
const exit = holdExits();
const { rerender } = render(<Host value='ada@example.com' />);
rerender(<Host />);

const description = screen.getByTestId('description');
expect(description).toHaveAttribute('data-ending-style');
expect(description).toHaveAttribute('aria-hidden', 'true');
expect(description).toHaveTextContent('ada@example.com');

await act(async () => {
exit.resolve();
await exit.promise;
});
expect(screen.queryByTestId('description')).not.toBeInTheDocument();
});

it('enters again after it has closed', async () => {
const { rerender } = render(<Host value='ada@example.com' />);
rerender(<Host />);
await waitFor(() => expect(screen.queryByTestId('description')).not.toBeInTheDocument());

rerender(<Host value='grace@example.com' />);
expect(screen.getByTestId('description')).toHaveAttribute('data-starting-style');
});

it('truncates the text to one line and carries the title', () => {
render(
<Host
value='ada@example.com'
truncate
/>,
);

expect(screen.getByText('ada@example.com')).toHaveClass(...atoms(truncationStyles.singleLine));
expect(screen.getByTestId('description')).toHaveAttribute('title', 'ada@example.com');
});
});

it('states why a row has no action, with the leading glyph in its own slot', () => {
render(
<Section.Root>
Expand Down
78 changes: 78 additions & 0 deletions packages/mosaic/src/components/section/section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useSafeLayoutEffect } from '@clerk/shared/react';
import * as stylex from '@stylexjs/stylex';
import React from 'react';

import type { TransitionProps } from '../../primitives/hooks/use-transition';
import { useTransition } from '../../primitives/hooks/use-transition';
import { useRender } from '../../primitives/utils';
import type { MosaicComponentProps } from '../../props';
Expand Down Expand Up @@ -29,6 +30,7 @@ export type SectionMediaProps = MosaicComponentProps<'div'> & { size?: SectionMe
export type SectionContentProps = MosaicComponentProps<'div'>;
export type SectionLabelProps = MosaicComponentProps<'div'>;
export type SectionDescriptionProps = MosaicComponentProps<'div'>;
export type SectionCollapsibleDescriptionProps = MosaicComponentProps<'div'> & { truncate?: boolean };
export type SectionActionsProps = MosaicComponentProps<'div'>;
export type SectionNoteProps = MosaicComponentProps<'div'> & { icon?: React.ReactNode };
export type SectionErrorProps = MosaicComponentProps<'p'>;
Expand Down Expand Up @@ -244,6 +246,81 @@ const Description = React.forwardRef<HTMLDivElement, SectionDescriptionProps>(fu
});
});

function useSettledEntrance(mounted: boolean, transitionProps: TransitionProps): TransitionProps {
const settled = React.useRef(false);
const entrance = React.useRef<boolean | null>(null);

React.useEffect(() => {
settled.current = true;
}, []);

if (!mounted) {
entrance.current = null;
} else if (entrance.current === null) {
entrance.current = settled.current;
}

return entrance.current ? transitionProps : { ...transitionProps, 'data-starting-style': undefined };
}

/**
* A `Section.Description` that opens and closes on the content it is given, so a row grows and
* shrinks by a line rather than jumping. Render it with the content —
* `<Section.CollapsibleDescription>{identifier}</Section.CollapsibleDescription>` — rather than
* behind a conditional: empty content closes it, and the last content stays in place until it has
* closed. Content present when it first mounts shows at once. `truncate` keeps the text to one
* line with an ellipsis; pass `title` with it so the full text is still reachable.
*/
const CollapsibleDescription = React.forwardRef<HTMLDivElement, SectionCollapsibleDescriptionProps>(
function SectionCollapsibleDescription({ truncate = false, render, xstyle, children, ...rest }, ref) {
const open = hasMessage(children);
const element = React.useRef<HTMLElement | null>(null);
const [textElement, setTextElement] = React.useState<HTMLElement | null>(null);
const height = useMessageHeight(textElement);
const transition = useTransition({ open, ref: element });
const transitionProps = useSettledEntrance(transition.mounted, transition.transitionProps);
const content = useHeldMessage(children, open);
const textProps = stylex.props(
reset.base,
feedbackStyles.message,
styles.collapsibleDescriptionText,
truncate && truncationStyles.singleLine,
);

return useRender({
defaultTagName: 'div',
render,
enabled: transition.mounted,
ref: [ref, element],
props: {
...mergeStyleProps(
themeProps('section-description', { collapsible: true }),
stylex.props(
reset.base,
styles.description,
feedbackStyles.collapse,
feedbackHeight.measured(height),
styles.collapsibleDescription,
xstyle,
),
{ 'aria-hidden': open ? undefined : true, ...transitionProps },
rest,
),
children: (
<span
ref={setTextElement}
{...textProps}
{...transitionProps}
style={{ ...textProps.style, ...transitionProps.style }}
>
{content}
</span>
),
},
});
},
);

const Actions = React.forwardRef<HTMLDivElement, SectionActionsProps>(function SectionActions(
{ render, xstyle, ...rest },
ref,
Expand Down Expand Up @@ -364,6 +441,7 @@ export const Section = {
Content,
Label,
Description,
CollapsibleDescription,
Actions,
Note,
Error: SectionError,
Expand Down
Loading
Loading