From 235697fe9b17a2bacf2f4640af7007fff15183c0 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 16:48:48 -0600 Subject: [PATCH 1/2] fix(mosaic): forward refs from every part that renders through useRender Adds the mosaic/forward-ref-parts lint rule and wraps the parts it flags in React.forwardRef so consumer refs reach the DOM element on React 18. --- .changeset/happy-waves-bake.md | 2 + packages/mosaic/docs/headless.md | 3 + packages/mosaic/eslint/config.mjs | 11 +- packages/mosaic/eslint/import-guards.test.mjs | 45 ++++- packages/mosaic/eslint/rules.mjs | 160 ++++++++++++++++++ .../mosaic/src/components/menu/menu.test.tsx | 15 ++ packages/mosaic/src/components/menu/menu.tsx | 8 +- .../primitives/accordion/accordion-header.tsx | 23 ++- .../primitives/accordion/accordion-item.tsx | 7 +- .../primitives/accordion/accordion-root.tsx | 6 +- .../accordion/accordion-trigger.tsx | 108 ++++++------ .../primitives/accordion/accordion.test.tsx | 31 ++++ .../collapsible/collapsible-root.tsx | 7 +- .../collapsible/collapsible-trigger.tsx | 75 ++++---- .../collapsible/collapsible.test.tsx | 17 ++ .../file-upload/file-upload-dropzone.tsx | 121 ++++++------- .../file-upload/file-upload-item-delete.tsx | 53 +++--- .../file-upload/file-upload-item.tsx | 57 ++++--- .../file-upload/file-upload-root.tsx | 7 +- .../file-upload/file-upload-trigger.tsx | 43 ++--- .../file-upload/file-upload.test.tsx | 38 +++++ .../src/primitives/menu/menu-separator.tsx | 7 +- .../mosaic/src/primitives/menu/menu.test.tsx | 17 ++ .../mosaic/src/primitives/otp/otp-root.tsx | 16 +- .../mosaic/src/primitives/otp/otp.test.tsx | 15 +- .../src/primitives/popover/popover-close.tsx | 7 +- .../popover/popover-description.tsx | 37 ++-- .../src/primitives/popover/popover-title.tsx | 7 +- .../src/primitives/popover/popover.test.tsx | 23 +++ .../select/__tests__/select.test.tsx | 34 ++++ .../src/primitives/select/select-option.tsx | 8 +- .../src/primitives/select/select-value.tsx | 6 +- .../mosaic/src/primitives/tabs/tabs-list.tsx | 6 +- .../mosaic/src/primitives/tabs/tabs.test.tsx | 12 ++ 34 files changed, 753 insertions(+), 279 deletions(-) create mode 100644 .changeset/happy-waves-bake.md diff --git a/.changeset/happy-waves-bake.md b/.changeset/happy-waves-bake.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/happy-waves-bake.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/docs/headless.md b/packages/mosaic/docs/headless.md index 3a4e5685209..cbe49407a7a 100644 --- a/packages/mosaic/docs/headless.md +++ b/packages/mosaic/docs/headless.md @@ -42,6 +42,9 @@ Every part that renders a DOM element calls **`useRender`** (from Copy `src/primitives/select/select-trigger.tsx`: it is the reference part. Its params are documented on `UseRenderParams*` in `utils/use-render.tsx`. +- **Forward the ref.** Every part that renders through `useRender` is a + `React.forwardRef` and passes its `ref` to `useRender`; + `mosaic/forward-ref-parts` enforces this. - **`ownProps satisfies DefaultProps`** for authored defaults, not `as`. - **`mergeProps(internal, consumer)` — internal first, consumer second.** Event handlers chain (internal fires, then consumer), `style` shallow-merges, diff --git a/packages/mosaic/eslint/config.mjs b/packages/mosaic/eslint/config.mjs index 0e2f95ca67f..ab7e3b23c2d 100644 --- a/packages/mosaic/eslint/config.mjs +++ b/packages/mosaic/eslint/config.mjs @@ -4,7 +4,7 @@ import vitest from '@vitest/eslint-plugin'; import boundaries from 'eslint-plugin-boundaries'; import noUnneededEffects from 'eslint-plugin-react-you-might-not-need-an-effect'; -import { noDynamicMessageCatalogs, noModelReactState } from './rules.mjs'; +import { forwardRefParts, noDynamicMessageCatalogs, noModelReactState } from './rules.mjs'; const mosaicRoot = path.resolve(import.meta.dirname, '..'); @@ -29,6 +29,7 @@ export default [ rules: { 'no-model-react-state': noModelReactState, 'no-dynamic-message-catalogs': noDynamicMessageCatalogs, + 'forward-ref-parts': forwardRefParts, }, }, }, @@ -54,6 +55,14 @@ export default [ 'mosaic/no-model-react-state': 'error', }, }, + { + name: 'packages/mosaic - parts', + files: ['src/primitives/**/*.tsx', 'src/components/**/*.tsx'], + ignores: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}'], + rules: { + 'mosaic/forward-ref-parts': 'error', + }, + }, { name: 'packages/mosaic - effects', files: ['src/features/**/*.{ts,tsx}'], diff --git a/packages/mosaic/eslint/import-guards.test.mjs b/packages/mosaic/eslint/import-guards.test.mjs index 29b7e4de800..31664f2947a 100644 --- a/packages/mosaic/eslint/import-guards.test.mjs +++ b/packages/mosaic/eslint/import-guards.test.mjs @@ -27,6 +27,8 @@ const flowFixtures = [ 'sample.messages.ts', ].map(file => `${featureDir}/${file}`); const testFixture = 'src/hooks/__tests__/sample.test.ts'; +const partFixture = 'src/primitives/sample-part.tsx'; +const partTestFixture = 'src/primitives/__tests__/sample-part.test.tsx'; const rootEslint = new ESLint({ cwd: root }); const eslint = new ESLint({ cwd: mosaicRoot, @@ -34,7 +36,9 @@ const eslint = new ESLint({ languageOptions: { parserOptions: { projectService: { - allowDefaultProject: [...flowFixtures, ...layerFixtures, testFixture].map(file => `packages/mosaic/${file}`), + allowDefaultProject: [...flowFixtures, ...layerFixtures, testFixture, partFixture, partTestFixture].map( + file => `packages/mosaic/${file}`, + ), maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 32, }, }, @@ -345,6 +349,44 @@ describe('Mosaic layers', { timeout: 30_000 }, () => { }); }); +describe('Mosaic parts', { timeout: 30_000 }, () => { + async function forwardRefMessages(file, source) { + const messages = await lintAt( + file, + ["import React from 'react';", "import { useRender } from './utils';", source].join('\n'), + ); + expect(messages.find(message => message.fatal)).toBeUndefined(); + return messages.filter(message => message.ruleId === 'mosaic/forward-ref-parts').map(message => message.message); + } + + test.each([ + "export function Part(props: { render?: never }) { return useRender({ defaultTagName: 'div', props }); }", + "export const Part = (props: { render?: never }) => useRender({ defaultTagName: 'div', props });", + "export function Part(props: { render?: never }) { return useRender({ defaultTagName: 'div', props })} />; }", + "export const Part = React.forwardRef(function Part(props) { return useRender({ defaultTagName: 'div', props }); });", + ])('rejects a part that drops the ref: %s', async source => { + expect(await forwardRefMessages(partFixture, source)).toEqual([ + 'Parts that render through useRender must use React.forwardRef and pass the ref to useRender (see primitives/select/select-trigger.tsx).', + ]); + }); + + test.each([ + "export const Part = React.forwardRef(function Part(props, ref) { return useRender({ defaultTagName: 'button', ref: [null, ref], props }); });", + 'export function Provider(props: { children: React.ReactNode }) { return props.children; }', + ])('allows parts that forward the ref or render nothing: %s', async source => { + expect(await forwardRefMessages(partFixture, source)).toEqual([]); + }); + + test('ignores test files', async () => { + expect( + await forwardRefMessages( + partTestFixture, + "export function Part(props: { render?: never }) { return useRender({ defaultTagName: 'div', props }); }", + ), + ).toEqual([]); + }); +}); + describe('Mosaic tests', { timeout: 30_000 }, () => { async function vitestRules(source) { const messages = await lintAt(testFixture, ["import { describe, expect, test } from 'vitest';", source].join('\n')); @@ -450,6 +492,7 @@ describe('Mosaic package configuration', { timeout: 30_000 }, () => { 'boundaries/dependencies', 'mosaic/no-model-react-state', 'mosaic/no-dynamic-message-catalogs', + 'mosaic/forward-ref-parts', '@typescript-eslint/no-import-type-side-effects', ...Object.keys(noUnneededEffects.configs.strict.rules), ]) { diff --git a/packages/mosaic/eslint/rules.mjs b/packages/mosaic/eslint/rules.mjs index 2b4b12a9987..5b3ed7e4784 100644 --- a/packages/mosaic/eslint/rules.mjs +++ b/packages/mosaic/eslint/rules.mjs @@ -77,3 +77,163 @@ export const noDynamicMessageCatalogs = { }; }, }; + +function isForwardRefCall(node) { + if (node?.type !== 'CallExpression') { + return false; + } + const callee = node.callee; + if (callee.type === 'Identifier') { + return callee.name === 'forwardRef'; + } + return ( + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.object.name === 'React' && + propertyName(callee.property, callee.computed) === 'forwardRef' + ); +} + +function unwrapExpression(node) { + let current = node; + while (current && ['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression'].includes(current.type)) { + current = current.expression; + } + return current; +} + +function isFunction(node) { + return ['FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'].includes(node?.type); +} + +function useRenderCalls(fn, visitorKeys) { + const calls = []; + const visit = node => { + if (!node || typeof node.type !== 'string') { + return; + } + if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'useRender') { + calls.push(node); + } + for (const key of visitorKeys[node.type] ?? []) { + const child = node[key]; + if (Array.isArray(child)) { + child.forEach(visit); + } else { + visit(child); + } + } + }; + visit(fn); + return calls; +} + +function passesRef(call) { + const [options] = call.arguments; + if (options?.type !== 'ObjectExpression') { + return true; + } + return options.properties.some( + property => + property.type === 'SpreadElement' || + (property.type === 'Property' && propertyName(property.key, property.computed) === 'ref'), + ); +} + +export const forwardRefParts = { + meta: { + type: 'problem', + schema: [], + messages: { + forwardRef: + 'Parts that render through useRender must use React.forwardRef and pass the ref to useRender (see primitives/select/select-trigger.tsx).', + }, + }, + create(context) { + const isComponentName = name => /^[A-Z]/.test(name); + + const check = (reportNode, init) => { + const expression = unwrapExpression(init); + const wrapped = isForwardRefCall(expression); + const fn = wrapped ? unwrapExpression(expression.arguments[0]) : expression; + if (!isFunction(fn)) { + return; + } + const calls = useRenderCalls(fn, context.sourceCode.visitorKeys); + if (calls.length === 0) { + return; + } + if (!wrapped || calls.some(call => !passesRef(call))) { + context.report({ node: reportNode, messageId: 'forwardRef' }); + } + }; + + const checkDeclaration = declaration => { + if (declaration?.type === 'FunctionDeclaration' && declaration.id && isComponentName(declaration.id.name)) { + check(declaration.id, declaration); + } + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + if (declarator.id.type === 'Identifier' && isComponentName(declarator.id.name) && declarator.init) { + check(declarator.id, declarator.init); + } + } + } + }; + + return { + Program(program) { + const localDeclarations = new Map(); + for (const statement of program.body) { + const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; + if (declaration?.type === 'FunctionDeclaration' && declaration.id) { + localDeclarations.set(declaration.id.name, declaration); + } + if (declaration?.type === 'VariableDeclaration') { + for (const declarator of declaration.declarations) { + if (declarator.id.type === 'Identifier') { + localDeclarations.set(declarator.id.name, { type: 'VariableDeclaration', declarations: [declarator] }); + } + } + } + } + + const checked = new Set(); + const checkOnce = declaration => { + if (declaration && !checked.has(declaration)) { + checked.add(declaration); + checkDeclaration(declaration); + } + }; + + for (const statement of program.body) { + if (statement.type === 'ExportNamedDeclaration') { + if (statement.declaration?.type === 'FunctionDeclaration') { + checkOnce(localDeclarations.get(statement.declaration.id?.name)); + } else if (statement.declaration?.type === 'VariableDeclaration') { + for (const declarator of statement.declaration.declarations) { + if (declarator.id.type === 'Identifier') { + checkOnce(localDeclarations.get(declarator.id.name)); + } + } + } else if (!statement.source) { + for (const specifier of statement.specifiers) { + if (specifier.local.type === 'Identifier' && isComponentName(specifier.exported.name ?? '')) { + checkOnce(localDeclarations.get(specifier.local.name)); + } + } + } + } + if (statement.type === 'ExportDefaultDeclaration') { + const declaration = statement.declaration; + if (declaration.type === 'Identifier') { + checkOnce(localDeclarations.get(declaration.name)); + } else if (declaration.type === 'FunctionDeclaration' && declaration.id) { + checkOnce(localDeclarations.get(declaration.id.name)); + } + } + } + }, + }; + }, +}; diff --git a/packages/mosaic/src/components/menu/menu.test.tsx b/packages/mosaic/src/components/menu/menu.test.tsx index 61bab15e268..7046923cea4 100644 --- a/packages/mosaic/src/components/menu/menu.test.tsx +++ b/packages/mosaic/src/components/menu/menu.test.tsx @@ -371,4 +371,19 @@ describe('Mosaic Menu', () => { ); expect(ref.current).toBe(screen.getByRole('button')); }); + + it('forwards a ref to the separator', () => { + const ref = React.createRef(); + render( + + + + Cut + + Copy + + , + ); + expect(ref.current).toBe(screen.getByRole('separator')); + }); }); diff --git a/packages/mosaic/src/components/menu/menu.tsx b/packages/mosaic/src/components/menu/menu.tsx index 9fa8081b4a0..45e7178796b 100644 --- a/packages/mosaic/src/components/menu/menu.tsx +++ b/packages/mosaic/src/components/menu/menu.tsx @@ -226,13 +226,17 @@ export const MenuItem = React.forwardRef(funct export type MenuSeparatorProps = Omit & MosaicStyleProps; /** A full-bleed divider between groups of items. */ -export function MenuSeparator({ xstyle, ...rest }: MenuSeparatorProps): React.ReactElement { +export const MenuSeparator = React.forwardRef(function MosaicMenuSeparator( + { xstyle, ...rest }, + ref, +) { return ( ); -} +}); export const Menu = { // Renders no element of its own; re-exported unchanged so the state provider sits diff --git a/packages/mosaic/src/primitives/accordion/accordion-header.tsx b/packages/mosaic/src/primitives/accordion/accordion-header.tsx index 4089c1d3425..43d4c019cd2 100644 --- a/packages/mosaic/src/primitives/accordion/accordion-header.tsx +++ b/packages/mosaic/src/primitives/accordion/accordion-header.tsx @@ -1,17 +1,22 @@ 'use client'; +import React from 'react'; + import { type ComponentProps, mergeProps, useRender } from '../utils'; export type AccordionHeaderProps = ComponentProps<'h3'>; -export function AccordionHeader(props: AccordionHeaderProps) { - const { render, ...otherProps } = props; +export const AccordionHeader = React.forwardRef( + function AccordionHeader(props, ref) { + const { render, ...otherProps } = props; - const defaultProps = {}; + const defaultProps = {}; - return useRender({ - defaultTagName: 'h3', - render, - props: mergeProps<'h3'>(defaultProps, otherProps), - }); -} + return useRender({ + defaultTagName: 'h3', + ref, + render, + props: mergeProps<'h3'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/mosaic/src/primitives/accordion/accordion-item.tsx b/packages/mosaic/src/primitives/accordion/accordion-item.tsx index 40704b32cc0..ecc92e5d292 100644 --- a/packages/mosaic/src/primitives/accordion/accordion-item.tsx +++ b/packages/mosaic/src/primitives/accordion/accordion-item.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo } from 'react'; +import React, { useMemo } from 'react'; import { type ComponentProps, mergeProps, useRender } from '../utils'; import { AccordionItemContext, type AccordionItemContextValue, useAccordionContext } from './accordion-context'; @@ -12,7 +12,7 @@ export interface AccordionItemProps extends ComponentProps<'div'> { disabled?: boolean; } -export function AccordionItem(props: AccordionItemProps) { +export const AccordionItem = React.forwardRef(function AccordionItem(props, ref) { const { render, value: itemValue, disabled: itemDisabled, ...otherProps } = props; const ctx = useAccordionContext(); @@ -34,6 +34,7 @@ export function AccordionItem(props: AccordionItemProps) { {useRender({ defaultTagName: 'div', + ref, render, state, stateAttributesMapping: { @@ -44,4 +45,4 @@ export function AccordionItem(props: AccordionItemProps) { })} ); -} +}); diff --git a/packages/mosaic/src/primitives/accordion/accordion-root.tsx b/packages/mosaic/src/primitives/accordion/accordion-root.tsx index 32cf87b437f..2adddfc4c21 100644 --- a/packages/mosaic/src/primitives/accordion/accordion-root.tsx +++ b/packages/mosaic/src/primitives/accordion/accordion-root.tsx @@ -21,7 +21,7 @@ export interface AccordionProps extends ComponentProps<'div'> { children: ReactNode; } -export function AccordionRoot(props: AccordionProps) { +export const AccordionRoot = React.forwardRef(function AccordionRoot(props, ref) { const { render, type = 'multiple', disabled = false, children, ...otherProps } = props; const [value, setValue] = useControllableState(props.value, props.defaultValue ?? [], props.onValueChange); @@ -80,7 +80,7 @@ export function AccordionRoot(props: AccordionProps) { return useRender({ defaultTagName: 'div', render, - ref: isRef(compositeRef) ? compositeRef : undefined, + ref: [isRef(compositeRef) ? compositeRef : null, ref], props: mergedProps, }); }} @@ -89,4 +89,4 @@ export function AccordionRoot(props: AccordionProps) { ); -} +}); diff --git a/packages/mosaic/src/primitives/accordion/accordion-trigger.tsx b/packages/mosaic/src/primitives/accordion/accordion-trigger.tsx index 0731c8b78de..7dddcf23a59 100644 --- a/packages/mosaic/src/primitives/accordion/accordion-trigger.tsx +++ b/packages/mosaic/src/primitives/accordion/accordion-trigger.tsx @@ -8,56 +8,58 @@ import { useAccordionContext, useAccordionItemContext } from './accordion-contex export type AccordionTriggerProps = ComponentProps<'button'>; -export function AccordionTrigger(props: AccordionTriggerProps) { - const { render, children, ...otherProps } = props; - const ctx = useAccordionContext(); - const { itemValue, open, disabled, triggerId, panelId } = useAccordionItemContext(); - - const state = { open, disabled }; - - return ( - ) => { - const defaultProps: Record = { - id: triggerId, - type: 'button' as const, - 'aria-expanded': open, - 'aria-controls': panelId, - 'aria-disabled': disabled || undefined, - onClick: () => { - if (!disabled) { - ctx.toggle(itemValue); - } - }, - }; - - const merged = mergeProps<'button'>(mergeProps<'button'>(defaultProps, otherProps), compositeProps); - - // The wired id is owned by the primitive: a consumer-supplied id must - // not override it, or the trigger/panel aria pairing would silently break. - merged.id = triggerId; - - // CompositeItem injects its roving-tabindex ref via compositeProps; hand it to - // useRender's ref param (which owns ref-merging) instead of leaving it in props, - // where useRender's merged ref would overwrite it and break focus navigation. - const { ref: compositeRef, ...mergedProps } = merged; - - // eslint-disable-next-line react-hooks/rules-of-hooks -- floating-ui's CompositeItem calls this render callback synchronously during its own render, so the hook keeps a stable position. - return useRender({ - defaultTagName: 'button', - render, - ref: isRef(compositeRef) ? compositeRef : undefined, - state, - stateAttributesMapping: { - open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), - disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), - }, - props: mergedProps, - }); - }} - > - {children} - - ); -} +export const AccordionTrigger = React.forwardRef( + function AccordionTrigger(props, ref) { + const { render, children, ...otherProps } = props; + const ctx = useAccordionContext(); + const { itemValue, open, disabled, triggerId, panelId } = useAccordionItemContext(); + + const state = { open, disabled }; + + return ( + ) => { + const defaultProps: Record = { + id: triggerId, + type: 'button' as const, + 'aria-expanded': open, + 'aria-controls': panelId, + 'aria-disabled': disabled || undefined, + onClick: () => { + if (!disabled) { + ctx.toggle(itemValue); + } + }, + }; + + const merged = mergeProps<'button'>(mergeProps<'button'>(defaultProps, otherProps), compositeProps); + + // The wired id is owned by the primitive: a consumer-supplied id must + // not override it, or the trigger/panel aria pairing would silently break. + merged.id = triggerId; + + // CompositeItem injects its roving-tabindex ref via compositeProps; hand it to + // useRender's ref param (which owns ref-merging) instead of leaving it in props, + // where useRender's merged ref would overwrite it and break focus navigation. + const { ref: compositeRef, ...mergedProps } = merged; + + // eslint-disable-next-line react-hooks/rules-of-hooks -- floating-ui's CompositeItem calls this render callback synchronously during its own render, so the hook keeps a stable position. + return useRender({ + defaultTagName: 'button', + render, + ref: [isRef(compositeRef) ? compositeRef : null, ref], + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), + }, + props: mergedProps, + }); + }} + > + {children} + + ); + }, +); diff --git a/packages/mosaic/src/primitives/accordion/accordion.test.tsx b/packages/mosaic/src/primitives/accordion/accordion.test.tsx index 7b939a021f9..ab631f72081 100644 --- a/packages/mosaic/src/primitives/accordion/accordion.test.tsx +++ b/packages/mosaic/src/primitives/accordion/accordion.test.tsx @@ -1,5 +1,6 @@ import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { axe } from '../test-utils/axe'; @@ -442,4 +443,34 @@ describe('Accordion', () => { expect(await axe(container)).toHaveNoViolations(); }); }); + + it('forwards refs to the root, item, header and trigger', () => { + const refs = { + root: createRef(), + item: createRef(), + header: createRef(), + trigger: createRef(), + }; + render( + + + + Section 1 + + Content 1 + + , + ); + expect(refs.root.current).toBe(screen.getByTestId('accordion-root')); + expect(refs.item.current).toBe(screen.getByTestId('accordion-item')); + expect(refs.header.current).toBe(screen.getByRole('heading')); + expect(refs.trigger.current).toBe(screen.getByRole('button', { name: 'Section 1' })); + }); }); diff --git a/packages/mosaic/src/primitives/collapsible/collapsible-root.tsx b/packages/mosaic/src/primitives/collapsible/collapsible-root.tsx index 85c368948d2..fb404895c4e 100644 --- a/packages/mosaic/src/primitives/collapsible/collapsible-root.tsx +++ b/packages/mosaic/src/primitives/collapsible/collapsible-root.tsx @@ -1,6 +1,6 @@ 'use client'; -import { type ReactNode, useId, useMemo } from 'react'; +import React, { type ReactNode, useId, useMemo } from 'react'; import { useControllableState } from '../hooks/use-controllable-state'; import { type ComponentProps, mergeProps, useRender } from '../utils'; @@ -18,7 +18,7 @@ export interface CollapsibleProps extends ComponentProps<'div'> { children: ReactNode; } -export function CollapsibleRoot(props: CollapsibleProps) { +export const CollapsibleRoot = React.forwardRef(function CollapsibleRoot(props, ref) { const { render, open: openProp, defaultOpen, onOpenChange, disabled = false, children, ...otherProps } = props; const [open, setOpen] = useControllableState(openProp, defaultOpen ?? false, onOpenChange); @@ -42,6 +42,7 @@ export function CollapsibleRoot(props: CollapsibleProps) { {useRender({ defaultTagName: 'div', + ref, render, state, stateAttributesMapping: { @@ -52,4 +53,4 @@ export function CollapsibleRoot(props: CollapsibleProps) { })} ); -} +}); diff --git a/packages/mosaic/src/primitives/collapsible/collapsible-trigger.tsx b/packages/mosaic/src/primitives/collapsible/collapsible-trigger.tsx index 1ab27e66337..1dd9f526eb2 100644 --- a/packages/mosaic/src/primitives/collapsible/collapsible-trigger.tsx +++ b/packages/mosaic/src/primitives/collapsible/collapsible-trigger.tsx @@ -1,42 +1,47 @@ 'use client'; +import React from 'react'; + import { type ComponentProps, mergeProps, useRender } from '../utils'; import { useCollapsibleContext } from './collapsible-context'; export type CollapsibleTriggerProps = ComponentProps<'button'>; -export function CollapsibleTrigger(props: CollapsibleTriggerProps) { - const { render, ...otherProps } = props; - const { open, setOpen, disabled, triggerId, panelId } = useCollapsibleContext(); - - const state = { open, disabled }; - - const defaultProps: Record = { - id: triggerId, - type: 'button' as const, - 'aria-expanded': open, - 'aria-controls': panelId, - 'aria-disabled': disabled || undefined, - onClick: () => { - if (!disabled) { - setOpen(!open); - } - }, - }; - - const merged = mergeProps<'button'>(defaultProps, otherProps); - // The wired id is owned by the primitive: a consumer-supplied id must not - // override it, or the trigger/panel aria pairing would silently break. - merged.id = triggerId; - - return useRender({ - defaultTagName: 'button', - render, - state, - stateAttributesMapping: { - open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), - disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), - }, - props: merged, - }); -} +export const CollapsibleTrigger = React.forwardRef( + function CollapsibleTrigger(props, ref) { + const { render, ...otherProps } = props; + const { open, setOpen, disabled, triggerId, panelId } = useCollapsibleContext(); + + const state = { open, disabled }; + + const defaultProps: Record = { + id: triggerId, + type: 'button' as const, + 'aria-expanded': open, + 'aria-controls': panelId, + 'aria-disabled': disabled || undefined, + onClick: () => { + if (!disabled) { + setOpen(!open); + } + }, + }; + + const merged = mergeProps<'button'>(defaultProps, otherProps); + // The wired id is owned by the primitive: a consumer-supplied id must not + // override it, or the trigger/panel aria pairing would silently break. + merged.id = triggerId; + + return useRender({ + defaultTagName: 'button', + ref, + render, + state, + stateAttributesMapping: { + open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), + }, + props: merged, + }); + }, +); diff --git a/packages/mosaic/src/primitives/collapsible/collapsible.test.tsx b/packages/mosaic/src/primitives/collapsible/collapsible.test.tsx index b4a78beafdf..576e84b476c 100644 --- a/packages/mosaic/src/primitives/collapsible/collapsible.test.tsx +++ b/packages/mosaic/src/primitives/collapsible/collapsible.test.tsx @@ -1,5 +1,6 @@ import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { axe } from '../test-utils/axe'; @@ -208,4 +209,20 @@ describe('Collapsible', () => { expect(await axe(container)).toHaveNoViolations(); }); }); + + it('forwards refs to the root and trigger', () => { + const root = createRef(); + const trigger = createRef(); + render( + + Toggle + Content + , + ); + expect(root.current).toBe(screen.getByTestId('collapsible-root')); + expect(trigger.current).toBe(screen.getByRole('button', { name: 'Toggle' })); + }); }); diff --git a/packages/mosaic/src/primitives/file-upload/file-upload-dropzone.tsx b/packages/mosaic/src/primitives/file-upload/file-upload-dropzone.tsx index 6a1c3e0efa7..30416c204e9 100644 --- a/packages/mosaic/src/primitives/file-upload/file-upload-dropzone.tsx +++ b/packages/mosaic/src/primitives/file-upload/file-upload-dropzone.tsx @@ -1,73 +1,76 @@ 'use client'; -import { type DragEvent, useRef, useState } from 'react'; +import React, { type DragEvent, useRef, useState } from 'react'; import { type ComponentProps, mergeProps, useRender } from '../utils'; import { useFileUploadContext } from './file-upload-context'; export type FileUploadDropzoneProps = ComponentProps<'div'>; -export function FileUploadDropzone(props: FileUploadDropzoneProps) { - const { render, ...otherProps } = props; - const { disabled, addFiles } = useFileUploadContext(); +export const FileUploadDropzone = React.forwardRef( + function FileUploadDropzone(props, ref) { + const { render, ...otherProps } = props; + const { disabled, addFiles } = useFileUploadContext(); - const [dragging, setDragging] = useState(false); - // Counts enter/leave events so dragging over child elements does not flicker - // the drag state (dragenter/dragleave fire per nested element). - const dragDepth = useRef(0); + const [dragging, setDragging] = useState(false); + // Counts enter/leave events so dragging over child elements does not flicker + // the drag state (dragenter/dragleave fire per nested element). + const dragDepth = useRef(0); - const state = { dragging, disabled }; + const state = { dragging, disabled }; - const defaultProps: Record = { - 'aria-disabled': disabled || undefined, - onDragEnter: (event: DragEvent) => { - event.preventDefault(); - if (disabled) { - return; - } - dragDepth.current += 1; - setDragging(true); - }, - onDragOver: (event: DragEvent) => { - // Required for the element to be a valid drop target. - event.preventDefault(); - if (!disabled) { - event.dataTransfer.dropEffect = 'copy'; - } - }, - onDragLeave: (event: DragEvent) => { - event.preventDefault(); - if (disabled) { - return; - } - dragDepth.current -= 1; - if (dragDepth.current <= 0) { + const defaultProps: Record = { + 'aria-disabled': disabled || undefined, + onDragEnter: (event: DragEvent) => { + event.preventDefault(); + if (disabled) { + return; + } + dragDepth.current += 1; + setDragging(true); + }, + onDragOver: (event: DragEvent) => { + // Required for the element to be a valid drop target. + event.preventDefault(); + if (!disabled) { + event.dataTransfer.dropEffect = 'copy'; + } + }, + onDragLeave: (event: DragEvent) => { + event.preventDefault(); + if (disabled) { + return; + } + dragDepth.current -= 1; + if (dragDepth.current <= 0) { + dragDepth.current = 0; + setDragging(false); + } + }, + onDrop: (event: DragEvent) => { + event.preventDefault(); dragDepth.current = 0; setDragging(false); - } - }, - onDrop: (event: DragEvent) => { - event.preventDefault(); - dragDepth.current = 0; - setDragging(false); - if (disabled) { - return; - } - const { files } = event.dataTransfer; - if (files.length > 0) { - addFiles(files); - } - }, - }; + if (disabled) { + return; + } + const { files } = event.dataTransfer; + if (files.length > 0) { + addFiles(files); + } + }, + }; - return useRender({ - defaultTagName: 'div', - render, - state, - stateAttributesMapping: { - dragging: (v: boolean) => (v ? { 'data-dragging': '' } : null), - disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), - }, - props: mergeProps<'div'>(defaultProps, otherProps), - }); -} + return useRender({ + defaultTagName: 'div', + ref, + render, + state, + stateAttributesMapping: { + dragging: (v: boolean) => (v ? { 'data-dragging': '' } : null), + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), + }, + props: mergeProps<'div'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/mosaic/src/primitives/file-upload/file-upload-item-delete.tsx b/packages/mosaic/src/primitives/file-upload/file-upload-item-delete.tsx index 7b63dad2f34..2065ebe049b 100644 --- a/packages/mosaic/src/primitives/file-upload/file-upload-item-delete.tsx +++ b/packages/mosaic/src/primitives/file-upload/file-upload-item-delete.tsx @@ -1,34 +1,39 @@ 'use client'; +import React from 'react'; + import { type ComponentProps, mergeProps, useRender } from '../utils'; import { useFileUploadContext, useFileUploadItemContext } from './file-upload-context'; export type FileUploadItemDeleteProps = ComponentProps<'button'>; -export function FileUploadItemDelete(props: FileUploadItemDeleteProps) { - const { render, ...otherProps } = props; - const { disabled } = useFileUploadContext(); - const { remove } = useFileUploadItemContext(); +export const FileUploadItemDelete = React.forwardRef( + function FileUploadItemDelete(props, ref) { + const { render, ...otherProps } = props; + const { disabled } = useFileUploadContext(); + const { remove } = useFileUploadItemContext(); - const state = { disabled }; + const state = { disabled }; - const defaultProps: Record = { - type: 'button' as const, - disabled, - onClick: () => { - if (!disabled) { - remove(); - } - }, - }; + const defaultProps: Record = { + type: 'button' as const, + disabled, + onClick: () => { + if (!disabled) { + remove(); + } + }, + }; - return useRender({ - defaultTagName: 'button', - render, - state, - stateAttributesMapping: { - disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), - }, - props: mergeProps<'button'>(defaultProps, otherProps), - }); -} + return useRender({ + defaultTagName: 'button', + ref, + render, + state, + stateAttributesMapping: { + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/mosaic/src/primitives/file-upload/file-upload-item.tsx b/packages/mosaic/src/primitives/file-upload/file-upload-item.tsx index b6a2d30d75a..427a9f24175 100644 --- a/packages/mosaic/src/primitives/file-upload/file-upload-item.tsx +++ b/packages/mosaic/src/primitives/file-upload/file-upload-item.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { type ComponentProps, mergeProps, useRender } from '../utils'; import { FileUploadItemContext, type FileUploadItemContextValue, useFileUploadContext } from './file-upload-context'; @@ -10,29 +10,32 @@ export interface FileUploadItemProps extends ComponentProps<'div'> { file: File; } -export function FileUploadItem(props: FileUploadItemProps) { - const { render, file, ...otherProps } = props; - const { removeFile } = useFileUploadContext(); - - const remove = useCallback(() => removeFile(file), [removeFile, file]); - - const itemContextValue = useMemo(() => ({ file, remove }), [file, remove]); - - const state = { image: file.type.startsWith('image/') }; - - const defaultProps: Record = {}; - - return ( - - {useRender({ - defaultTagName: 'div', - render, - state, - stateAttributesMapping: { - image: (v: boolean) => (v ? { 'data-image': '' } : null), - }, - props: mergeProps<'div'>(defaultProps, otherProps), - })} - - ); -} +export const FileUploadItem = React.forwardRef( + function FileUploadItem(props, ref) { + const { render, file, ...otherProps } = props; + const { removeFile } = useFileUploadContext(); + + const remove = useCallback(() => removeFile(file), [removeFile, file]); + + const itemContextValue = useMemo(() => ({ file, remove }), [file, remove]); + + const state = { image: file.type.startsWith('image/') }; + + const defaultProps: Record = {}; + + return ( + + {useRender({ + defaultTagName: 'div', + ref, + render, + state, + stateAttributesMapping: { + image: (v: boolean) => (v ? { 'data-image': '' } : null), + }, + props: mergeProps<'div'>(defaultProps, otherProps), + })} + + ); + }, +); diff --git a/packages/mosaic/src/primitives/file-upload/file-upload-root.tsx b/packages/mosaic/src/primitives/file-upload/file-upload-root.tsx index 42964ee97ce..0de6b77a311 100644 --- a/packages/mosaic/src/primitives/file-upload/file-upload-root.tsx +++ b/packages/mosaic/src/primitives/file-upload/file-upload-root.tsx @@ -1,6 +1,6 @@ 'use client'; -import { type CSSProperties, type ReactNode, useCallback, useId, useMemo, useRef } from 'react'; +import React, { type CSSProperties, type ReactNode, useCallback, useId, useMemo, useRef } from 'react'; import { useControllableState } from '../hooks/use-controllable-state'; import { type ComponentProps, mergeProps, useRender } from '../utils'; @@ -57,7 +57,7 @@ const visuallyHiddenInputStyle: CSSProperties = { border: 0, }; -export function FileUploadRoot(props: FileUploadProps) { +export const FileUploadRoot = React.forwardRef(function FileUploadRoot(props, ref) { const { render, value: valueProp, @@ -170,6 +170,7 @@ export function FileUploadRoot(props: FileUploadProps) { {useRender({ defaultTagName: 'div', + ref, render, state, stateAttributesMapping: { @@ -180,4 +181,4 @@ export function FileUploadRoot(props: FileUploadProps) { })} ); -} +}); diff --git a/packages/mosaic/src/primitives/file-upload/file-upload-trigger.tsx b/packages/mosaic/src/primitives/file-upload/file-upload-trigger.tsx index 6ae78665f95..82d09f58686 100644 --- a/packages/mosaic/src/primitives/file-upload/file-upload-trigger.tsx +++ b/packages/mosaic/src/primitives/file-upload/file-upload-trigger.tsx @@ -1,29 +1,34 @@ 'use client'; +import React from 'react'; + import { type ComponentProps, mergeProps, useRender } from '../utils'; import { useFileUploadContext } from './file-upload-context'; export type FileUploadTriggerProps = ComponentProps<'button'>; -export function FileUploadTrigger(props: FileUploadTriggerProps) { - const { render, ...otherProps } = props; - const { disabled, openFilePicker } = useFileUploadContext(); +export const FileUploadTrigger = React.forwardRef( + function FileUploadTrigger(props, ref) { + const { render, ...otherProps } = props; + const { disabled, openFilePicker } = useFileUploadContext(); - const state = { disabled }; + const state = { disabled }; - const defaultProps: Record = { - type: 'button' as const, - disabled, - onClick: () => openFilePicker(), - }; + const defaultProps: Record = { + type: 'button' as const, + disabled, + onClick: () => openFilePicker(), + }; - return useRender({ - defaultTagName: 'button', - render, - state, - stateAttributesMapping: { - disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), - }, - props: mergeProps<'button'>(defaultProps, otherProps), - }); -} + return useRender({ + defaultTagName: 'button', + ref, + render, + state, + stateAttributesMapping: { + disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), + }, + props: mergeProps<'button'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/mosaic/src/primitives/file-upload/file-upload.test.tsx b/packages/mosaic/src/primitives/file-upload/file-upload.test.tsx index bd4d62d6376..d75e1d42877 100644 --- a/packages/mosaic/src/primitives/file-upload/file-upload.test.tsx +++ b/packages/mosaic/src/primitives/file-upload/file-upload.test.tsx @@ -1,5 +1,6 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { axe } from '../test-utils/axe'; @@ -372,4 +373,41 @@ describe('FileUpload', () => { expect(await axe(container)).toHaveNoViolations(); }); }); + + it('forwards refs to the root, dropzone, trigger, item and delete button', () => { + const file = makeFile('a.png', 'image/png'); + const refs = { + root: createRef(), + dropzone: createRef(), + trigger: createRef(), + item: createRef(), + remove: createRef(), + }; + render( + + + Choose files + + + Remove a.png + + , + ); + expect(refs.root.current).toBe(screen.getByTestId('file-upload-root')); + expect(refs.dropzone.current).toBe(screen.getByTestId('file-upload-dropzone')); + expect(refs.trigger.current).toBe(screen.getByRole('button', { name: 'Choose files' })); + expect(refs.item.current).toBe(screen.getByTestId('file-upload-item')); + expect(refs.remove.current).toBe(screen.getByRole('button', { name: 'Remove a.png' })); + }); }); diff --git a/packages/mosaic/src/primitives/menu/menu-separator.tsx b/packages/mosaic/src/primitives/menu/menu-separator.tsx index dd743fb872e..3db82298413 100644 --- a/packages/mosaic/src/primitives/menu/menu-separator.tsx +++ b/packages/mosaic/src/primitives/menu/menu-separator.tsx @@ -1,10 +1,12 @@ 'use client'; +import React from 'react'; + import { type ComponentProps, mergeProps, useRender } from '../utils'; export type MenuSeparatorProps = ComponentProps<'div'>; -export function MenuSeparator(props: MenuSeparatorProps) { +export const MenuSeparator = React.forwardRef(function MenuSeparator(props, ref) { const { render, ...otherProps } = props; const defaultProps = { @@ -13,7 +15,8 @@ export function MenuSeparator(props: MenuSeparatorProps) { return useRender({ defaultTagName: 'div', + ref, render, props: mergeProps<'div'>(defaultProps, otherProps), }); -} +}); diff --git a/packages/mosaic/src/primitives/menu/menu.test.tsx b/packages/mosaic/src/primitives/menu/menu.test.tsx index 01b5421e034..f745ddea1da 100644 --- a/packages/mosaic/src/primitives/menu/menu.test.tsx +++ b/packages/mosaic/src/primitives/menu/menu.test.tsx @@ -1178,4 +1178,21 @@ describe('Menu', () => { expect(await axe(document.body, { rules: { region: { enabled: false } } })).toHaveNoViolations(); }); }); + + it('forwards a ref to the separator', () => { + const ref = React.createRef(); + render( + + Actions + + + Cut + + Copy + + + , + ); + expect(ref.current).toBe(screen.getByRole('separator')); + }); }); diff --git a/packages/mosaic/src/primitives/otp/otp-root.tsx b/packages/mosaic/src/primitives/otp/otp-root.tsx index 5f0ee6f3810..7a7801983f5 100644 --- a/packages/mosaic/src/primitives/otp/otp-root.tsx +++ b/packages/mosaic/src/primitives/otp/otp-root.tsx @@ -1,6 +1,15 @@ 'use client'; -import { type CSSProperties, type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import React, { + type CSSProperties, + type ReactNode, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; import { useControllableState } from '../hooks/use-controllable-state'; import { type ComponentProps, mergeProps, useRender } from '../utils'; @@ -55,7 +64,7 @@ const visuallyHiddenInputStyle: CSSProperties = { border: 0, }; -export function OtpRoot(props: OtpProps) { +export const OtpRoot = React.forwardRef(function OtpRoot(props, ref) { const { render, length, @@ -240,6 +249,7 @@ export function OtpRoot(props: OtpProps) { {useRender({ defaultTagName: 'div', + ref, render, state, stateAttributesMapping: { @@ -251,4 +261,4 @@ export function OtpRoot(props: OtpProps) { })} ); -} +}); diff --git a/packages/mosaic/src/primitives/otp/otp.test.tsx b/packages/mosaic/src/primitives/otp/otp.test.tsx index db1c5de87e3..cedfbe0beed 100644 --- a/packages/mosaic/src/primitives/otp/otp.test.tsx +++ b/packages/mosaic/src/primitives/otp/otp.test.tsx @@ -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 { DirectionProvider } from '../direction-provider'; @@ -631,4 +631,17 @@ describe('Otp', () => { expect(await axe(container)).toHaveNoViolations(); }); }); + + it('forwards a ref to the root', () => { + const ref = createRef(); + render( + + + , + ); + expect(ref.current).toBe(screen.getByRole('group')); + }); }); diff --git a/packages/mosaic/src/primitives/popover/popover-close.tsx b/packages/mosaic/src/primitives/popover/popover-close.tsx index 2e9bf0ee116..b61bb6bc1fe 100644 --- a/packages/mosaic/src/primitives/popover/popover-close.tsx +++ b/packages/mosaic/src/primitives/popover/popover-close.tsx @@ -1,11 +1,13 @@ 'use client'; +import React from 'react'; + import { type ComponentProps, mergeProps, useRender } from '../utils'; import { usePopoverContext } from './popover-context'; export type PopoverCloseProps = ComponentProps<'button'>; -export function PopoverClose(props: PopoverCloseProps) { +export const PopoverClose = React.forwardRef(function PopoverClose(props, ref) { const { render, ...otherProps } = props; const { setOpen } = usePopoverContext(); @@ -18,7 +20,8 @@ export function PopoverClose(props: PopoverCloseProps) { return useRender({ defaultTagName: 'button', + ref, render, props: mergeProps<'button'>(defaultProps, otherProps), }); -} +}); diff --git a/packages/mosaic/src/primitives/popover/popover-description.tsx b/packages/mosaic/src/primitives/popover/popover-description.tsx index 0db573b4064..52b121b58d8 100644 --- a/packages/mosaic/src/primitives/popover/popover-description.tsx +++ b/packages/mosaic/src/primitives/popover/popover-description.tsx @@ -1,28 +1,31 @@ 'use client'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { type ComponentProps, mergeProps, useRender } from '../utils'; import { usePopoverContext } from './popover-context'; export type PopoverDescriptionProps = Omit, 'id'>; -export function PopoverDescription(props: PopoverDescriptionProps) { - const { render, ...otherProps } = props; - const { descriptionId, setHasDescription } = usePopoverContext(); +export const PopoverDescription = React.forwardRef( + function PopoverDescription(props, ref) { + const { render, ...otherProps } = props; + const { descriptionId, setHasDescription } = usePopoverContext(); - useEffect(() => { - setHasDescription(true); - return () => setHasDescription(false); - }, [setHasDescription]); + useEffect(() => { + setHasDescription(true); + return () => setHasDescription(false); + }, [setHasDescription]); - const defaultProps = { - id: descriptionId, - }; + const defaultProps = { + id: descriptionId, + }; - return useRender({ - defaultTagName: 'p', - render, - props: mergeProps<'p'>(defaultProps, otherProps), - }); -} + return useRender({ + defaultTagName: 'p', + ref, + render, + props: mergeProps<'p'>(defaultProps, otherProps), + }); + }, +); diff --git a/packages/mosaic/src/primitives/popover/popover-title.tsx b/packages/mosaic/src/primitives/popover/popover-title.tsx index 7b85893e987..8dfe6b31cff 100644 --- a/packages/mosaic/src/primitives/popover/popover-title.tsx +++ b/packages/mosaic/src/primitives/popover/popover-title.tsx @@ -1,13 +1,13 @@ 'use client'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { type ComponentProps, mergeProps, useRender } from '../utils'; import { usePopoverContext } from './popover-context'; export type PopoverTitleProps = Omit, 'id'>; -export function PopoverTitle(props: PopoverTitleProps) { +export const PopoverTitle = React.forwardRef(function PopoverTitle(props, ref) { const { render, ...otherProps } = props; const { labelId, setHasTitle } = usePopoverContext(); @@ -22,7 +22,8 @@ export function PopoverTitle(props: PopoverTitleProps) { return useRender({ defaultTagName: 'h2', + ref, render, props: mergeProps<'h2'>(defaultProps, otherProps), }); -} +}); diff --git a/packages/mosaic/src/primitives/popover/popover.test.tsx b/packages/mosaic/src/primitives/popover/popover.test.tsx index 338de3fabb4..a286080b6c4 100644 --- a/packages/mosaic/src/primitives/popover/popover.test.tsx +++ b/packages/mosaic/src/primitives/popover/popover.test.tsx @@ -373,4 +373,27 @@ describe('Popover', () => { expect(ref.current).toHaveAttribute('data-side'); }); }); + + it('forwards refs to the title, description and close button', () => { + const refs = { + title: createRef(), + description: createRef(), + close: createRef(), + }; + render( + + Open popover + + + Popover Title + Some description + Close + + + , + ); + expect(refs.title.current).toBe(screen.getByText('Popover Title')); + expect(refs.description.current).toBe(screen.getByText('Some description')); + expect(refs.close.current).toBe(screen.getByRole('button', { name: 'Close' })); + }); }); diff --git a/packages/mosaic/src/primitives/select/__tests__/select.test.tsx b/packages/mosaic/src/primitives/select/__tests__/select.test.tsx index 0ac9acdd38a..43169a4e839 100644 --- a/packages/mosaic/src/primitives/select/__tests__/select.test.tsx +++ b/packages/mosaic/src/primitives/select/__tests__/select.test.tsx @@ -1,5 +1,6 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { axe } from '../../test-utils/axe'; @@ -923,4 +924,37 @@ describe('Select', () => { await waitFor(() => expect(document.activeElement).toBe(options[0])); }); }); + + it('forwards refs to the value and the options', () => { + const value = createRef(); + const option = createRef(); + render( + + + + + + + + Apple + + + + , + ); + expect(value.current).toBe(screen.getByTestId('select-value')); + expect(option.current).toBe(screen.getByRole('option', { name: 'Apple' })); + }); }); diff --git a/packages/mosaic/src/primitives/select/select-option.tsx b/packages/mosaic/src/primitives/select/select-option.tsx index d259b80f7f6..7ebca9bbad3 100644 --- a/packages/mosaic/src/primitives/select/select-option.tsx +++ b/packages/mosaic/src/primitives/select/select-option.tsx @@ -1,7 +1,7 @@ 'use client'; import { useListItem } from '@floating-ui/react'; -import { useEffect } from 'react'; +import React, { useEffect } from 'react'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../utils'; import { useSelectContext } from './select-context'; @@ -12,7 +12,7 @@ export interface SelectOptionProps extends ComponentProps<'button'> { disabled?: boolean; } -export function SelectOption(props: SelectOptionProps) { +export const SelectOption = React.forwardRef(function SelectOption(props, ref) { const { render, value, label, disabled, ...otherProps } = props; const { activeIndex, @@ -79,7 +79,7 @@ export function SelectOption(props: SelectOptionProps) { active: (v: boolean) => (v ? { 'data-active': '' } : null), disabled: (v: boolean) => (v ? { 'data-disabled': '' } : null), }, - ref: [itemRef, isAnchor ? selectedItemRef : null], + ref: [itemRef, isAnchor ? selectedItemRef : null, ref], props: mergeProps<'button'>(defaultProps, otherProps), }); -} +}); diff --git a/packages/mosaic/src/primitives/select/select-value.tsx b/packages/mosaic/src/primitives/select/select-value.tsx index 7f0e88ad75e..62dbed1944a 100644 --- a/packages/mosaic/src/primitives/select/select-value.tsx +++ b/packages/mosaic/src/primitives/select/select-value.tsx @@ -1,6 +1,7 @@ 'use client'; import type { ReactNode } from 'react'; +import React from 'react'; import { type ComponentProps, mergeProps, useRender } from '../utils'; import type { SelectItem } from './select-context'; @@ -26,7 +27,7 @@ export interface SelectValueProps extends Omit, 'children children?: ReactNode | ((value: string[]) => ReactNode); } -export function SelectValue(props: SelectValueProps) { +export const SelectValue = React.forwardRef(function SelectValue(props, ref) { const { render, placeholder, children, ...otherProps } = props; const { selectedValues, cachedLabels, items, valueToLabelRef } = useSelectContext(); @@ -47,7 +48,8 @@ export function SelectValue(props: SelectValueProps) { return useRender({ defaultTagName: 'span', + ref, render, props: mergeProps<'span'>(defaultProps, otherProps), }); -} +}); diff --git a/packages/mosaic/src/primitives/tabs/tabs-list.tsx b/packages/mosaic/src/primitives/tabs/tabs-list.tsx index 5b78a170364..6abe1a2f349 100644 --- a/packages/mosaic/src/primitives/tabs/tabs-list.tsx +++ b/packages/mosaic/src/primitives/tabs/tabs-list.tsx @@ -9,7 +9,7 @@ import { useTabsContext } from './tabs-context'; export type TabsListProps = ComponentProps<'div'>; -export function TabsList(props: TabsListProps) { +export const TabsList = React.forwardRef(function TabsList(props, ref) { const { render, children, ...otherProps } = props; const { orientation } = useTabsContext(); const direction = useDirection(); @@ -42,7 +42,7 @@ export function TabsList(props: TabsListProps) { return useRender({ defaultTagName: 'div', render, - ref: isRef(compositeRef) ? compositeRef : undefined, + ref: [isRef(compositeRef) ? compositeRef : null, ref], props: mergedProps, }); }} @@ -50,4 +50,4 @@ export function TabsList(props: TabsListProps) { {children} ); -} +}); diff --git a/packages/mosaic/src/primitives/tabs/tabs.test.tsx b/packages/mosaic/src/primitives/tabs/tabs.test.tsx index 33769ed538b..a33db5a9a5b 100644 --- a/packages/mosaic/src/primitives/tabs/tabs.test.tsx +++ b/packages/mosaic/src/primitives/tabs/tabs.test.tsx @@ -799,4 +799,16 @@ describe('Tabs', () => { expect(panel.style.getPropertyValue('--cl-tab-transition-direction')).toBe('1'); }); }); + + it('forwards a ref to the list', () => { + const ref = createRef(); + render( + + + Account + + , + ); + expect(ref.current).toBe(screen.getByRole('tablist')); + }); }); From 4a794d982a49abe4e33be5458f5931d3b6b674f2 Mon Sep 17 00:00:00 2001 From: austincalvelage Date: Wed, 28 Oct 2026 17:14:12 -0600 Subject: [PATCH 2/2] fix(mosaic): require forward-ref-parts to see the forwarded ref in useRender's ref --- packages/mosaic/eslint/import-guards.test.mjs | 6 +++ packages/mosaic/eslint/rules.mjs | 39 +++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/mosaic/eslint/import-guards.test.mjs b/packages/mosaic/eslint/import-guards.test.mjs index 31664f2947a..d063d0d2b97 100644 --- a/packages/mosaic/eslint/import-guards.test.mjs +++ b/packages/mosaic/eslint/import-guards.test.mjs @@ -364,6 +364,9 @@ describe('Mosaic parts', { timeout: 30_000 }, () => { "export const Part = (props: { render?: never }) => useRender({ defaultTagName: 'div', props });", "export function Part(props: { render?: never }) { return useRender({ defaultTagName: 'div', props })} />; }", "export const Part = React.forwardRef(function Part(props) { return useRender({ defaultTagName: 'div', props }); });", + "export const Part = React.forwardRef(function Part(props, ref) { const internalRef = React.useRef(null); return useRender({ defaultTagName: 'div', ref: internalRef, props }); });", + "export const Part = React.forwardRef(function Part(props, ref) { const internalRef = React.useRef(null); return useRender({ defaultTagName: 'div', ref: [internalRef], props }); });", + "export const Part = React.forwardRef(function Part(props, ref) { return useRender({ defaultTagName: 'div', ref: null, props }); });", ])('rejects a part that drops the ref: %s', async source => { expect(await forwardRefMessages(partFixture, source)).toEqual([ 'Parts that render through useRender must use React.forwardRef and pass the ref to useRender (see primitives/select/select-trigger.tsx).', @@ -372,6 +375,9 @@ describe('Mosaic parts', { timeout: 30_000 }, () => { test.each([ "export const Part = React.forwardRef(function Part(props, ref) { return useRender({ defaultTagName: 'button', ref: [null, ref], props }); });", + "export const Part = React.forwardRef(function Part(props, ref) { return useRender({ defaultTagName: 'div', ref, props }); });", + "export const Part = React.forwardRef(function Part(props, ref) { return useRender({ defaultTagName: 'div', ref: [null, ref ?? null], props }); });", + "export const Part = React.forwardRef(function Part(props: { open?: boolean }, ref) { const finalRef = props.open ? ref : [null, ref]; return useRender({ defaultTagName: 'div', ref: finalRef, props }); });", 'export function Provider(props: { children: React.ReactNode }) { return props.children; }', ])('allows parts that forward the ref or render nothing: %s', async source => { expect(await forwardRefMessages(partFixture, source)).toEqual([]); diff --git a/packages/mosaic/eslint/rules.mjs b/packages/mosaic/eslint/rules.mjs index 5b3ed7e4784..88b0b363139 100644 --- a/packages/mosaic/eslint/rules.mjs +++ b/packages/mosaic/eslint/rules.mjs @@ -128,15 +128,45 @@ function useRenderCalls(fn, visitorKeys) { return calls; } -function passesRef(call) { +function includesRef(value, refName, scope) { + const node = unwrapExpression(value); + if (node?.type === 'Identifier') { + if (node.name === refName) { + return true; + } + const variable = findVariable(scope, node.name); + const definition = variable?.defs.length === 1 ? variable.defs[0] : undefined; + return definition?.node.type === 'VariableDeclarator' && includesRef(definition.node.init, refName, scope); + } + if (node?.type === 'LogicalExpression') { + return includesRef(node.left, refName, scope); + } + if (node?.type === 'ConditionalExpression') { + return includesRef(node.consequent, refName, scope) && includesRef(node.alternate, refName, scope); + } + return node?.type === 'ArrayExpression' && node.elements.some(element => includesRef(element, refName, scope)); +} + +function findVariable(scope, name) { + for (let current = scope; current; current = current.upper) { + const variable = current.set.get(name); + if (variable) { + return variable; + } + } + return undefined; +} + +function passesRef(call, refName, scope) { const [options] = call.arguments; if (options?.type !== 'ObjectExpression') { return true; } return options.properties.some( property => - property.type === 'SpreadElement' || - (property.type === 'Property' && propertyName(property.key, property.computed) === 'ref'), + property.type === 'Property' && + propertyName(property.key, property.computed) === 'ref' && + includesRef(property.value, refName, scope), ); } @@ -163,7 +193,8 @@ export const forwardRefParts = { if (calls.length === 0) { return; } - if (!wrapped || calls.some(call => !passesRef(call))) { + const refName = fn.params[1]?.type === 'Identifier' ? fn.params[1].name : undefined; + if (!wrapped || calls.some(call => !passesRef(call, refName, context.sourceCode.getScope(call)))) { context.report({ node: reportNode, messageId: 'forwardRef' }); } };