Skip to content
Merged
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/mosaic-panel-size-hook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/mosaic/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Most problems a feature hits already have a shared answer. Find the row for your
| An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` |
| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) |
| An input that reformats as the user types without moving the caret | `useFormattedInput` (`src/primitives/hooks/`) |
| A panel that animates open and closed to its measured size | `usePanelSize` (`src/primitives/hooks/`) |
| An interaction with an async lifecycle or two values that move together | A machine in the controller file, via `setup()` (`src/machine/`) |
| Styles several components share | `src/styles/*.styles.ts` |

Expand Down
52 changes: 3 additions & 49 deletions packages/mosaic/src/primitives/accordion/accordion-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
'use client';

import React, { useLayoutEffect, useRef, useState } from 'react';
import React from 'react';

import { useAnimationsFinished } from '../hooks/use-animations-finished';
import { useTransition } from '../hooks/use-transition';
import { usePanelSize } from '../hooks/use-panel-size';
import { type ComponentProps, mergeProps, useRender } from '../utils';
import { autoUpdate, getScrollDimensions } from '../utils/dom';
import { useAccordionItemContext } from './accordion-context';

export type AccordionPanelProps = ComponentProps<'div'>;
Expand All @@ -15,51 +13,7 @@ export const AccordionPanel = React.forwardRef<HTMLDivElement, AccordionPanelPro
const { render, ...otherProps } = props;
const { open, triggerId, panelId } = useAccordionItemContext();

const panelRef = useRef<HTMLElement | null>(null);
const [height, setHeight] = useState<number | undefined>(undefined);

const { mounted, transitionStatus, transitionProps } = useTransition({
open,
ref: panelRef,
});

const runOnAnimationsFinished = useAnimationsFinished(panelRef, open);

// Measure the content height and keep it in sync via ResizeObserver
useLayoutEffect(() => {
if (!mounted) {
return;
}

const panel = panelRef.current;
if (!panel) {
return;
}

return autoUpdate(panel, () => setHeight(getScrollDimensions(panel).height));
}, [mounted]);

// Once the open animation settles, drop the measured pixel height so the
// panel flows naturally with its content (CSS var omitted → height: auto).
useLayoutEffect(() => {
if (!open || !mounted) {
return;
}
runOnAnimationsFinished(() => setHeight(undefined));
}, [open, mounted, runOnAnimationsFinished]);

// Re-pin to a measured pixel value when closing starts so the exit
// transition animates from a concrete height (height: auto can't interpolate).
useLayoutEffect(() => {
if (open || transitionStatus !== 'ending') {
return;
}
const panel = panelRef.current;
if (!panel) {
return;
}
setHeight(getScrollDimensions(panel).height);
}, [open, transitionStatus]);
const { panelRef, mounted, transitionProps, height } = usePanelSize(open);

const state = { open };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ describe('Accordion', () => {
renderAccordion({ defaultValue: ['item1'] });
const panel = document.querySelector('[data-testid="accordion-panel"]') as HTMLElement;
expect(panel.getAttribute('style')).toContain('--cl-accordion-panel-height');
expect(panel.getAttribute('style')).not.toContain('panel-width');
} finally {
if (original) {
(Element.prototype as { getAnimations?: unknown }).getAnimations = original;
Expand Down
2 changes: 1 addition & 1 deletion packages/mosaic/src/primitives/autocomplete/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ No additional props beyond standard HTML attributes and the `render` prop.

### `Autocomplete.Arrow`

Accepts all `FloatingArrow` props. `ref` and `context` are injected automatically.
Accepts all `FloatingArrow` props and forwards `ref`. `context` is injected automatically.

## Keyboard Navigation

Expand Down
31 changes: 17 additions & 14 deletions packages/mosaic/src/primitives/autocomplete/autocomplete-arrow.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
'use client';

import { FloatingArrow } from '@floating-ui/react';
import { FloatingArrow, useMergeRefs } from '@floating-ui/react';
import React from 'react';

import { parsePlacement } from '../utils/side-offset';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompleteArrowProps = React.ComponentPropsWithRef<typeof FloatingArrow>;
export type AutocompleteArrowProps = Omit<React.ComponentPropsWithoutRef<typeof FloatingArrow>, 'context'>;

export function AutocompleteArrow(props: AutocompleteArrowProps) {
const { floatingContext, arrowRef, placement } = useAutocompleteContext();
const side = parsePlacement(placement).side;
export const AutocompleteArrow = React.forwardRef<SVGSVGElement, AutocompleteArrowProps>(
function AutocompleteArrow(props, ref) {
const { floatingContext, arrowRef, placement } = useAutocompleteContext();
const mergedRef = useMergeRefs([arrowRef, ref]);
const side = parsePlacement(placement).side;

return (
<FloatingArrow
data-side={side}
{...props}
ref={arrowRef}
context={floatingContext}
/>
);
}
return (
<FloatingArrow
data-side={side}
{...props}
ref={mergedRef}
context={floatingContext}
/>
);
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const AutocompleteOption = React.forwardRef<HTMLDivElement, AutocompleteO
const ownProps = {
id,
role: 'option',
'aria-selected': isActive,
'aria-selected': isSelected,
'aria-disabled': disabled || undefined,
} satisfies DefaultProps<'div'>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@

import React from 'react';

import { type ComponentProps, mergeProps, useRender } from '../utils';
import { type ComponentProps, Freeze, mergeProps, useRender } from '../utils';
import { useAutocompleteContext } from './autocomplete-context';

export type AutocompletePopupProps = ComponentProps<'div'>;

export const AutocompletePopup = React.forwardRef<HTMLDivElement, AutocompletePopupProps>(
function AutocompletePopup(props, ref) {
const { render, ...otherProps } = props;
const { popupRef, transitionProps } = useAutocompleteContext();
const { render, children, ...otherProps } = props;
const { open, popupRef, transitionProps } = useAutocompleteContext();

const defaultProps = {
...transitionProps,
children: <Freeze frozen={!open}>{children}</Freeze>,
};

return useRender({
Expand Down
104 changes: 98 additions & 6 deletions packages/mosaic/src/primitives/autocomplete/autocomplete.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { createRef, useState } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { Popover } from '../popover/index';
Expand Down Expand Up @@ -291,18 +291,110 @@ describe('Autocomplete', () => {
expect(options).toHaveLength(4);
});

it('active option has aria-selected=true', async () => {
it('keeps aria-selected on the selected option while the highlight moves', async () => {
const user = userEvent.setup();
render(<FilteredAutocomplete />);
render(
<Autocomplete.Root
defaultOpen
defaultValue='apple'
>
<Autocomplete.Input placeholder='Search fruits...' />
<Autocomplete.Positioner>
<Autocomplete.Popup>
{fruits.map(f => (
<Autocomplete.Option
key={f.value}
value={f.value}
label={f.label}
>
{f.label}
</Autocomplete.Option>
))}
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Root>,
);

await user.type(screen.getByPlaceholderText('Search fruits...'), 'a');
await user.click(screen.getByPlaceholderText('Search fruits...'));
await user.keyboard('{ArrowDown}{ArrowDown}');

const options = screen.getAllByRole('option');
expect(options[0]).toHaveAttribute('aria-selected', 'true');
const options = screen.getAllByRole('option', { hidden: true });
const active = options.find(option => option.hasAttribute('data-active'));
const selected = options.find(option => option.hasAttribute('data-selected'));
expect(active).toBeDefined();
expect(active).not.toBe(selected);
expect(selected).toHaveAttribute('aria-selected', 'true');
expect(active).toHaveAttribute('aria-selected', 'false');
});
});

describe('Arrow', () => {
it('forwards a ref to the arrow element', () => {
const ref = createRef<SVGSVGElement>();
render(
<Autocomplete.Root defaultOpen>
<Autocomplete.Input placeholder='Search fruits...' />
<Autocomplete.Positioner>
<Autocomplete.Popup>
<Autocomplete.Arrow ref={ref} />
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Root>,
);

expect(ref.current).toBeInstanceOf(SVGSVGElement);
});
});

describe('animation lifecycle', () => {
it('holds options during exit and shows updated options when reopened', () => {
function Fixture({ open, label }: { open: boolean; label: string }) {
return (
<Autocomplete.Root open={open}>
<Autocomplete.Input aria-label='Fruit' />
<Autocomplete.Positioner>
<Autocomplete.Popup data-testid='frozen-popup'>
<Autocomplete.Option value={label}>{label}</Autocomplete.Option>
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Root>
);
}

const { rerender } = render(
<Fixture
open
label='Apple'
/>,
);
const popup = screen.getByTestId('frozen-popup');
Object.defineProperty(popup, 'getAnimations', {
value: () => [{ finished: new Promise<void>(() => {}) }],
});

rerender(
<Fixture
open={false}
label='Banana'
/>,
);

expect(popup).toHaveAttribute('data-closed', '');
expect(popup).toHaveTextContent('Apple');
expect(popup).not.toHaveTextContent('Banana');

rerender(
<Fixture
open
label='Banana'
/>,
);

expect(popup).toHaveAttribute('data-open', '');
expect(popup).toHaveTextContent('Banana');
expect(popup).not.toHaveTextContent('Apple');
});

it('positioner is not rendered when closed', () => {
render(<StaticAutocomplete />);
expect(document.querySelector('[data-testid="autocomplete-positioner"]')).not.toBeInTheDocument();
Expand Down
61 changes: 3 additions & 58 deletions packages/mosaic/src/primitives/collapsible/collapsible-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
'use client';

import React, { useLayoutEffect, useRef, useState } from 'react';
import React from 'react';

import { useAnimationsFinished } from '../hooks/use-animations-finished';
import { useTransition } from '../hooks/use-transition';
import { usePanelSize } from '../hooks/use-panel-size';
import { type ComponentProps, mergeProps, useRender } from '../utils';
import { autoUpdate, getScrollDimensions } from '../utils/dom';
import { useCollapsibleContext } from './collapsible-context';

export type CollapsiblePanelProps = ComponentProps<'div'>;
Expand All @@ -15,60 +13,7 @@ export const CollapsiblePanel = React.forwardRef<HTMLDivElement, CollapsiblePane
const { render, ...otherProps } = props;
const { open, triggerId, panelId } = useCollapsibleContext();

const panelRef = useRef<HTMLElement | null>(null);
const [height, setHeight] = useState<number | undefined>(undefined);
const [width, setWidth] = useState<number | undefined>(undefined);

const { mounted, transitionStatus, transitionProps } = useTransition({
open,
ref: panelRef,
});

const runOnAnimationsFinished = useAnimationsFinished(panelRef, open);

useLayoutEffect(() => {
if (!mounted) {
return;
}

const panel = panelRef.current;
if (!panel) {
return;
}

return autoUpdate(panel, () => {
const dimensions = getScrollDimensions(panel);
setHeight(dimensions.height);
setWidth(dimensions.width);
});
}, [mounted]);

// Once the open animation settles, drop the measured pixel dimensions so the
// panel flows naturally with its content (CSS vars omitted → auto).
useLayoutEffect(() => {
if (!open || !mounted) {
return;
}
runOnAnimationsFinished(() => {
setHeight(undefined);
setWidth(undefined);
});
}, [open, mounted, runOnAnimationsFinished]);

// Re-pin to measured pixel values when closing starts so the exit
// transition animates from concrete dimensions (auto can't interpolate).
useLayoutEffect(() => {
if (open || transitionStatus !== 'ending') {
return;
}
const panel = panelRef.current;
if (!panel) {
return;
}
const dimensions = getScrollDimensions(panel);
setHeight(dimensions.height);
setWidth(dimensions.width);
}, [open, transitionStatus]);
const { panelRef, mounted, transitionProps, height, width } = usePanelSize(open);

const state = { open };

Expand Down
Loading
Loading