diff --git a/change/@fluentui-react-tag-picker-73122b43-f401-4b38-ba87-5f92358110f5.json b/change/@fluentui-react-tag-picker-73122b43-f401-4b38-ba87-5f92358110f5.json new file mode 100644 index 00000000000000..a66fc07c09bb61 --- /dev/null +++ b/change/@fluentui-react-tag-picker-73122b43-f401-4b38-ba87-5f92358110f5.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: omit the default TagPicker expand icon from headless state", + "packageName": "@fluentui/react-tag-picker", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-teaching-popover-c2dda2cd-f30a-4bea-a04b-400a23471e90.json b/change/@fluentui-react-teaching-popover-c2dda2cd-f30a-4bea-a04b-400a23471e90.json new file mode 100644 index 00000000000000..17f3227c36460f --- /dev/null +++ b/change/@fluentui-react-teaching-popover-c2dda2cd-f30a-4bea-a04b-400a23471e90.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: separate headless teaching popover title state from bundled icon defaults", + "packageName": "@fluentui/react-teaching-popover", + "email": "vgenaev@gmail.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/TagPicker.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/TagPicker.fixture.js new file mode 100644 index 00000000000000..afa18f9cb4f6f1 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/TagPicker.fixture.js @@ -0,0 +1,7 @@ +import * as TagPicker from '@fluentui/react-headless-components-preview/tag-picker'; + +console.log(TagPicker); + +export default { + name: '@fluentui/react-headless-components-preview/tag-picker', +}; diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/TeachingPopover.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/TeachingPopover.fixture.js new file mode 100644 index 00000000000000..8e23718683a360 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/TeachingPopover.fixture.js @@ -0,0 +1,7 @@ +import * as TeachingPopover from '@fluentui/react-headless-components-preview/teaching-popover'; + +console.log(TeachingPopover); + +export default { + name: '@fluentui/react-headless-components-preview/teaching-popover', +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.test.tsx index 8abad2ea4033be..37063801bec7c2 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.test.tsx @@ -3,8 +3,10 @@ import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { isConformant } from '../../testing/isConformant'; import { TeachingPopover } from './TeachingPopover'; +import { TeachingPopoverHeader } from './TeachingPopoverHeader'; import { TeachingPopoverTrigger } from './TeachingPopoverTrigger'; import { TeachingPopoverSurface } from './TeachingPopoverSurface'; +import { TeachingPopoverTitle } from './TeachingPopoverTitle'; describe('TeachingPopover', () => { isConformant({ @@ -43,6 +45,52 @@ describe('TeachingPopover', () => { expect(getByText('Surface content')).toBeInTheDocument(); }); + it('does not render default header or title icons', () => { + const { container, getByTestId } = render( + + + + + + Tips + Title + + , + ); + const header = getByTestId('header'); + const title = getByTestId('title'); + + expect(container.querySelector('svg')).toBeNull(); + expect(header.querySelector('[aria-hidden="true"]')).toBeEmptyDOMElement(); + expect(header.querySelector('button[aria-label="dismiss"]')).toBeEmptyDOMElement(); + expect(title.querySelector('button')).toBeNull(); + }); + + it('renders explicitly provided header and title slots', () => { + const { getByTestId } = render( + + + + + + Tip }} + dismissButton={{ children: Close header }} + > + Tips + + Close title }}> + Title + + + , + ); + + expect(getByTestId('header-icon')).toHaveTextContent('Tip'); + expect(getByTestId('header-dismiss')).toHaveTextContent('Close header'); + expect(getByTestId('title-dismiss')).toHaveTextContent('Close title'); + }); + it('renders an arrow by default (withArrow=true)', () => { const { getByRole } = render( diff --git a/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.test.tsx b/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.test.tsx index e60d00f8869029..70f683794796cd 100644 --- a/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.test.tsx +++ b/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.test.tsx @@ -1,9 +1,10 @@ import { renderHook } from '@testing-library/react-hooks'; import * as React from 'react'; +import { ChevronDownRegular } from '@fluentui/react-icons'; import { TagPickerContextProvider } from '../../contexts/TagPickerContext'; import type { TagPickerContextValue } from '../../contexts/TagPickerContext'; -import { useTagPickerControl_unstable } from './useTagPickerControl'; +import { useTagPickerControlBase_unstable, useTagPickerControl_unstable } from './useTagPickerControl'; const makeContext = (overrides: Partial = {}): TagPickerContextValue => ({ triggerRef: React.createRef(), @@ -35,23 +36,57 @@ const wrap = (overrides: Partial = {}): React.FC<{ childr return Wrapper; }; +describe('useTagPickerControlBase_unstable', () => { + it('does not create an expandIcon or aside by default', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagPickerControlBase_unstable({}, ref), { wrapper: wrap() }); + + expect(result.current.expandIcon).toBeUndefined(); + expect(result.current.aside).toBeUndefined(); + }); + + it('creates an expandIcon and aside when explicitly provided', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTagPickerControlBase_unstable({ expandIcon: {} }, ref), { + wrapper: wrap({ open: true }), + }); + + expect(result.current.expandIcon).toBeDefined(); + expect(result.current.expandIcon?.children).toBeUndefined(); + expect(result.current.expandIcon?.['aria-expanded']).toBe(true); + expect(result.current.expandIcon?.role).toBe('button'); + expect(result.current.aside).toBeDefined(); + }); +}); + describe('useTagPickerControl_unstable', () => { - it('always renders the internal aside slot', () => { + it('renders the default expandIcon and aside when a popover exists', () => { const ref = React.createRef(); const { result } = renderHook(() => useTagPickerControl_unstable({}, ref), { wrapper: wrap() }); + const defaultIcon = result.current.expandIcon?.children; + expect(React.isValidElement(defaultIcon) && defaultIcon.type).toBe(ChevronDownRegular); expect(result.current.aside).toBeDefined(); }); - it('renders an expandIcon by default and hides it when noPopover is set', () => { + it('does not render a default expandIcon or aside when noPopover is set', () => { const ref = React.createRef(); - const withPopover = renderHook(() => useTagPickerControl_unstable({}, ref), { wrapper: wrap() }); const noPopover = renderHook(() => useTagPickerControl_unstable({}, ref), { wrapper: wrap({ noPopover: true }), }); - expect(withPopover.result.current.expandIcon).toBeDefined(); expect(noPopover.result.current.expandIcon).toBeUndefined(); + expect(noPopover.result.current.aside).toBeUndefined(); + }); + + it('preserves a consumer-provided expandIcon', () => { + const ref = React.createRef(); + const customIcon = ; + const { result } = renderHook(() => useTagPickerControl_unstable({ expandIcon: customIcon }, ref), { + wrapper: wrap(), + }); + + expect(result.current.expandIcon?.children).toBe(customIcon); }); it('binds aria-expanded on expandIcon to picker open state', () => { diff --git a/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.tsx b/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.tsx index e5c3164185cbec..967d5061d4d812 100644 --- a/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.tsx +++ b/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.tsx @@ -11,13 +11,14 @@ import { useMergedRefs, } from '@fluentui/react-utilities'; import { useFluent_unstable } from '@fluentui/react-shared-contexts'; -import type { TagPickerControlBaseState, TagPickerControlProps, TagPickerControlState } from './TagPickerControl.types'; -import { useTagPickerContext_unstable } from '../../contexts/TagPickerContext'; +import { useFieldContext_unstable } from '@fluentui/react-field'; import { ChevronDownRegular } from '@fluentui/react-icons'; -import { useResizeObserverRef } from '../../utils/useResizeObserverRef'; + +import type { TagPickerControlBaseState, TagPickerControlProps, TagPickerControlState } from './TagPickerControl.types'; import { tagPickerControlAsideWidthToken } from './useTagPickerControlStyles.styles'; -import { useFieldContext_unstable } from '@fluentui/react-field'; +import { useTagPickerContext_unstable } from '../../contexts/TagPickerContext'; import { useExpandLabel } from '../../utils/useExpandLabel'; +import { useResizeObserverRef } from '../../utils/useResizeObserverRef'; /** * Create the base state required to render TagPickerControl, without design-only state. @@ -57,11 +58,9 @@ export const useTagPickerControlBase_unstable = ( } const expandIcon = slot.optional(props.expandIcon, { - renderByDefault: !noPopover, defaultProps: { 'aria-expanded': open, 'aria-disabled': disabled ? 'true' : undefined, - children: , role: 'button', }, elementType: 'span', @@ -164,7 +163,15 @@ export const useTagPickerControl_unstable = ( props: TagPickerControlProps, ref: React.Ref, ): TagPickerControlState => { - const baseState = useTagPickerControlBase_unstable(props, ref); + const noPopover = useTagPickerContext_unstable(ctx => ctx.noPopover ?? false); + const baseProps = + !noPopover && props.expandIcon === undefined + ? { + ...props, + expandIcon: , + } + : props; + const baseState = useTagPickerControlBase_unstable(baseProps, ref); const size = useTagPickerContext_unstable(ctx => ctx.size); const appearance = useTagPickerContext_unstable(ctx => ctx.appearance); diff --git a/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/index.ts b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/index.ts index 935babeb0b4975..7db7b13bb528fe 100644 --- a/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/index.ts +++ b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/index.ts @@ -7,7 +7,8 @@ export type { TeachingPopoverTitleState, } from './TeachingPopoverTitle.types'; export { renderTeachingPopoverTitle_unstable } from './renderTeachingPopoverTitle'; -export { useTeachingPopoverTitle_unstable, useTeachingPopoverTitleBase_unstable } from './useTeachingPopoverTitle'; +export { useTeachingPopoverTitle_unstable } from './useTeachingPopoverTitle'; +export { useTeachingPopoverTitleBase_unstable } from './useTeachingPopoverTitleBase'; export { teachingPopoverTitleClassNames, useTeachingPopoverTitleStyles_unstable, diff --git a/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitle.tsx b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitle.tsx index 5c19840a8dfa8c..5a747b70691225 100644 --- a/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitle.tsx +++ b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitle.tsx @@ -1,67 +1,13 @@ 'use client'; import * as React from 'react'; -import { getIntrinsicElementProps, useEventCallback, slot } from '@fluentui/react-utilities'; -import type { - TeachingPopoverTitleBaseProps, - TeachingPopoverTitleBaseState, - TeachingPopoverTitleProps, - TeachingPopoverTitleState, -} from './TeachingPopoverTitle.types'; import { DismissFilled, DismissRegular, bundleIcon } from '@fluentui/react-icons'; import { usePopoverContext_unstable } from '@fluentui/react-popover'; +import type { TeachingPopoverTitleProps, TeachingPopoverTitleState } from './TeachingPopoverTitle.types'; +import { useTeachingPopoverTitleBase_unstable } from './useTeachingPopoverTitleBase'; const DismissIcon = bundleIcon(DismissFilled, DismissRegular); -/** - * Base hook that builds TeachingPopoverTitle state for behavior and structure only. - * Does not bundle/render the dismiss icon and does not read `appearance` from popover context. - * @param props - TeachingPopoverTitle properties - * @param ref - reference to root HTMLElement of TeachingPopoverTitle - */ -export const useTeachingPopoverTitleBase_unstable = ( - props: TeachingPopoverTitleBaseProps, - ref: React.Ref, -): TeachingPopoverTitleBaseState => { - const { dismissButton } = props; - - const setOpen = usePopoverContext_unstable(context => context.setOpen); - const triggerRef = usePopoverContext_unstable(context => context.triggerRef); - - const onDismissButtonClick = useEventCallback((ev: React.MouseEvent) => { - if (!ev.defaultPrevented) { - setOpen(ev, false); - } - - if (triggerRef.current) { - triggerRef.current.focus(); - } - }); - - return { - components: { - root: 'h2', - dismissButton: 'button', - }, - root: slot.always( - getIntrinsicElementProps('h2', { - ref, - ...props, - }), - { elementType: 'h2' }, - ), - dismissButton: slot.optional(dismissButton, { - renderByDefault: false, - defaultProps: { - onClick: onDismissButtonClick, - 'aria-label': 'dismiss', - 'aria-hidden': true, - }, - elementType: 'button', - }), - }; -}; - /** * Returns the props and state required to render the component * @param props - TeachingPopoverTitle properties diff --git a/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitleBase.test.tsx b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitleBase.test.tsx new file mode 100644 index 00000000000000..2a08ab1d10c1e2 --- /dev/null +++ b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitleBase.test.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { PopoverProvider } from '@fluentui/react-popover'; +import type { PopoverContextValue } from '@fluentui/react-popover'; +import { useTeachingPopoverTitleBase_unstable } from './useTeachingPopoverTitleBase'; + +const defaultPopoverContext: PopoverContextValue = { + open: true, + setOpen: () => null, + toggleOpen: () => null, + triggerRef: { current: null }, + contentRef: { current: null }, + arrowRef: { current: null }, + openOnContext: false, + openOnHover: false, + size: 'medium', + inline: false, +}; + +function makeWrapper(contextValue: Partial = {}) { + const value = { ...defaultPopoverContext, ...contextValue }; + return ({ children }: { children: React.ReactNode }) => React.createElement(PopoverProvider, { value }, children); +} + +describe('useTeachingPopoverTitleBase_unstable', () => { + it('returns structural state without styled defaults', () => { + const ref = React.createRef(); + const { result } = renderHook(() => useTeachingPopoverTitleBase_unstable({ dismissButton: {} }, ref), { + wrapper: makeWrapper(), + }); + + // eslint-disable-next-line @typescript-eslint/no-deprecated + expect(result.current.components).toEqual({ root: 'h2', dismissButton: 'button' }); + expect(result.current).not.toHaveProperty('appearance'); + expect(result.current.dismissButton?.children).toBeUndefined(); + }); + + it('dismisses the popover and restores focus', () => { + const ref = React.createRef(); + const setOpen = jest.fn(); + const focus = jest.fn(); + const triggerRef = { current: { focus } as unknown as HTMLElement }; + const { result } = renderHook(() => useTeachingPopoverTitleBase_unstable({ dismissButton: {} }, ref), { + wrapper: makeWrapper({ setOpen, triggerRef }), + }); + const event = { defaultPrevented: false } as React.MouseEvent; + + result.current.dismissButton?.onClick?.(event); + + expect(setOpen).toHaveBeenCalledWith(event, false); + expect(focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitleBase.tsx b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitleBase.tsx new file mode 100644 index 00000000000000..d949ab831ee877 --- /dev/null +++ b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitleBase.tsx @@ -0,0 +1,55 @@ +'use client'; + +import type * as React from 'react'; +import { usePopoverContext_unstable } from '@fluentui/react-popover'; +import { getIntrinsicElementProps, slot, useEventCallback } from '@fluentui/react-utilities'; +import type { TeachingPopoverTitleBaseProps, TeachingPopoverTitleBaseState } from './TeachingPopoverTitle.types'; + +/** + * Base hook that builds TeachingPopoverTitle state for behavior and structure only. + * Does not bundle/render the dismiss icon and does not read `appearance` from popover context. + * @param props - TeachingPopoverTitle properties + * @param ref - reference to root HTMLElement of TeachingPopoverTitle + */ +export const useTeachingPopoverTitleBase_unstable = ( + props: TeachingPopoverTitleBaseProps, + ref: React.Ref, +): TeachingPopoverTitleBaseState => { + const { dismissButton } = props; + + const setOpen = usePopoverContext_unstable(context => context.setOpen); + const triggerRef = usePopoverContext_unstable(context => context.triggerRef); + + const onDismissButtonClick = useEventCallback((ev: React.MouseEvent) => { + if (!ev.defaultPrevented) { + setOpen(ev, false); + } + + if (triggerRef.current) { + triggerRef.current.focus(); + } + }); + + return { + components: { + root: 'h2', + dismissButton: 'button', + }, + root: slot.always( + getIntrinsicElementProps('h2', { + ref, + ...props, + }), + { elementType: 'h2' }, + ), + dismissButton: slot.optional(dismissButton, { + renderByDefault: false, + defaultProps: { + onClick: onDismissButtonClick, + 'aria-label': 'dismiss', + 'aria-hidden': true, + }, + elementType: 'button', + }), + }; +}; diff --git a/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.spec.ts b/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.spec.ts index 9ddbc96b6aa064..539ca0176e294b 100644 --- a/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.spec.ts +++ b/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.spec.ts @@ -495,6 +495,25 @@ describe('migrate-converged-pkg generator', () => { " `); }); + + it(`emits the CommonJS jest config + setup as .cjs when the package opts into "type": "module"`, async () => { + const projectConfig = readProjectConfiguration(tree, options.name); + updateJson(tree, `${projectConfig.root}/package.json`, (json: PackageJson) => { + json.type = 'module'; + return json; + }); + + await generator(tree, options); + + // legacy `.js` variants are replaced by `.cjs` so Node parses them as CommonJS under type:module + expect(tree.exists(`${projectConfig.root}/jest.config.js`)).toBeFalsy(); + expect(tree.exists(`${projectConfig.root}/jest.config.cjs`)).toBeTruthy(); + expect(tree.exists(`${projectConfig.root}/config/tests.js`)).toBeFalsy(); + expect(tree.exists(`${projectConfig.root}/config/tests.cjs`)).toBeTruthy(); + expect(tree.read(`${projectConfig.root}/jest.config.cjs`, 'utf-8')).toContain( + `setupFilesAfterEnv: ['./config/tests.cjs']`, + ); + }); }); describe(`storybook updates`, () => { @@ -951,6 +970,58 @@ describe('migrate-converged-pkg generator', () => { const pkgJson = getPackageJson(); expect((pkgJson.exports?.['.'] as { import?: string }).import).toBe(undefined); }); + + it(`opts a package into the ESM-first shape when it declares "type": "module"`, async () => { + const { getPackageJson } = updatePackageJson(tree, { + projectName: options.name, + jsonUpdates: { + type: 'module', + main: './lib-commonjs/index.js', + module: './lib/index.js', + style: './css/index.css', + }, + }); + + await generator(tree, options); + + const pkgJson = getPackageJson(); + expect(pkgJson.type).toBe('module'); + // CommonJS entry switches to `.cjs` so Node parses it correctly under `type: module` + expect(pkgJson.main).toBe('./lib-commonjs/index.cjs'); + // no `node` condition; `import`/`require` carry their own per-condition `types` + expect((pkgJson.exports?.['.'] as { node?: unknown }).node).toBeUndefined(); + expect(pkgJson.exports).toMatchInlineSnapshot(` + Object { + ".": Object { + "import": Object { + "default": "./lib/index.js", + "types": "./dist/index.d.ts", + }, + "require": Object { + "default": "./lib-commonjs/index.cjs", + "types": "./dist/index.d.cts", + }, + "style": "./css/index.css", + }, + "./package.json": "./package.json", + } + `); + }); + + it(`keeps CommonJS packages on the legacy shape (no opt-in)`, async () => { + const { getPackageJson } = updatePackageJson(tree, { + projectName: options.name, + jsonUpdates: { module: './lib/index.js' }, + }); + + await generator(tree, options); + + const pkgJson = getPackageJson(); + expect(pkgJson.type).toBeUndefined(); + // CommonJS entry is not rewritten to `.cjs` + expect(pkgJson.main).not.toMatch(/\.cjs$/); + expect((pkgJson.exports?.['.'] as { node?: unknown }).node).toBe('./lib-commonjs/index.js'); + }); }); it(`should not add start scripts to node packages`, async () => { diff --git a/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.ts b/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.ts index bc798ab216deb1..db008382632cac 100644 --- a/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.ts +++ b/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.ts @@ -685,6 +685,33 @@ function updatePackageJson(tree: Tree, options: NormalizedSchemaWithTsConfigs) { } function setupExportMaps(json: PackageJson) { + // Opt-in: a package becomes ESM-first by declaring `"type": "module"` in its package.json. Such + // packages get the ESM/CJS conditional export shape (no `node` condition); every other package + // keeps the existing CommonJS-first shape below unchanged (this stays a no-op for them). + if (json.type === 'module') { + const esm = json.module ? normalizePackageEntryPointPaths(json.module) : null; + const commonjs = json.main ? normalizePackageEntryPointPaths(json.main) : null; + // bare Node `import` resolves to valid ESM (`lib/`), `require` resolves to CommonJS + // (`lib-commonjs/*.cjs`). Per-condition `types` point `require` at a `.d.cts` so `node16`/ + // `nodenext` CJS consumers get a CommonJS-flavoured declaration (keeps `@arethetypeswrong/cli` green). + const commonjsCjs = commonjs ? commonjs.replace(/\.js$/, '.cjs') : null; + if (commonjsCjs) { + json.main = commonjsCjs; + } + const esmTypes = json.typings; + const cjsTypes = json.typings ? json.typings.replace(/\.d\.ts$/, '.d.cts') : undefined; + json.exports = { + '.': { + ...(json.style ? { style: normalizePackageEntryPointPaths(json.style) } : null), + ...(esm && esmTypes ? { import: { types: esmTypes, default: esm } } : null), + ...(commonjsCjs && cjsTypes ? { require: { types: cjsTypes, default: commonjsCjs } } : null), + }, + './package.json': './package.json', + }; + + return json; + } + json.exports = { '.': { types: json.typings, @@ -719,6 +746,8 @@ function updatePackageJson(tree: Tree, options: NormalizedSchemaWithTsConfigs) { const typesPath = getTypes(json); const storybookPath = getStorybookPath(json); const amdPath = options.projectConfig.tags?.includes('ships-amd') ? 'lib-amd' : null; + // `type: module` packages also ship `.d.cts` declarations for the `require` types condition + const cjsTypesPath = json.type === 'module' && typesPath ? typesPath.replace(/\.d\.ts$/, '.d.cts') : null; const filesDefinition = [ rootMarkdownPath, mainPath, @@ -726,6 +755,7 @@ function updatePackageJson(tree: Tree, options: NormalizedSchemaWithTsConfigs) { ...(binPath ?? []), stylesPath, typesPath, + cjsTypesPath, storybookPath, amdPath, ] @@ -970,20 +1000,39 @@ function updateLocalJestConfig(tree: Tree, options: NormalizedSchema) { const packageJson = readJson(tree, options.paths.packageJson); packageJson.dependencies = packageJson.dependencies ?? {}; + // ESM-first packages opt in via `"type": "module"`, so their CommonJS jest config + setup must use + // the `.cjs` extension. CommonJS (default) packages keep `.js` unchanged. + const isEsmFirst = packageJson.type === 'module'; + const testSetupExtension = isEsmFirst ? 'cjs' : 'js'; + const jestConfigPath = isEsmFirst ? options.paths.jestConfig.replace(/\.js$/, '.cjs') : options.paths.jestConfig; + const resolvedJestSetupFilePath = isEsmFirst ? jestSetupFilePath.replace(/\.js$/, '.cjs') : jestSetupFilePath; + const config = { pkgName: options.normalizedPkgName, addSnapshotSerializers: packageType === 'web' && Object.keys(packageJson.dependencies).some(pkgDepName => packagesThatTriggerAddingSnapshots.includes(pkgDepName)), - testSetupFilePath: `./${path.basename(options.paths.configRoot)}/tests.js`, + testSetupFilePath: `./${path.basename(options.paths.configRoot)}/tests.${testSetupExtension}`, platform: packageType, projectConfig: options.projectConfig, } as const; - tree.write(options.paths.jestConfig, templates.jest(config)); + // drop the legacy `.js` variants when emitting `.cjs` (type:module packages) + if (isEsmFirst) { + if (tree.exists(options.paths.jestConfig)) { + tree.delete(options.paths.jestConfig); + } + if (tree.exists(jestSetupFilePath) && !tree.exists(resolvedJestSetupFilePath)) { + const existingSetup = tree.read(jestSetupFilePath, 'utf-8') as string; + tree.write(resolvedJestSetupFilePath, existingSetup); + tree.delete(jestSetupFilePath); + } + } + + tree.write(jestConfigPath, templates.jest(config)); - if (!tree.exists(jestSetupFilePath)) { - tree.write(jestSetupFilePath, templates.jestSetup); + if (!tree.exists(resolvedJestSetupFilePath)) { + tree.write(resolvedJestSetupFilePath, templates.jestSetup); } return tree; @@ -1075,8 +1124,12 @@ function createTsSolutionConfig(tree: Tree, options: NormalizedSchema) { function updateTsGlobalTypes(tree: Tree, options: NormalizedSchema) { // update test TS config updateJson(tree, options.paths.tsconfig.test, (json: TsConfig) => { - if (tree.exists(options.paths.jestSetupFile)) { - const jestSetupFile = tree.read(options.paths.jestSetupFile, 'utf8')!; + // ESM-first (type:module) packages emit the jest setup as `.cjs`; fall back to the legacy `.js` path + const jestSetupFilePath = [options.paths.jestSetupFile.replace(/\.js$/, '.cjs'), options.paths.jestSetupFile].find( + candidate => tree.exists(candidate), + ); + if (jestSetupFilePath) { + const jestSetupFile = tree.read(jestSetupFilePath, 'utf8')!; if (jestSetupFile.includes(`require('@testing-library/jest-dom')`)) { json.compilerOptions.types = json.compilerOptions.types ?? [];