diff --git a/apps/popover-tests-headless/.npmignore b/apps/popover-tests-headless/.npmignore new file mode 100644 index 00000000000000..52d2a7273a151d --- /dev/null +++ b/apps/popover-tests-headless/.npmignore @@ -0,0 +1,29 @@ +.storybook/ +.vscode/ +bundle-size/ +config/ +coverage/ +e2e/ +etc/ +node_modules/ +src/ +dist/types/ +temp/ +__fixtures__ +__mocks__ +__tests__ + +*.api.json +*.log +*.spec.* +*.stories.* +*.test.* +*.yml + +# config files +*config.* +*rc.* +.editorconfig +.eslint* +.git* +.prettierignore diff --git a/apps/popover-tests-headless/.storybook/main.js b/apps/popover-tests-headless/.storybook/main.js new file mode 100644 index 00000000000000..05203b9a42a45f --- /dev/null +++ b/apps/popover-tests-headless/.storybook/main.js @@ -0,0 +1,10 @@ +const rootMain = require('../../../.storybook/main'); + +module.exports = /** @type {Omit} */ ({ + ...rootMain, + stories: [...rootMain.stories, '../src/**/*.mdx', '../src/**/*.stories.@(ts|tsx)'], + addons: [...rootMain.addons], + webpackFinal: (config, options) => { + return { ...rootMain.webpackFinal(config, options) }; + }, +}); diff --git a/apps/popover-tests-headless/.storybook/preview.js b/apps/popover-tests-headless/.storybook/preview.js new file mode 100644 index 00000000000000..35fc35c83b7b40 --- /dev/null +++ b/apps/popover-tests-headless/.storybook/preview.js @@ -0,0 +1,9 @@ +import * as rootPreview from '../../../.storybook/preview'; + +/** @type {typeof rootPreview.decorators} */ +export const decorators = [...rootPreview.decorators]; + +/** @type {typeof rootPreview.parameters} */ +export const parameters = { ...rootPreview.parameters }; + +export const tags = ['autodocs']; diff --git a/apps/popover-tests-headless/.storybook/tsconfig.json b/apps/popover-tests-headless/.storybook/tsconfig.json new file mode 100644 index 00000000000000..fb6d443db30d0d --- /dev/null +++ b/apps/popover-tests-headless/.storybook/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "", + "allowJs": true, + "checkJs": true, + "types": ["static-assets", "environment"] + }, + "include": ["../src/**/*.stories.ts", "../src/**/*.stories.tsx", "*.js"] +} diff --git a/apps/popover-tests-headless/README.md b/apps/popover-tests-headless/README.md new file mode 100644 index 00000000000000..d11aac9f18244d --- /dev/null +++ b/apps/popover-tests-headless/README.md @@ -0,0 +1,77 @@ +# @fluentui/popover-tests-headless + +Private test application for the Headless adaptive positioning runtime. + +The app exercises both positioning implementations behind the same component +APIs: + +- native HTML Popover API with CSS Anchor Positioning; +- native HTML Popover API with lazy `react-positioning` fallback. + +No Playwright tests are included yet. The initial workflow is manual +verification, followed by Playwright coverage after the scenarios are approved. + +## CSR manual verification + +Start the private Storybook from the repository root: + +```bash +yarn nx storybook popover-tests-headless +``` + +Each anchored control has its own page under **Positioning Runtime / CSR**: + +| Control | Story URL | +| --------------- | -------------------------------------------------------- | +| Popover | `?path=/story/positioning-runtime-csr--popover` | +| TeachingPopover | `?path=/story/positioning-runtime-csr--teaching-popover` | +| Menu | `?path=/story/positioning-runtime-csr--menu` | +| Tooltip | `?path=/story/positioning-runtime-csr--tooltip` | +| Dropdown | `?path=/story/positioning-runtime-csr--dropdown` | +| Combobox | `?path=/story/positioning-runtime-csr--combobox` | +| TagPicker | `?path=/story/positioning-runtime-csr--tag-picker` | + +Every CSR page provides reload controls for: + +- auto-detected mode; +- forced native mode; +- forced fallback mode. + +The Popover page includes basic, nested, and collision-placement scenarios. +Stable `data-testid` hooks are included for future Playwright tests. + +## SSR verification + +Run the SSR harness: + +```bash +yarn nx run popover-tests-headless:test-ssr +``` + +Each control also has a separate **Positioning Runtime / SSR** page: + +| Control | Story URL | +| --------------- | -------------------------------------------------------- | +| Popover | `?path=/story/positioning-runtime-ssr--popover` | +| TeachingPopover | `?path=/story/positioning-runtime-ssr--teaching-popover` | +| Menu | `?path=/story/positioning-runtime-ssr--menu` | +| Tooltip | `?path=/story/positioning-runtime-ssr--tooltip` | +| Dropdown | `?path=/story/positioning-runtime-ssr--dropdown` | +| Combobox | `?path=/story/positioning-runtime-ssr--combobox` | +| TagPicker | `?path=/story/positioning-runtime-ssr--tag-picker` | + +The SSR harness server-renders every story, then loads the generated output in +Chrome to catch browser and hydration errors. The SSR Storybook pages mirror +those scenarios and expose the hydration runtime controls for manual +inspection. + +Every SSR page reports: + +- server render mode: always `ssr`; +- requested hydration runtime: auto, native, or fallback; +- runtime resolved by the hydrated client. + +The hydration runtime buttons use the same `positioningRuntime` query parameter +as the CSR pages. They do not change the server render mode. In auto mode, +hydration keeps native anchor positioning on supported browsers and only loads +the fallback on unsupported browsers. diff --git a/apps/popover-tests-headless/config/tests.js b/apps/popover-tests-headless/config/tests.js new file mode 100644 index 00000000000000..c6c67de97059e8 --- /dev/null +++ b/apps/popover-tests-headless/config/tests.js @@ -0,0 +1,3 @@ +/** Jest test setup file. */ + +require('@testing-library/jest-dom'); diff --git a/apps/popover-tests-headless/eslint.config.js b/apps/popover-tests-headless/eslint.config.js new file mode 100644 index 00000000000000..01494481e8e631 --- /dev/null +++ b/apps/popover-tests-headless/eslint.config.js @@ -0,0 +1,14 @@ +// @ts-check +const fluentPlugin = require('@fluentui/eslint-plugin'); + +/** @type {import("eslint").Linter.Config[]} */ +module.exports = [ + ...fluentPlugin.configs['flat/node'], + { + rules: { + '@typescript-eslint/explicit-module-boundary-types': 'off', + 'import/no-extraneous-dependencies': ['error', { packageDir: ['../../', './'] }], + 'no-restricted-globals': 'off', + }, + }, +]; diff --git a/apps/popover-tests-headless/jest.config.js b/apps/popover-tests-headless/jest.config.js new file mode 100644 index 00000000000000..3df8a3b1a49d54 --- /dev/null +++ b/apps/popover-tests-headless/jest.config.js @@ -0,0 +1,15 @@ +// @ts-check + +/** + * @type {import('@jest/types').Config.InitialOptions} + */ +module.exports = { + displayName: 'popover-tests-headless', + preset: '../../jest.preset.js', + transform: { + '^.+\\.tsx?$': ['@swc/jest', {}], + }, + testEnvironment: 'jsdom', + coverageDirectory: './coverage', + setupFilesAfterEnv: ['./config/tests.js'], +}; diff --git a/apps/popover-tests-headless/just.config.ts b/apps/popover-tests-headless/just.config.ts new file mode 100644 index 00000000000000..b10db31a6aca51 --- /dev/null +++ b/apps/popover-tests-headless/just.config.ts @@ -0,0 +1,3 @@ +import { preset } from '@fluentui/scripts-tasks'; + +preset(); diff --git a/apps/popover-tests-headless/package.json b/apps/popover-tests-headless/package.json new file mode 100644 index 00000000000000..0c31591a7b3ed9 --- /dev/null +++ b/apps/popover-tests-headless/package.json @@ -0,0 +1,24 @@ +{ + "name": "@fluentui/popover-tests-headless", + "version": "0.0.0", + "description": "Private SSR and CSR verification app for Headless adaptive positioning", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui" + }, + "license": "MIT", + "scripts": { + "clean": "yarn run -T just-scripts clean", + "code-style": "yarn run -T just-scripts code-style", + "lint": "yarn run -T just-scripts lint", + "storybook": "yarn run -T storybook dev", + "test": "yarn run -T jest --passWithNoTests", + "type-check": "yarn run -T just-scripts type-check", + "test-ssr": "yarn run -T test-ssr \"./src/stories/**/*.stories.tsx\"" + }, + "dependencies": { + "@fluentui/react-components": "*", + "@fluentui/react-headless-components-preview": "*" + } +} diff --git a/apps/popover-tests-headless/project.json b/apps/popover-tests-headless/project.json new file mode 100644 index 00000000000000..5a48f40f6f2fb6 --- /dev/null +++ b/apps/popover-tests-headless/project.json @@ -0,0 +1,8 @@ +{ + "name": "popover-tests-headless", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "implicitDependencies": [], + "sourceRoot": "apps/popover-tests-headless/src", + "tags": ["vNext", "platform:web"] +} diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Combobox.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Combobox.stories.tsx new file mode 100644 index 00000000000000..77026c9ef2150b --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Combobox.stories.tsx @@ -0,0 +1,5 @@ +import { ComboboxControl } from './Controls'; + +export const Combobox = ComboboxControl; + +export default { title: 'Positioning Runtime / CSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Controls.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Controls.tsx new file mode 100644 index 00000000000000..8ba924ea8c9254 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Controls.tsx @@ -0,0 +1,832 @@ +import * as React from 'react'; + +import { Button, makeStyles, tokens } from '@fluentui/react-components'; +import { Combobox, Option as ComboboxOption } from '@fluentui/react-headless-components-preview/combobox'; +import { Dropdown, Option as DropdownOption } from '@fluentui/react-headless-components-preview/dropdown'; +import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from '@fluentui/react-headless-components-preview/menu'; +import { Popover, PopoverSurface, PopoverTrigger } from '@fluentui/react-headless-components-preview/popover'; +import { Tag } from '@fluentui/react-headless-components-preview/tag'; +import { + TagPicker, + TagPickerControl as HeadlessTagPickerControl, + TagPickerGroup, + TagPickerInput, + TagPickerList, + TagPickerOption, +} from '@fluentui/react-headless-components-preview/tag-picker'; +import type { TagPickerProps } from '@fluentui/react-headless-components-preview/tag-picker'; +import { + TeachingPopover, + TeachingPopoverBody, + TeachingPopoverFooter, + TeachingPopoverSurface, + TeachingPopoverTitle, + TeachingPopoverTrigger, +} from '@fluentui/react-headless-components-preview/teaching-popover'; +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +type RuntimeMode = 'auto' | 'native' | 'fallback'; +type RuntimeWindow = Window & { + __FUI_HEADLESS_POSITIONING_RUNTIME_MODE__?: RuntimeMode; +}; + +const RUNTIME_QUERY_PARAM = 'positioningRuntime'; + +const useStyles = makeStyles({ + page: { + display: 'grid', + gap: tokens.spacingVerticalXXL, + minHeight: '100vh', + padding: tokens.spacingHorizontalXXL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + }, + header: { + display: 'grid', + gap: tokens.spacingVerticalM, + maxWidth: '960px', + }, + title: { + margin: 0, + fontFamily: tokens.fontFamilyBase, + fontSize: tokens.fontSizeHero800, + lineHeight: tokens.lineHeightHero800, + }, + subtitle: { + margin: 0, + color: tokens.colorNeutralForeground2, + fontSize: tokens.fontSizeBase400, + lineHeight: tokens.lineHeightBase400, + }, + statusPanel: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + padding: tokens.spacingHorizontalM, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground2, + }, + statusLabel: { + fontWeight: tokens.fontWeightSemibold, + }, + modeControls: { + display: 'flex', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + }, + grid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', + gap: tokens.spacingHorizontalL, + alignItems: 'start', + }, + card: { + display: 'grid', + gap: tokens.spacingVerticalM, + minWidth: 0, + padding: tokens.spacingHorizontalL, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground2, + boxShadow: tokens.shadow4, + }, + wideCard: { + gridColumn: '1 / -1', + }, + cardTitle: { + margin: 0, + fontSize: tokens.fontSizeBase500, + lineHeight: tokens.lineHeightBase500, + }, + cardCopy: { + margin: 0, + color: tokens.colorNeutralForeground2, + lineHeight: tokens.lineHeightBase400, + }, + row: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + }, + column: { + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: tokens.spacingVerticalS, + }, + surface: { + minWidth: '240px', + maxWidth: '340px', + margin: 0, + padding: tokens.spacingHorizontalL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + boxShadow: tokens.shadow16, + '& [data-arrow]': { + position: 'absolute', + width: '12px', + height: '12px', + backgroundColor: tokens.colorNeutralBackground1, + transform: 'rotate(45deg)', + }, + '&[data-placement^="above"] [data-arrow]': { + bottom: '-6px', + }, + '&[data-placement^="below"] [data-arrow]': { + top: '-6px', + }, + '&[data-placement^="before"] [data-arrow]': { + right: '-6px', + }, + '&[data-placement^="after"] [data-arrow]': { + left: '-6px', + }, + '&[data-positioning-runtime="native"][data-placement="above"] [data-arrow], &[data-positioning-runtime="native"][data-placement="below"] [data-arrow]': + { + insetInline: 0, + marginInline: 'auto', + }, + '&[data-positioning-runtime="native"][data-placement$="-start"] [data-arrow]': { + left: tokens.spacingHorizontalL, + }, + '&[data-positioning-runtime="native"][data-placement$="-end"] [data-arrow]': { + right: tokens.spacingHorizontalL, + }, + '&[data-positioning-runtime="native"][data-placement="before"] [data-arrow], &[data-positioning-runtime="native"][data-placement="after"] [data-arrow]': + { + insetBlock: 0, + marginBlock: 'auto', + }, + '&[data-positioning-runtime="native"][data-placement$="-top"] [data-arrow]': { + top: tokens.spacingVerticalL, + }, + '&[data-positioning-runtime="native"][data-placement$="-bottom"] [data-arrow]': { + bottom: tokens.spacingVerticalL, + }, + }, + nestedSurface: { + minWidth: '280px', + }, + menuSurface: { + display: 'flex', + flexDirection: 'column', + minWidth: '180px', + padding: tokens.spacingVerticalXS, + }, + menuList: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + outlineStyle: 'none', + }, + menuItem: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + color: tokens.colorNeutralForeground1, + backgroundColor: 'transparent', + borderRadius: tokens.borderRadiusMedium, + textAlign: 'start', + cursor: 'pointer', + '&:hover, &:focus-visible': { + backgroundColor: tokens.colorNeutralBackground1Hover, + outline: `${tokens.strokeWidthThick} solid ${tokens.colorBrandStroke1}`, + }, + }, + tooltip: { + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + color: tokens.colorNeutralForegroundInverted, + backgroundColor: tokens.colorNeutralBackgroundStatic, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow8, + '& [data-arrow]': { + position: 'absolute', + width: '8px', + height: '8px', + backgroundColor: tokens.colorNeutralBackgroundStatic, + transform: 'rotate(45deg)', + }, + '&[data-placement^="above"] [data-arrow]': { + bottom: '-4px', + }, + '&[data-placement^="below"] [data-arrow]': { + top: '-4px', + }, + '&[data-positioning-runtime="native"] [data-arrow]': { + insetInline: 0, + marginInline: 'auto', + }, + }, + dropdownRoot: { + position: 'relative', + display: 'flex', + width: '280px', + }, + dropdownButton: { + display: 'flex', + justifyContent: 'space-between', + width: '100%', + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusMedium, + }, + listbox: { + display: 'flex', + flexDirection: 'column', + minWidth: '280px', + maxHeight: '240px', + overflowY: 'auto', + padding: tokens.spacingVerticalXS, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusMedium, + boxShadow: tokens.shadow16, + }, + option: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + borderRadius: tokens.borderRadiusMedium, + '&:hover, &[data-activedescendant-focusvisible]': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + '&[data-selected]': { + color: tokens.colorBrandForeground1, + backgroundColor: tokens.colorBrandBackground2, + }, + }, + comboboxInput: { + width: '280px', + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusMedium, + }, + tagPickerControl: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: tokens.spacingHorizontalXS, + width: '320px', + padding: tokens.spacingVerticalXS, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusMedium, + }, + tagPickerGroup: { + display: 'flex', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalXS, + }, + tag: { + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`, + backgroundColor: tokens.colorBrandBackground2, + borderRadius: tokens.borderRadiusMedium, + }, + tagPickerInput: { + minWidth: '160px', + flexGrow: 1, + border: '0', + outlineStyle: 'none', + }, + teachingTitle: { + margin: 0, + fontSize: tokens.fontSizeBase500, + lineHeight: tokens.lineHeightBase500, + }, + edgeCanvas: { + position: 'relative', + height: '280px', + overflow: 'hidden', + border: `${tokens.strokeWidthThin} dashed ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground3, + }, + edgeTrigger: { + position: 'absolute', + insetInlineEnd: tokens.spacingHorizontalM, + insetBlockEnd: tokens.spacingVerticalM, + }, + placement: { + display: 'inline-flex', + padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalXS}`, + fontFamily: tokens.fontFamilyMonospace, + fontSize: tokens.fontSizeBase200, + color: tokens.colorBrandForeground1, + backgroundColor: tokens.colorBrandBackground2, + borderRadius: tokens.borderRadiusSmall, + }, + probeTrigger: { + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + }, +}); + +const getRuntimeMode = (): RuntimeMode => { + const requested = new URLSearchParams(window.location.search).get(RUNTIME_QUERY_PARAM); + return requested === 'native' || requested === 'fallback' ? requested : 'auto'; +}; + +const reloadWithRuntime = (mode: RuntimeMode): void => { + const url = new URL(window.location.href); + if (mode === 'auto') { + url.searchParams.delete(RUNTIME_QUERY_PARAM); + } else { + url.searchParams.set(RUNTIME_QUERY_PARAM, mode); + } + window.location.assign(url.toString()); +}; + +const usePlacementReadout = (): [React.RefCallback, string] => { + const [element, setElement] = React.useState(null); + const [placement, setPlacement] = React.useState('not mounted'); + + React.useEffect(() => { + if (!element) { + setPlacement('not mounted'); + return; + } + + const update = () => setPlacement(element.getAttribute('data-placement') ?? 'pending'); + update(); + + const observer = new MutationObserver(update); + observer.observe(element, { + attributes: true, + attributeFilter: ['data-placement'], + }); + + return () => observer.disconnect(); + }, [element]); + + return [setElement, placement]; +}; + +const RuntimeSentinel = (props: { onRuntimeChange: (runtime: string) => void }): React.ReactElement => { + const { onRuntimeChange } = props; + const styles = useStyles(); + const [element, setElement] = React.useState(null); + + React.useEffect(() => { + if (!element) { + return; + } + + const update = () => onRuntimeChange(element.getAttribute('data-positioning-runtime') ?? 'pending'); + update(); + + const observer = new MutationObserver(update); + observer.observe(element, { + attributes: true, + attributeFilter: ['data-positioning-runtime'], + }); + + return () => observer.disconnect(); + }, [element, onRuntimeChange]); + + return ( + + + + ); +}; + +const RuntimeControls = (props: { requestedMode: RuntimeMode; resolvedMode: string }): React.ReactElement => { + const { requestedMode, resolvedMode } = props; + const styles = useStyles(); + + return ( +
+ + Requested:{' '} + {requestedMode} + + + Resolved:{' '} + {resolvedMode} + +
+ {(['auto', 'native', 'fallback'] as const).map(mode => ( + + ))} +
+
+ ); +}; + +const BasicPopoverExample = (): React.ReactElement => { + const styles = useStyles(); + const [open, setOpen] = React.useState(false); + const [surfaceRef, placement] = usePlacementReadout(); + + return ( +
+

Basic Popover

+

+ Verify click, outside click, Escape, arrow placement, focus restoration, and controlled close. +

+
+ setOpen(data.open)} positioning="below-start" withArrow> + + + + +
+ Adaptive popover + + Placement:{' '} + + {placement} + + + +
+
+
+ {open ? 'open' : 'closed'} +
+
+ ); +}; + +const EdgePlacementExample = (): React.ReactElement => { + const styles = useStyles(); + const [surfaceRef, placement] = usePlacementReadout(); + + return ( +
+

Collision and fallback placement

+

+ The requested placement is below-end at the canvas corner. It should resolve to a fitting fallback and keep the + surface inside the viewport. +

+
+ + + + + +
+ Collision test + + Resolved placement:{' '} + + {placement} + + +
+
+
+
+
+ ); +}; + +const NestedPopoverExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Nested Popovers

+

+ Open both surfaces, then press Escape. The inner surface should close first while the outer remains open. +

+ + + + + +
+ Outer popover + + + + + + Inner popover + + +
+
+
+
+ ); +}; + +const MenuExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Menu

+

Verify first-item focus, keyboard navigation, outside dismissal, and Escape.

+ + + + + + + New file + Open file + Save + + + +
+ ); +}; + +const TooltipExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Tooltip

+

Verify hover, keyboard focus, outside dismissal, and Escape.

+ + Adaptive tooltip + + } + relationship="description" + showDelay={0} + withArrow + > + + +
+ ); +}; + +const DropdownExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Dropdown

+

+ Verify listbox portal placement, keyboard navigation, selection, and outside dismissal. +

+ + Canary + Preview + Production + +
+ ); +}; + +const ComboboxExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

Combobox

+

+ Verify editable filtering, listbox positioning, keyboard navigation, selection, and dismissal. +

+ + Canary + Preview + Production + +
+ ); +}; + +const TeachingPopoverExample = (): React.ReactElement => { + const styles = useStyles(); + + return ( +
+

TeachingPopover

+

Verify arrow positioning and interactive content placement.

+ + + + + + + Positioning guidance + This teaching surface uses the same adaptive positioning hook. + + + + + + +
+ ); +}; + +const TAG_PICKER_OPTIONS = ['Ada Lovelace', 'Grace Hopper', 'Margaret Hamilton']; + +const TagPickerExample = (): React.ReactElement => { + const styles = useStyles(); + const [selectedOptions, setSelectedOptions] = React.useState([]); + + const onOptionSelect: TagPickerProps['onOptionSelect'] = (_event, data) => { + setSelectedOptions(data.selectedOptions); + }; + + return ( +
+

TagPicker

+

Verify input/listbox positioning and multiselect behavior.

+ + + + {selectedOptions.map(option => ( + + {option} + + ))} + + + + + {TAG_PICKER_OPTIONS.filter(option => !selectedOptions.includes(option)).map(option => ( + + {option} + + ))} + + +
+ ); +}; + +const RuntimePage = (props: { + children: React.ReactNode; + description: string; + requestedMode: RuntimeMode; + title: string; +}): React.ReactElement => { + const { children, description, requestedMode, title } = props; + const styles = useStyles(); + const [resolvedMode, setResolvedMode] = React.useState('pending'); + + return ( +
+ +
+

{title}

+

{description}

+ +
+
{children}
+
+ ); +}; + +const RuntimeGate = (props: { children: React.ReactNode; description: string; title: string }): React.ReactElement => { + const { children, description, title } = props; + const [requestedMode, setRequestedMode] = React.useState(null); + + React.useEffect(() => { + const mode = getRuntimeMode(); + (window as RuntimeWindow).__FUI_HEADLESS_POSITIONING_RUNTIME_MODE__ = mode; + setRequestedMode(mode); + }, []); + + if (!requestedMode) { + return
Initializing positioning runtime...
; + } + + return ( + + {children} + + ); +}; + +export const PopoverControl = (): React.ReactElement => ( + + + + + +); + +export const MenuControl = (): React.ReactElement => ( + + + +); + +export const TooltipControl = (): React.ReactElement => ( + + + +); + +export const DropdownControl = (): React.ReactElement => ( + + + +); + +export const ComboboxControl = (): React.ReactElement => ( + + + +); + +export const TeachingPopoverControl = (): React.ReactElement => ( + + + +); + +export const TagPickerControl = (): React.ReactElement => ( + + + +); diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Dropdown.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Dropdown.stories.tsx new file mode 100644 index 00000000000000..7b3c12e787f3bd --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Dropdown.stories.tsx @@ -0,0 +1,5 @@ +import { DropdownControl } from './Controls'; + +export const Dropdown = DropdownControl; + +export default { title: 'Positioning Runtime / CSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Menu.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Menu.stories.tsx new file mode 100644 index 00000000000000..381e1c340eb293 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Menu.stories.tsx @@ -0,0 +1,5 @@ +import { MenuControl } from './Controls'; + +export const Menu = MenuControl; + +export default { title: 'Positioning Runtime / CSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Popover.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Popover.stories.tsx new file mode 100644 index 00000000000000..eb2f81f6e7b45d --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Popover.stories.tsx @@ -0,0 +1,5 @@ +import { PopoverControl } from './Controls'; + +export const Popover = PopoverControl; + +export default { title: 'Positioning Runtime / CSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/TagPicker.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/TagPicker.stories.tsx new file mode 100644 index 00000000000000..def5b6240bf296 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/TagPicker.stories.tsx @@ -0,0 +1,5 @@ +import { TagPickerControl } from './Controls'; + +export const TagPicker = TagPickerControl; + +export default { title: 'Positioning Runtime / CSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/TeachingPopover.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/TeachingPopover.stories.tsx new file mode 100644 index 00000000000000..e0958a2e754ce9 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/TeachingPopover.stories.tsx @@ -0,0 +1,5 @@ +import { TeachingPopoverControl } from './Controls'; + +export const TeachingPopover = TeachingPopoverControl; + +export default { title: 'Positioning Runtime / CSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Tooltip.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Tooltip.stories.tsx new file mode 100644 index 00000000000000..aea4fb5fcded3b --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeCSR/Tooltip.stories.tsx @@ -0,0 +1,5 @@ +import { TooltipControl } from './Controls'; + +export const Tooltip = TooltipControl; + +export default { title: 'Positioning Runtime / CSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Combobox.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Combobox.stories.tsx new file mode 100644 index 00000000000000..9bb86b14014709 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Combobox.stories.tsx @@ -0,0 +1,5 @@ +import { ComboboxControl } from './Controls'; + +export const Combobox = ComboboxControl; + +export default { title: 'Positioning Runtime / SSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Controls.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Controls.tsx new file mode 100644 index 00000000000000..bb66f56bd1ace8 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Controls.tsx @@ -0,0 +1,438 @@ +import * as React from 'react'; + +import { Button, makeStyles, tokens } from '@fluentui/react-components'; +import { Combobox, Option as ComboboxOption } from '@fluentui/react-headless-components-preview/combobox'; +import { Dropdown, Option as DropdownOption } from '@fluentui/react-headless-components-preview/dropdown'; +import { Menu, MenuItem, MenuList, MenuPopover, MenuTrigger } from '@fluentui/react-headless-components-preview/menu'; +import { Popover, PopoverSurface, PopoverTrigger } from '@fluentui/react-headless-components-preview/popover'; +import { + TagPicker, + TagPickerControl as HeadlessTagPickerControl, + TagPickerInput, + TagPickerList, + TagPickerOption, +} from '@fluentui/react-headless-components-preview/tag-picker'; +import { + TeachingPopover, + TeachingPopoverBody, + TeachingPopoverSurface, + TeachingPopoverTitle, + TeachingPopoverTrigger, +} from '@fluentui/react-headless-components-preview/teaching-popover'; +import { Tooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +type RuntimeMode = 'auto' | 'native' | 'fallback'; +type RuntimeWindow = Window & { + __FUI_HEADLESS_POSITIONING_RUNTIME_MODE__?: RuntimeMode; +}; + +const RUNTIME_QUERY_PARAM = 'positioningRuntime'; + +const useStyles = makeStyles({ + page: { + display: 'grid', + gap: tokens.spacingVerticalL, + padding: tokens.spacingHorizontalXL, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + }, + row: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'flex-start', + gap: tokens.spacingHorizontalL, + }, + surface: { + minWidth: '220px', + margin: 0, + padding: tokens.spacingHorizontalM, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + boxShadow: tokens.shadow16, + '& [data-arrow]': { + position: 'absolute', + width: '10px', + height: '10px', + backgroundColor: tokens.colorNeutralBackground1, + transform: 'rotate(45deg)', + }, + '&[data-placement^="above"] [data-arrow]': { + bottom: '-5px', + }, + '&[data-placement^="below"] [data-arrow]': { + top: '-5px', + }, + '&[data-positioning-runtime="native"] [data-arrow]': { + insetInline: 0, + marginInline: 'auto', + }, + }, + menuSurface: { + display: 'flex', + flexDirection: 'column', + }, + menuList: { + display: 'flex', + flexDirection: 'column', + }, + menuItem: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + }, + tooltip: { + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + color: tokens.colorNeutralForegroundInverted, + backgroundColor: tokens.colorNeutralBackgroundStatic, + borderRadius: tokens.borderRadiusMedium, + '& [data-arrow]': { + position: 'absolute', + width: '8px', + height: '8px', + backgroundColor: tokens.colorNeutralBackgroundStatic, + transform: 'rotate(45deg)', + }, + '&[data-placement^="above"] [data-arrow]': { + bottom: '-4px', + }, + '&[data-positioning-runtime="native"] [data-arrow]': { + insetInline: 0, + marginInline: 'auto', + }, + }, + dropdown: { + width: '240px', + }, + listbox: { + display: 'flex', + flexDirection: 'column', + minWidth: '240px', + padding: tokens.spacingVerticalXS, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + }, + option: { + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + }, + input: { + width: '240px', + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + color: tokens.colorNeutralForeground1, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusMedium, + }, + tagPickerControl: { + display: 'flex', + width: '280px', + padding: tokens.spacingVerticalXS, + backgroundColor: tokens.colorNeutralBackground1, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusMedium, + }, + statusPanel: { + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: tokens.spacingHorizontalM, + padding: tokens.spacingHorizontalM, + border: `${tokens.strokeWidthThin} solid ${tokens.colorNeutralStroke1}`, + borderRadius: tokens.borderRadiusLarge, + backgroundColor: tokens.colorNeutralBackground2, + }, + statusLabel: { + fontWeight: tokens.fontWeightSemibold, + }, + modeControls: { + display: 'flex', + flexWrap: 'wrap', + gap: tokens.spacingHorizontalS, + }, + probeTrigger: { + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + whiteSpace: 'nowrap', + }, +}); + +const getHydrationRuntimeMode = (): RuntimeMode => { + if (typeof window === 'undefined') { + return 'auto'; + } + + const requested = new URLSearchParams(window.location.search).get(RUNTIME_QUERY_PARAM); + return requested === 'native' || requested === 'fallback' ? requested : 'auto'; +}; + +const configureHydrationRuntime = (): RuntimeMode => { + const mode = getHydrationRuntimeMode(); + if (typeof window !== 'undefined') { + (window as RuntimeWindow).__FUI_HEADLESS_POSITIONING_RUNTIME_MODE__ = mode; + } + return mode; +}; + +const reloadWithRuntime = (mode: RuntimeMode): void => { + const url = new URL(window.location.href); + if (mode === 'auto') { + url.searchParams.delete(RUNTIME_QUERY_PARAM); + } else { + url.searchParams.set(RUNTIME_QUERY_PARAM, mode); + } + window.location.assign(url.toString()); +}; + +const RuntimeSentinel = (props: { onRuntimeChange: (runtime: string) => void }): React.ReactElement => { + const { onRuntimeChange } = props; + const styles = useStyles(); + const [element, setElement] = React.useState(null); + + React.useEffect(() => { + if (!element) { + return; + } + + const update = () => onRuntimeChange(element.getAttribute('data-positioning-runtime') ?? 'pending'); + update(); + + const observer = new MutationObserver(update); + observer.observe(element, { + attributes: true, + attributeFilter: ['data-positioning-runtime'], + }); + + return () => observer.disconnect(); + }, [element, onRuntimeChange]); + + return ( + + + + ); +}; + +const HydrationRuntimeControls = (props: { requestedMode: RuntimeMode; resolvedMode: string }): React.ReactElement => { + const { requestedMode, resolvedMode } = props; + const styles = useStyles(); + + return ( +
+ + Server render: ssr + + + Hydration requested:{' '} + + {requestedMode} + + + + Client resolved:{' '} + {resolvedMode} + +
+ {(['auto', 'native', 'fallback'] as const).map(mode => ( + + ))} +
+
+ ); +}; + +const SsrPage = (props: { children: React.ReactNode; description: string; title: string }): React.ReactElement => { + const { children, description, title } = props; + const styles = useStyles(); + const requestedMode = configureHydrationRuntime(); + const [resolvedMode, setResolvedMode] = React.useState('pending'); + + return ( +
+ +

{title}

+

{description}

+ +
{children}
+
+ ); +}; + +export const PopoverControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + + + + + Server-rendered Popover surface + + + + ); +}; + +export const MenuControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + + + + + + First item + Second item + + + + + ); +}; + +export const TooltipControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + Server-rendered Tooltip + + } + relationship="description" + visible + withArrow + > + + + + ); +}; + +export const DropdownControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + One + Two + + + ); +}; + +export const ComboboxControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + One + Two + + + ); +}; + +export const TeachingPopoverControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + + + + + + Server-rendered TeachingPopover + + + + + ); +}; + +export const TagPickerControl = (): React.ReactElement => { + const styles = useStyles(); + + return ( + + + + + + + + Ada Lovelace + + + Grace Hopper + + + + + ); +}; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Dropdown.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Dropdown.stories.tsx new file mode 100644 index 00000000000000..4e90ed1975d839 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Dropdown.stories.tsx @@ -0,0 +1,5 @@ +import { DropdownControl } from './Controls'; + +export const Dropdown = DropdownControl; + +export default { title: 'Positioning Runtime / SSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Menu.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Menu.stories.tsx new file mode 100644 index 00000000000000..fb7ecf7aabd550 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Menu.stories.tsx @@ -0,0 +1,5 @@ +import { MenuControl } from './Controls'; + +export const Menu = MenuControl; + +export default { title: 'Positioning Runtime / SSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Popover.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Popover.stories.tsx new file mode 100644 index 00000000000000..55663d9569b1cd --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Popover.stories.tsx @@ -0,0 +1,5 @@ +import { PopoverControl } from './Controls'; + +export const Popover = PopoverControl; + +export default { title: 'Positioning Runtime / SSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/TagPicker.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/TagPicker.stories.tsx new file mode 100644 index 00000000000000..74b383d9efb083 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/TagPicker.stories.tsx @@ -0,0 +1,5 @@ +import { TagPickerControl } from './Controls'; + +export const TagPicker = TagPickerControl; + +export default { title: 'Positioning Runtime / SSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/TeachingPopover.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/TeachingPopover.stories.tsx new file mode 100644 index 00000000000000..caad95b21601d9 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/TeachingPopover.stories.tsx @@ -0,0 +1,5 @@ +import { TeachingPopoverControl } from './Controls'; + +export const TeachingPopover = TeachingPopoverControl; + +export default { title: 'Positioning Runtime / SSR' }; diff --git a/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Tooltip.stories.tsx b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Tooltip.stories.tsx new file mode 100644 index 00000000000000..5f6d7b961811c2 --- /dev/null +++ b/apps/popover-tests-headless/src/stories/PositioningRuntimeSSR/Tooltip.stories.tsx @@ -0,0 +1,5 @@ +import { TooltipControl } from './Controls'; + +export const Tooltip = TooltipControl; + +export default { title: 'Positioning Runtime / SSR' }; diff --git a/apps/popover-tests-headless/tsconfig.json b/apps/popover-tests-headless/tsconfig.json new file mode 100644 index 00000000000000..0214b01623499c --- /dev/null +++ b/apps/popover-tests-headless/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "target": "ES2019", + "noEmit": true, + "isolatedModules": true, + "importHelpers": true, + "jsx": "react", + "noUnusedLocals": true, + "preserveConstEnums": true + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + }, + { + "path": "./.storybook/tsconfig.json" + } + ] +} diff --git a/apps/popover-tests-headless/tsconfig.lib.json b/apps/popover-tests-headless/tsconfig.lib.json new file mode 100644 index 00000000000000..d6f931d85a702e --- /dev/null +++ b/apps/popover-tests-headless/tsconfig.lib.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "lib": ["ES2019", "dom"], + "outDir": "dist", + "declaration": true, + "declarationDir": "dist/types", + "inlineSources": true, + "types": ["static-assets", "environment", "node"] + }, + "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx"], + "include": ["./src/**/*.ts", "./src/**/*.tsx"] +} diff --git a/apps/popover-tests-headless/tsconfig.spec.json b/apps/popover-tests-headless/tsconfig.spec.json new file mode 100644 index 00000000000000..457c5258c305a5 --- /dev/null +++ b/apps/popover-tests-headless/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "types": ["jest", "node"] + }, + "include": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"] +} diff --git a/change/@fluentui-react-headless-components-preview-6284a3df-0970-4cc5-90e2-64753fd0119a.json b/change/@fluentui-react-headless-components-preview-6284a3df-0970-4cc5-90e2-64753fd0119a.json new file mode 100644 index 00000000000000..64b13665527d93 --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-6284a3df-0970-4cc5-90e2-64753fd0119a.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Add a lazy positioning fallback when CSS anchors are unavailable.", + "packageName": "@fluentui/react-headless-components-preview", + "email": "seanmonahan@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/change/@fluentui-react-positioning-f0007bb5-5fe9-46b1-9369-ecd8984efb51.json b/change/@fluentui-react-positioning-f0007bb5-5fe9-46b1-9369-ecd8984efb51.json new file mode 100644 index 00000000000000..00ebc1e53e3f33 --- /dev/null +++ b/change/@fluentui-react-positioning-f0007bb5-5fe9-46b1-9369-ecd8984efb51.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Add shared positioning manager foundation for anchor-positioning fallback.", + "packageName": "@fluentui/react-positioning", + "email": "seanmonahan@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-headless-components-preview/library/.swcrc b/packages/react-components/react-headless-components-preview/library/.swcrc index 67f33cb59414a3..d126c8fca310c7 100644 --- a/packages/react-components/react-headless-components-preview/library/.swcrc +++ b/packages/react-components/react-headless-components-preview/library/.swcrc @@ -15,7 +15,7 @@ "syntax": "typescript", "tsx": true, "decorators": false, - "dynamicImport": false + "dynamicImport": true }, "externalHelpers": true, "transform": { diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/PositioningRuntime.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/PositioningRuntime.fixture.js new file mode 100644 index 00000000000000..46c421d5391bad --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/PositioningRuntime.fixture.js @@ -0,0 +1,13 @@ +import * as Combobox from '@fluentui/react-headless-components-preview/combobox'; +import * as Dropdown from '@fluentui/react-headless-components-preview/dropdown'; +import * as Menu from '@fluentui/react-headless-components-preview/menu'; +import * as Popover from '@fluentui/react-headless-components-preview/popover'; +import * as TagPicker from '@fluentui/react-headless-components-preview/tag-picker'; +import * as TeachingPopover from '@fluentui/react-headless-components-preview/teaching-popover'; +import * as Tooltip from '@fluentui/react-headless-components-preview/tooltip'; + +console.log({ Combobox, Dropdown, Menu, Popover, TagPicker, TeachingPopover, Tooltip }); + +export default { + name: 'react-headless-components-preview: positioning runtime', +}; diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/PositioningRuntimeCommonJS.fixture.cjs b/packages/react-components/react-headless-components-preview/library/bundle-size/PositioningRuntimeCommonJS.fixture.cjs new file mode 100644 index 00000000000000..8bcd3facdd912e --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/bundle-size/PositioningRuntimeCommonJS.fixture.cjs @@ -0,0 +1,4 @@ +const Popover = require('@fluentui/react-headless-components-preview/popover'); +const Tooltip = require('@fluentui/react-headless-components-preview/tooltip'); + +console.log({ Popover, Tooltip }); diff --git a/packages/react-components/react-headless-components-preview/library/config/tests.cjs b/packages/react-components/react-headless-components-preview/library/config/tests.cjs index 3507289757d2ef..ba72a6fbf1168b 100644 --- a/packages/react-components/react-headless-components-preview/library/config/tests.cjs +++ b/packages/react-components/react-headless-components-preview/library/config/tests.cjs @@ -3,6 +3,10 @@ require('@testing-library/jest-dom'); require('@oddbird/popover-polyfill'); +// Existing component tests exercise the native positioning backend. Focused +// fallback tests override this value before their first positioning lookup. +window.__FUI_HEADLESS_POSITIONING_RUNTIME_MODE__ = 'native'; + global.ResizeObserver = class ResizeObserver { observe() { // no-op for jsdom diff --git a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md index bdda419957b68e..cc62f5ce2c0f30 100644 --- a/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md +++ b/packages/react-components/react-headless-components-preview/library/docs/popover-spec.md @@ -2,7 +2,7 @@ ## Overview -Popover is an anchored overlay surface that displays transient content (actions, details, confirmations, rich tooltips) next to a trigger element. It composes a trigger (optional if opened programmatically), a surface (the floating content), and an optional arrow. The surface elevates into the browser's **top layer** via the native HTML Popover API. Placement is computed via the native **CSS Anchor Positioning API** — no JS layout loop. +Popover is an anchored overlay surface that displays transient content (actions, details, confirmations, rich tooltips) next to a trigger element. It composes a trigger (optional if opened programmatically), a surface (the floating content), and an optional arrow. The surface elevates into the browser's **top layer** via the native HTML Popover API. Placement uses the native **CSS Anchor Positioning API** when the complete required feature set is available and lazily loads Fluent's `react-positioning` backend otherwise. Popover lets the browser manage dismissal: the surface is rendered with `popover="auto"`, so Escape, click-outside, and popover-stack peer-dismissal happen at HTML Popover spec timing and are mirrored back into React via the surface's `toggle` event. Open paths (click, hover, context-menu, controlled `open`) flow through React; close paths defer to the browser. Focus trapping is deferred to a later iteration — the surface is currently a non-modal `role="group"`. @@ -174,33 +174,41 @@ The headless Popover does **not** add `aria-live` to the surface. Consumers rend ## Positioning -Placement is handled entirely by the `usePositioning` hook, which writes native CSS anchor-positioning properties onto the surface element. No JS layout loop. +Placement is handled entirely by the `usePositioning` hook. Consumers use one API while the hook selects an internal backend per document: + +- During SSR and the initial hydration commit, the hook emits the native CSS-anchor contract. +- Browsers with complete CSS Anchor Positioning support remain on the native backend and do not download `react-positioning`. +- Other browsers lazily load one shared `react-positioning` chunk. The surface remains the same native Popover element in the browser top layer; only its coordinates are managed by JavaScript. + +HTML Popover behavior is never polyfilled by this runtime. Open state, light dismiss, focus behavior, and top-layer rendering remain browser-owned in both positioning modes. ### Options (all optional) -| Option | Type | Default | Effect | -| ------------------- | ----------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `position` | `'above' \| 'below' \| 'before' \| 'after'` | `'above'` | Which side of the anchor the surface sits on. Physical `top` / `bottom` / `left` / `right` are normalized. | -| `align` | `'start' \| 'center' \| 'end' \| 'top' \| 'bottom'` | `'center'` | Cross-axis alignment. `top` → `start`, `bottom` → `end` (v9 aliases). | -| `offset` | `number \| { mainAxis?: number; crossAxis?: number }` | `0` | Logical-margin offset from the anchor. | -| `fallbackPositions` | `PositioningShorthandValue[]` | `[]` | Custom fallback chain. Each entry is converted to a `` value inline in `position-try-fallbacks`. | -| `coverTarget` | `boolean` | `false` | Overlap the anchor instead of sitting beside it. | -| `pinned` | `boolean` | `false` | Disable fallback flipping; surface stays at the requested placement even if it overflows. | -| `matchTargetSize` | `'width'` | — | Sets the surface's `width` to `anchor-size(width)`. | -| `strategy` | `'fixed' \| 'absolute'` | `'absolute'` | CSS `position` property value on the surface. Matches v9's default. Use `'fixed'` when the surface needs to escape transformed / `contain: layout` ancestors for anchoring purposes. | -| `target` | `HTMLElement \| RefObject` | — | Custom anchor element. When set, `anchor-name` is written on this element instead of the trigger. | -| `positioningRef` | `Ref` | — | `{ setTarget(el): void; updatePosition(): void }`. `updatePosition` is a no-op — native positioning self-updates. | +| Option | Type | Default | Effect | +| ------------------- | ----------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `position` | `'above' \| 'below' \| 'before' \| 'after'` | `'above'` | Which side of the anchor the surface sits on. Physical `top` / `bottom` / `left` / `right` are normalized. | +| `align` | `'start' \| 'center' \| 'end' \| 'top' \| 'bottom'` | `'center'` | Cross-axis alignment. `top` → `start`, `bottom` → `end` (v9 aliases). | +| `offset` | `number \| { mainAxis?: number; crossAxis?: number }` | `0` | Logical-margin offset from the anchor. | +| `fallbackPositions` | `PositioningShorthandValue[]` | `[]` | Custom fallback chain. Preserved in order for both native `position-try-fallbacks` and the JavaScript positioning backend. | +| `coverTarget` | `boolean` | `false` | Overlap the anchor instead of sitting beside it. | +| `pinned` | `boolean` | `false` | Disable fallback flipping; surface stays at the requested placement even if it overflows. | +| `matchTargetSize` | `'width'` | — | Matches the surface width to the target through `anchor-size(width)` or equivalent fallback middleware. | +| `strategy` | `'fixed' \| 'absolute'` | `'fixed'` | CSS `position` property value on the surface. | +| `target` | `HTMLElement \| RefObject` | — | Custom anchor element. When set, `anchor-name` is written on this element instead of the trigger. | +| `positioningRef` | `Ref` | — | `{ setTarget(el): void; updatePosition(): void }`. Native positioning self-updates; fallback mode delegates updates to the active positioning manager. | ### Rendering -- The hook writes `anchor-name: --popover-anchor-` on the anchor (trigger or custom target) via `useIsomorphicLayoutEffect`. -- On the surface it writes `position: absolute` (or `fixed` if `strategy: 'fixed'`); `inset: auto; margin: 0; position-anchor: --popover-anchor-; position-area: ; position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline`. The `inset: auto; margin: 0` reset is required because the UA popover stylesheet sets `inset: 0; margin: auto`, which fights `position-area`. +- Native mode writes `anchor-name: --popover-anchor-` on the anchor (trigger or custom target) via `useIsomorphicLayoutEffect`. +- On the surface, native mode writes `position: fixed` (or the requested strategy); `inset: auto; margin: 0; position-anchor: --popover-anchor-; position-area: ; position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline`. The `inset: auto; margin: 0` reset is required because the UA popover stylesheet sets `inset: 0; margin: auto`, which fights `position-area`. - For center alignment, the hook also writes `place-self: anchor-center` as a workaround for https://crbug.com/438334710 (Chromium <=130 doesn't reliably apply the implicit anchor-center self-alignment to single-keyword `position-area` values). - `data-placement` is set to the requested placement and then live-updated by `usePlacementObserver` (ResizeObserver + scroll listener) to reflect the browser's post-flip decision. +- Fallback mode preserves the same surface and `data-placement` contract while `react-positioning` writes fixed coordinates. Shift middleware is disabled and ordered fallback placement uses `initialPlacement` semantics to stay close to native behavior. +- `data-positioning-runtime` reports `native`, `loading`, or `fallback` for internal diagnostics and compatibility styling. ### Arrow -Arrow positioning is **consumer-owned CSS** keyed off `[data-placement]`. The hook doesn't manipulate the arrow element. Consumers writing arrow styles typically target `[data-placement^='above']`, `[data-placement^='below']`, etc., and use anchor queries (`@container anchored()`) for flip-aware styling when supported. +Arrow styling remains keyed off `[data-placement]`. Native mode keeps arrow positioning consumer-owned through CSS anchors. Fallback mode passes the existing arrow element to `react-positioning`, which writes only its `left`/`top` coordinates. ## Open / dismiss model @@ -312,13 +320,13 @@ Positioning uses CSS _logical_ properties throughout (`block-start`, `block-end` ## Native API surface -The package relies on three native browser APIs: +The package relies on the HTML Popover API and conditionally uses CSS Anchor Positioning: -- **CSS Anchor Positioning** (Chromium 125+) — `anchor-name`, `position-anchor`, `position-area`, `anchor-size()`, `position-try-fallbacks`. -- **HTML Popover API** (Chromium 114+) — `popover="auto"` + `showPopover()` + the `toggle` event for top-layer elevation, light dismiss, and state mirroring. Feature-detected (`typeof el.showPopover === 'function'`); SSR-safe. -- **ResizeObserver** — used sparingly by `usePlacementObserver` (for live `data-placement`) +- **HTML Popover API** — required for `popover="auto"`, `showPopover()`, top-layer elevation, light dismiss, and state mirroring. There is no runtime polyfill or Portal fallback. +- **CSS Anchor Positioning** — selected only when `anchor-name`, `position-anchor`, `position-area`, `position-try-fallbacks`, and `anchor-size()` are all supported. +- **ResizeObserver** — used by native placement observation and the JavaScript fallback manager. -Firefox and Safari are implementing CSS Anchor Positioning; most features work but flip behaviour is still WIP. +Documents without the complete CSS Anchor Positioning contract share one lazily imported `react-positioning` runtime. Server rendering never imports that runtime. ## Notes @@ -326,4 +334,4 @@ Firefox and Safari are implementing CSS Anchor Positioning; most features work b - **Nested popovers**: nesting is JSX-nesting. The browser's popover-stack treats the inner trigger's DOM descendancy of the outer surface as the ancestor signal — Escape closes only the topmost popover, click-outside dismisses the chain. - **Hover-to-open**: `openOnHover` opens on `mouseenter` of the trigger and _stays_ open while the pointer is over the surface. Closing waits `mouseLeaveDelay` ms; this is the one close path that is React-driven (it unmounts the surface, which implicitly closes the native popover). - **Context popovers**: when `openOnContext={true}`, the mouse event's `clientX` / `clientY` are stored as `contextTarget` state — available to consumers via the popover context if they want to anchor the surface at the cursor position instead of on the trigger. -- **Positioning is CSS, not JS**: because placement computation is pushed to the browser, there's no JS layout loop and `positioning.updatePosition()` is a no-op. Consumers that need imperative retargeting use `positioning.setTarget(el)`. +- **Positioning is implementation-neutral**: native-capable browsers use CSS only. Other browsers use the lazy JavaScript backend without changing component props, DOM structure, Popover behavior, or top-layer rendering. diff --git a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md index 07d315e12e20a9..9737130ad77ba3 100644 --- a/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md +++ b/packages/react-components/react-headless-components-preview/library/etc/positioning.api.md @@ -11,7 +11,6 @@ import type { PositioningProps as PositioningProps_2 } from '@fluentui/react-pos import { PositioningShorthand } from '@fluentui/react-positioning'; import { PositioningShorthandValue } from '@fluentui/react-positioning'; import type * as React_2 from 'react'; -import { resolvePositioningShorthand } from '@fluentui/react-positioning'; export { Alignment } @@ -50,7 +49,8 @@ export const POSITIONS: { readonly after: "after"; }; -export { resolvePositioningShorthand } +// @public (undocumented) +export function resolvePositioningShorthand(shorthand: PositioningShorthand | null | undefined): Readonly; // @public (undocumented) export function usePositioning(options: PositioningProps): PositioningReturn; diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index adcb48a4d823da..72546f235c8cfe 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -522,10 +522,14 @@ } }, "./tag-picker": { - "types": "./dist/tag-picker.d.ts", - "node": "./lib-commonjs/tag-picker.js", - "import": "./lib/tag-picker.js", - "require": "./lib-commonjs/tag-picker.js" + "import": { + "types": "./dist/tag-picker.d.ts", + "default": "./lib/tag-picker.js" + }, + "require": { + "types": "./dist/tag-picker.d.cts", + "default": "./lib-commonjs/tag-picker.cjs" + } }, "./teaching-popover": { "import": { @@ -598,7 +602,8 @@ "devDependencies": { "@fluentui/scripts-cypress": "*", "@fluentui/verify-bundle-isolation": "*", - "@oddbird/popover-polyfill": "^0.6.1" + "@oddbird/popover-polyfill": "^0.6.1", + "webpack": "5.108.4" }, "type": "module" } diff --git a/packages/react-components/react-headless-components-preview/library/project.json b/packages/react-components/react-headless-components-preview/library/project.json index 7b99dac170d309..dba8b21c26e071 100644 --- a/packages/react-components/react-headless-components-preview/library/project.json +++ b/packages/react-components/react-headless-components-preview/library/project.json @@ -6,6 +6,11 @@ "tags": ["vNext", "platform:web", "react-headless"], "implicitDependencies": [], "targets": { + "build": { + "options": { + "preserveDynamicImport": true + } + }, "generate-api": { "options": { "exportSubpaths": true @@ -24,6 +29,20 @@ "technologies": ["webpack"], "description": "Assert entry points do not bundle tabster, Griffel or react-icons" } + }, + "verify-positioning-runtime-bundle": { + "cache": true, + "dependsOn": ["build", "^build"], + "command": "node scripts/verify-positioning-runtime-bundle.cjs PositioningRuntime.fixture.js && node scripts/verify-positioning-runtime-bundle.cjs PositioningRuntimeCommonJS.fixture.cjs", + "options": { + "cwd": "{projectRoot}" + }, + "inputs": ["default", "^default", { "externalDependencies": ["webpack"] }], + "outputs": ["{projectRoot}/dist/positioning-runtime-bundle"], + "metadata": { + "technologies": ["webpack"], + "description": "Assert the CSS Anchor positioning fallback remains outside initial bundles" + } } } } diff --git a/packages/react-components/react-headless-components-preview/library/scripts/verify-positioning-runtime-bundle.cjs b/packages/react-components/react-headless-components-preview/library/scripts/verify-positioning-runtime-bundle.cjs new file mode 100644 index 00000000000000..d7aca1884badae --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/scripts/verify-positioning-runtime-bundle.cjs @@ -0,0 +1,116 @@ +const path = require('node:path'); + +const webpack = require('webpack'); + +const packageRoot = path.resolve(__dirname, '..'); +const workspaceRoot = path.resolve(packageRoot, '../../../..'); +const fixture = process.argv[2] ?? 'PositioningRuntime.fixture.js'; +const outputPath = path.join(packageRoot, 'dist', 'positioning-runtime-bundle', path.parse(fixture).name); +const entry = path.join(packageRoot, 'bundle-size', fixture); + +const isFallbackRuntime = resource => + resource.includes(`${path.sep}usePositioning${path.sep}fallbackPositioningRuntime.`); + +const isFallbackDependency = resource => + resource.includes(`${path.sep}react-positioning${path.sep}`) || + resource.includes(`${path.sep}@fluentui${path.sep}react-positioning${path.sep}`) || + resource.includes(`${path.sep}@floating-ui${path.sep}`); + +class VerifyPositioningRuntimeChunksPlugin { + apply(compiler) { + compiler.hooks.afterEmit.tap('VerifyPositioningRuntimeChunksPlugin', compilation => { + const fallbackChunks = new Set(); + const initialViolations = new Set(); + + for (const module of compilation.modules) { + const resource = module.resource ?? module.nameForCondition?.(); + if (!resource) { + continue; + } + + for (const chunk of compilation.chunkGraph.getModuleChunks(module)) { + if (isFallbackRuntime(resource)) { + fallbackChunks.add(chunk); + } + + if (isFallbackDependency(resource) && chunk.canBeInitial()) { + initialViolations.add(resource); + } + } + } + + if (fallbackChunks.size !== 1) { + compilation.errors.push( + new Error(`Expected one async positioning fallback boundary, found ${fallbackChunks.size}.`), + ); + } else if ([...fallbackChunks][0].canBeInitial()) { + compilation.errors.push(new Error('Positioning fallback runtime was retained in an initial chunk.')); + } + + if (initialViolations.size > 0) { + compilation.errors.push( + new Error( + [ + 'Fallback positioning code was retained in an initial chunk:', + ...[...initialViolations].sort().map(resource => ` ${resource}`), + ].join('\n'), + ), + ); + } + }); + } +} + +const compiler = webpack({ + name: 'positioning-runtime-bundle', + target: 'web', + mode: 'production', + context: workspaceRoot, + entry, + externals: { + react: 'react', + 'react-dom': 'react-dom', + 'react/jsx-runtime': 'react/jsx-runtime', + 'react/compiler-runtime': 'react/compiler-runtime', + }, + output: { + path: outputPath, + filename: 'index.js', + chunkFilename: '[name].js', + }, + performance: { hints: false }, + optimization: { + concatenateModules: false, + minimize: false, + splitChunks: false, + }, + module: { + rules: [{ test: /\.[cm]?js$/, resolve: { fullySpecified: false } }], + }, + plugins: [new VerifyPositioningRuntimeChunksPlugin()], +}); + +compiler.run((error, stats) => { + compiler.close(() => { + if (error) { + console.error(error); + process.exitCode = 1; + return; + } + + if (!stats) { + console.error('webpack finished without producing stats'); + process.exitCode = 1; + return; + } + + if (stats.hasErrors()) { + const errors = stats.toJson({ all: false, errors: true }).errors ?? []; + console.error(errors.map(item => item.message).join('\n')); + process.exitCode = 1; + return; + } + + console.log(`${fixture}: positioning fallback is isolated in one asynchronous boundary.`); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useListboxSlot.ts b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useListboxSlot.ts index 2fd4d7e12a81ba..979166b635dcc4 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useListboxSlot.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Dropdown/useListboxSlot.ts @@ -126,13 +126,9 @@ export function useListboxSlot( } catch (error) { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console - console.warn( - [ - 'Popover API is not supported in this browser, and the listbox will not work correctly.', - 'Please include a popover polyfill for better browser support.', - ].join(' '), - { error }, - ); + console.warn('The HTML Popover API is required by Headless listboxes and is not supported in this browser.', { + error, + }); } } diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.internal-types.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.internal-types.ts new file mode 100644 index 00000000000000..16734f169fbca3 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/Popover.internal-types.ts @@ -0,0 +1,19 @@ +import type * as React from 'react'; + +import type { PositioningReturnInternal } from '../../hooks/usePositioning/internalTypes'; +import type { PopoverContextValue, PopoverState } from './Popover.types'; +import type { PopoverSurfaceState } from './PopoverSurface/PopoverSurface.types'; + +export type PopoverStateInternal = PopoverState & { + positioning: PositioningReturnInternal; +}; + +export type PopoverContextValueInternal = PopoverContextValue & { + positioning: PopoverContextValue['positioning'] & { + arrowRef: React.RefCallback; + }; +}; + +export type PopoverSurfaceStateInternal = PopoverSurfaceState & { + renderArrowRef: React.Ref; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/renderPopoverSurface.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/renderPopoverSurface.tsx index af62eb3434e1ff..13f1963c58da2f 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/renderPopoverSurface.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/renderPopoverSurface.tsx @@ -4,13 +4,15 @@ import { assertSlots } from '@fluentui/react-utilities'; import type { PopoverSurfaceSlots, PopoverSurfaceState } from './PopoverSurface.types'; import type { JSXElement } from '@fluentui/react-utilities'; +import type { PopoverSurfaceStateInternal } from '../Popover.internal-types'; export const renderPopoverSurface = (state: PopoverSurfaceState): JSXElement => { assertSlots(state); + const { renderArrowRef } = state as unknown as PopoverSurfaceStateInternal; return ( - {state.withArrow &&
} + {state.withArrow &&
} {state.root.children} ); diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts index a65e2d8ed73022..ae04bb9ded156a 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/PopoverSurface/usePopoverSurface.ts @@ -5,6 +5,7 @@ import { useMergedRefs, slot, useEventCallback } from '@fluentui/react-utilities import { usePopoverContext } from '../popoverContext'; import { stringifyDataAttribute } from '../../../utils'; import type { PopoverSurfaceProps, PopoverSurfaceState } from './PopoverSurface.types'; +import type { PopoverContextValueInternal, PopoverSurfaceStateInternal } from '../Popover.internal-types'; /** * Returns the state for a PopoverSurface component. @@ -19,13 +20,16 @@ export const usePopoverSurface = ( const arrowRef = usePopoverContext(context => context.arrowRef); const withArrow = usePopoverContext(context => context.withArrow); const open = usePopoverContext(context => context.open); - const positioningCtx = usePopoverContext(context => context.positioning); + const positioningCtx = usePopoverContext( + context => context.positioning, + ) as PopoverContextValueInternal['positioning']; const surfaceId = usePopoverContext(context => context.surfaceId); const trapFocus = usePopoverContext(context => context.trapFocus); - const state: PopoverSurfaceState = { + const state: PopoverSurfaceStateInternal = { withArrow, arrowRef, + renderArrowRef: useMergedRefs(arrowRef, positioningCtx.arrowRef), components: { root: 'dialog' }, root: slot.always( { diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts index 0d4069cce4ff4b..b568afcbc9d2f1 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Popover/usePopover.ts @@ -4,6 +4,7 @@ import * as React from 'react'; import { useControllableState, useEventCallback, useId, useTimeout } from '@fluentui/react-utilities'; import { usePositioning, resolvePositioningShorthand } from '../../hooks'; import type { PopoverProps, PopoverState, PopoverContextValue, OpenPopoverEvents } from './Popover.types'; +import type { PopoverContextValueInternal, PopoverStateInternal } from './Popover.internal-types'; const SUPPORTS_POPOVER_OPEN_SELECTOR = typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('selector(:popover-open)'); @@ -76,7 +77,9 @@ export const usePopover = (props: PopoverProps): PopoverState => { const generatedSurfaceId = useId('fui-popover-surface-'); const surfaceId = props.id ?? generatedSurfaceId; - const positioning = usePositioning(resolvePositioningShorthand(props.positioning)); + const positioning = usePositioning( + resolvePositioningShorthand(props.positioning), + ) as PopoverStateInternal['positioning']; const onSurfaceToggle = useEventCallback((event: Event) => { const toggle = event as ToggleEvent; @@ -176,10 +179,11 @@ export const usePopover = (props: PopoverProps): PopoverState => { setContextTarget, positioning, surfaceId, - }; + } as PopoverStateInternal; }; export const usePopoverContextValues = (state: PopoverState): { popover: PopoverContextValue } => { + const internalState = state as PopoverStateInternal; const { open, setOpen, @@ -210,8 +214,9 @@ export const usePopoverContextValues = (state: PopoverState): { popover: Popover positioning: { targetRef: positioning.targetRef, containerRef: positioning.containerRef, + arrowRef: internalState.positioning.arrowRef, }, surfaceId, - }, + } as PopoverContextValueInternal, }; }; diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts index 0bf96cc0846798..4530006b36df81 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/useTooltip.ts @@ -21,6 +21,7 @@ import { KEYBORG_FOCUSIN, useIsNavigatingWithKeyboard } from '@fluentui/react-ta import type { OnVisibleChangeData, TooltipProps, TooltipState, TooltipTriggerProps } from './Tooltip.types'; import { resolvePositioningShorthand, usePositioning } from '../../positioning'; +import type { PositioningReturnInternal } from '../../hooks/usePositioning/internalTypes'; import { stringifyDataAttribute } from '../../utils'; /** @@ -67,7 +68,8 @@ export const useTooltip = (props: TooltipProps): TooltipState => { }; const positioningOptions = resolvePositioningShorthand(positioning); - const { targetRef, containerRef } = usePositioning(positioningOptions); + const { targetRef, containerRef, arrowRef } = usePositioning(positioningOptions) as PositioningReturnInternal; + state.arrowRef = arrowRef as React.Ref; state.content.id = useId('tooltip-', state.content.id); state.content['data-open'] = stringifyDataAttribute(state.visible); @@ -115,13 +117,9 @@ export const useTooltip = (props: TooltipProps): TooltipState => { } catch (error) { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console - console.warn( - [ - 'Popover API is not supported in this browser, and the tooltip will not work correctly.', - 'Please include a popover polyfill for better browser support.', - ].join(' '), - { error }, - ); + console.warn('The HTML Popover API is required by Headless Tooltip and is not supported in this browser.', { + error, + }); } } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/anchorPositioningCapabilities.test.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/anchorPositioningCapabilities.test.ts new file mode 100644 index 00000000000000..69f97daf7d466c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/anchorPositioningCapabilities.test.ts @@ -0,0 +1,43 @@ +import { + setPositioningRuntimeOverrideForTests, + supportsNativeAnchorPositioning, +} from './anchorPositioningCapabilities'; + +const createTargetDocument = (unsupportedFeature?: string): Document => { + const css = { + supports: (property: string, value: string) => `${property}: ${value}` !== unsupportedFeature, + }; + const targetWindow = { CSS: css }; + + return { + defaultView: targetWindow, + } as unknown as Document; +}; + +describe('supportsNativeAnchorPositioning', () => { + afterEach(() => { + setPositioningRuntimeOverrideForTests(document, 'auto'); + }); + + it('supports internal native and fallback overrides', () => { + setPositioningRuntimeOverrideForTests(document, 'native'); + expect(supportsNativeAnchorPositioning(document)).toBe(true); + + setPositioningRuntimeOverrideForTests(document, 'fallback'); + expect(supportsNativeAnchorPositioning(document)).toBe(false); + }); + + it('accepts the complete native contract', () => { + expect(supportsNativeAnchorPositioning(createTargetDocument())).toBe(true); + }); + + it.each([ + ['anchor-name', '--fui-positioning-anchor'], + ['position-anchor', '--fui-positioning-anchor'], + ['position-area', 'block-end'], + ['position-try-fallbacks', 'flip-block'], + ['width', 'anchor-size(width)'], + ])('rejects partial support without %s', (property, value) => { + expect(supportsNativeAnchorPositioning(createTargetDocument(`${property}: ${value}`))).toBe(false); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/anchorPositioningCapabilities.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/anchorPositioningCapabilities.ts new file mode 100644 index 00000000000000..c7e50280568713 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/anchorPositioningCapabilities.ts @@ -0,0 +1,46 @@ +export type PositioningRuntimeOverride = 'auto' | 'native' | 'fallback'; + +const POSITIONING_RUNTIME_OVERRIDE_KEY = '__FUI_HEADLESS_POSITIONING_RUNTIME_MODE__'; + +type PositioningRuntimeWindow = Window & { + [POSITIONING_RUNTIME_OVERRIDE_KEY]?: PositioningRuntimeOverride; +}; + +const supports = (css: typeof CSS, property: string, value: string): boolean => css.supports(property, value); + +export function getPositioningRuntimeOverride(targetDocument: Document): PositioningRuntimeOverride { + return (targetDocument.defaultView as PositioningRuntimeWindow | null)?.[POSITIONING_RUNTIME_OVERRIDE_KEY] ?? 'auto'; +} + +export function setPositioningRuntimeOverrideForTests( + targetDocument: Document, + override: PositioningRuntimeOverride, +): void { + const targetWindow = targetDocument.defaultView as PositioningRuntimeWindow | null; + + if (targetWindow) { + targetWindow[POSITIONING_RUNTIME_OVERRIDE_KEY] = override; + } +} + +export function supportsNativeAnchorPositioning(targetDocument: Document): boolean { + const override = getPositioningRuntimeOverride(targetDocument); + if (override !== 'auto') { + return override === 'native'; + } + + const targetWindow = targetDocument.defaultView; + if (!targetWindow?.CSS || typeof targetWindow.CSS.supports !== 'function') { + return false; + } + + const css = targetWindow.CSS; + + return ( + supports(css, 'anchor-name', '--fui-positioning-anchor') && + supports(css, 'position-anchor', '--fui-positioning-anchor') && + supports(css, 'position-area', 'block-end') && + supports(css, 'position-try-fallbacks', 'flip-block') && + supports(css, 'width', 'anchor-size(width)') + ); +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningRuntime.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningRuntime.ts new file mode 100644 index 00000000000000..03edc12b4cb03a --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/fallbackPositioningRuntime.ts @@ -0,0 +1,11 @@ +import { createPositioningManager_unstable } from '@fluentui/react-positioning'; + +/** + * Positioning implementation loaded only when the target document cannot use + * the complete CSS Anchor Positioning contract required by Headless. + */ +export const fallbackPositioningRuntime = { + createPositioningManager: createPositioningManager_unstable, +}; + +export type FallbackPositioningRuntime = typeof fallbackPositioningRuntime; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts index f2d52d9a89aa0a..5aff1155d628da 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/index.ts @@ -1,6 +1,5 @@ -export { resolvePositioningShorthand } from '@fluentui/react-positioning'; export { usePositioning } from './usePositioning'; -export { getPlacementString } from './utils'; +export { getPlacementString, resolvePositioningShorthand } from './utils'; export { POSITIONS, ALIGNMENTS } from './constants'; export type { PositioningProps, PositioningReturn } from './types'; export type { diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/internalTypes.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/internalTypes.ts new file mode 100644 index 00000000000000..04ec626a504408 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/internalTypes.ts @@ -0,0 +1,7 @@ +import type * as React from 'react'; + +import type { PositioningReturn } from './types'; + +export type PositioningReturnInternal = PositioningReturn & { + arrowRef: React.RefCallback; +}; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/positioningRuntime.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/positioningRuntime.test.tsx new file mode 100644 index 00000000000000..23424916498bc6 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/positioningRuntime.test.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; +import { act, render } from '@testing-library/react'; + +import type { FallbackPositioningRuntime } from './fallbackPositioningRuntime'; +import { setPositioningRuntimeOverrideForTests } from './anchorPositioningCapabilities'; +import { + getPositioningRuntimeSnapshot, + resetPositioningRuntimeForTests, + setFallbackPositioningLoaderForTests, + usePositioningRuntime, +} from './positioningRuntime'; + +const fallbackRuntime = { + createPositioningManager: jest.fn(), +} as unknown as FallbackPositioningRuntime; + +const createTargetDocument = (supportsAnchors: boolean): Document => + ({ + defaultView: { + CSS: { + supports: () => supportsAnchors, + }, + }, + } as unknown as Document); + +describe('positioningRuntime', () => { + beforeEach(() => { + resetPositioningRuntimeForTests(); + setPositioningRuntimeOverrideForTests(document, 'fallback'); + }); + + afterEach(() => { + resetPositioningRuntimeForTests(); + setPositioningRuntimeOverrideForTests(document, 'auto'); + }); + + it('hydrates with the SSR/native backend before settling on native positioning in auto mode', () => { + const targetDocument = createTargetDocument(true); + const loader = jest.fn(); + const modes: string[] = []; + setFallbackPositioningLoaderForTests(loader); + + const TestPositioning = () => { + modes.push(usePositioningRuntime(targetDocument).mode); + return null; + }; + + render(); + + expect(modes[0]).toBe('ssr'); + expect(modes[modes.length - 1]).toBe('native'); + expect(loader).not.toHaveBeenCalled(); + }); + + it('hydrates with the SSR/native backend before loading fallback positioning in auto mode', () => { + const targetDocument = createTargetDocument(false); + const loader = jest.fn( + () => + new Promise(() => { + // Deliberately left pending to verify the loading snapshot. + }), + ); + const modes: string[] = []; + setFallbackPositioningLoaderForTests(loader); + + const TestPositioning = () => { + modes.push(usePositioningRuntime(targetDocument).mode); + return null; + }; + + render(); + + expect(modes[0]).toBe('ssr'); + expect(modes[modes.length - 1]).toBe('fallback-loading'); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('loads one shared fallback runtime when the first positioned component mounts', async () => { + let resolveRuntime: ((module: typeof import('./fallbackPositioningRuntime')) => void) | undefined; + const loader = jest.fn( + () => + new Promise(resolve => { + resolveRuntime = resolve; + }), + ); + setFallbackPositioningLoaderForTests(loader); + + const TestPositioning = () => { + usePositioningRuntime(document); + return null; + }; + + render( + <> + + + , + ); + + expect(loader).toHaveBeenCalledTimes(1); + expect(getPositioningRuntimeSnapshot(document).mode).toBe('fallback-loading'); + + await act(async () => { + resolveRuntime?.({ + fallbackPositioningRuntime: fallbackRuntime, + } as typeof import('./fallbackPositioningRuntime')); + await Promise.resolve(); + }); + + expect(getPositioningRuntimeSnapshot(document)).toEqual({ + mode: 'fallback-ready', + runtime: fallbackRuntime, + }); + }); + + it('does not load fallback code in native mode', () => { + setPositioningRuntimeOverrideForTests(document, 'native'); + const loader = jest.fn(); + setFallbackPositioningLoaderForTests(loader); + + const TestPositioning = () => { + usePositioningRuntime(document); + return null; + }; + + render(); + + expect(loader).not.toHaveBeenCalled(); + expect(getPositioningRuntimeSnapshot(document).mode).toBe('native'); + }); + + it('does not load fallback code during server rendering', () => { + const loader = jest.fn(); + setFallbackPositioningLoaderForTests(loader); + + expect(getPositioningRuntimeSnapshot(undefined)).toEqual({ mode: 'ssr' }); + expect(loader).not.toHaveBeenCalled(); + }); + + it('retains a fallback loading error', async () => { + const error = new Error('chunk failed'); + setFallbackPositioningLoaderForTests(() => Promise.reject(error)); + + const TestPositioning = () => { + usePositioningRuntime(document); + return null; + }; + render(); + + await act(async () => { + await Promise.resolve(); + }); + + expect(getPositioningRuntimeSnapshot(document)).toEqual({ + error, + mode: 'fallback-error', + }); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/positioningRuntime.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/positioningRuntime.ts new file mode 100644 index 00000000000000..990081d165b8e9 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/positioningRuntime.ts @@ -0,0 +1,120 @@ +'use client'; + +import * as React from 'react'; +import { useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; + +import type { FallbackPositioningRuntime } from './fallbackPositioningRuntime'; +import { getPositioningRuntimeOverride, supportsNativeAnchorPositioning } from './anchorPositioningCapabilities'; + +export type PositioningRuntimeSnapshot = + | { mode: 'ssr' } + | { mode: 'native' } + | { mode: 'fallback-idle' } + | { mode: 'fallback-loading' } + | { mode: 'fallback-ready'; runtime: FallbackPositioningRuntime } + | { mode: 'fallback-error'; error: unknown }; + +type PositioningRuntimeRecord = { + listeners: Set<() => void>; + snapshot: PositioningRuntimeSnapshot; +}; + +type FallbackPositioningModule = typeof import('./fallbackPositioningRuntime.js'); +type FallbackPositioningLoader = () => Promise; + +const defaultFallbackLoader: FallbackPositioningLoader = () => import('./fallbackPositioningRuntime.js'); + +let fallbackLoader = defaultFallbackLoader; +let fallbackRuntimePromise: Promise | undefined; +let runtimeRecords = new WeakMap(); + +const emit = (record: PositioningRuntimeRecord): void => { + for (const listener of record.listeners) { + listener(); + } +}; + +const getOrCreateRecord = (targetDocument: Document): PositioningRuntimeRecord => { + const existing = runtimeRecords.get(targetDocument); + if (existing) { + return existing; + } + + const record: PositioningRuntimeRecord = { + listeners: new Set(), + snapshot: supportsNativeAnchorPositioning(targetDocument) ? { mode: 'native' } : { mode: 'fallback-idle' }, + }; + runtimeRecords.set(targetDocument, record); + + return record; +}; + +const loadFallbackRuntime = (): Promise => { + fallbackRuntimePromise ??= fallbackLoader().then(module => module.fallbackPositioningRuntime); + return fallbackRuntimePromise; +}; + +const startFallbackRuntime = (record: PositioningRuntimeRecord): void => { + if (record.snapshot.mode !== 'fallback-idle') { + return; + } + + record.snapshot = { mode: 'fallback-loading' }; + emit(record); + + loadFallbackRuntime().then( + runtime => { + record.snapshot = { mode: 'fallback-ready', runtime }; + emit(record); + }, + error => { + record.snapshot = { mode: 'fallback-error', error }; + emit(record); + }, + ); +}; + +export function getPositioningRuntimeSnapshot(targetDocument: Document | undefined): PositioningRuntimeSnapshot { + return targetDocument ? getOrCreateRecord(targetDocument).snapshot : { mode: 'ssr' }; +} + +export function usePositioningRuntime(targetDocument: Document | undefined): PositioningRuntimeSnapshot { + const override = targetDocument ? getPositioningRuntimeOverride(targetDocument) : 'auto'; + const [currentRecord, setCurrentRecord] = React.useState< + { record: PositioningRuntimeRecord; targetDocument: Document } | undefined + >(() => + targetDocument && override !== 'auto' ? { record: getOrCreateRecord(targetDocument), targetDocument } : undefined, + ); + const [, forceUpdate] = React.useReducer(value => value + 1, 0); + const record = currentRecord && currentRecord.targetDocument === targetDocument ? currentRecord.record : undefined; + + useIsomorphicLayoutEffect(() => { + if (!targetDocument) { + setCurrentRecord(undefined); + return; + } + + const nextRecord = getOrCreateRecord(targetDocument); + const listener = () => forceUpdate(); + nextRecord.listeners.add(listener); + setCurrentRecord({ record: nextRecord, targetDocument }); + startFallbackRuntime(nextRecord); + + return () => { + nextRecord.listeners.delete(listener); + }; + }, [targetDocument]); + + return record?.snapshot ?? { mode: 'ssr' }; +} + +export function resetPositioningRuntimeForTests(): void { + fallbackLoader = defaultFallbackLoader; + fallbackRuntimePromise = undefined; + runtimeRecords = new WeakMap(); +} + +export function setFallbackPositioningLoaderForTests(loader: FallbackPositioningLoader): void { + fallbackLoader = loader; + fallbackRuntimePromise = undefined; +} diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx index cadfd8afc62d67..ab2d988ba4b854 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.test.tsx @@ -1,8 +1,12 @@ import * as React from 'react'; import { act, render } from '@testing-library/react'; import { usePositioning } from './usePositioning'; -import { getPlacementString } from './utils/placement'; +import { getDefaultFallbackPositions, getLogicalPlacement, getPlacementString } from './utils/placement'; import type { PositioningProps, PositioningReturn } from './types'; +import type { PositioningReturnInternal } from './internalTypes'; +import type { FallbackPositioningRuntime } from './fallbackPositioningRuntime'; +import { setPositioningRuntimeOverrideForTests } from './anchorPositioningCapabilities'; +import { resetPositioningRuntimeForTests, setFallbackPositioningLoaderForTests } from './positioningRuntime'; function mountHook(options: PositioningProps = {}) { const resultRef = React.createRef<{ current: PositioningReturn }>(); @@ -245,3 +249,127 @@ describe('getPlacementString', () => { expect(getPlacementString('after', 'end')).toBe('after-bottom'); }); }); + +describe('native fallback placement mapping', () => { + it('maps default block and inline flip tactics in order', () => { + expect(getDefaultFallbackPositions('above', 'start')).toEqual(['below-start', 'above-end', 'below-end']); + expect(getDefaultFallbackPositions('before', 'start')).toEqual(['before-bottom', 'after-top', 'after-bottom']); + }); + + it('omits duplicate no-op flips for centered placements', () => { + expect(getDefaultFallbackPositions('above', 'center')).toEqual(['below']); + expect(getDefaultFallbackPositions('before', 'center')).toEqual(['after']); + }); + + it('maps physical placement back to logical placement including RTL', () => { + expect(getLogicalPlacement('top-start', 'ltr')).toBe('above-start'); + expect(getLogicalPlacement('right-end', 'ltr')).toBe('after-bottom'); + expect(getLogicalPlacement('right-end', 'rtl')).toBe('before-bottom'); + expect(getLogicalPlacement('left-start', 'rtl')).toBe('after-top'); + }); +}); + +describe('fallback positioning backend', () => { + beforeEach(() => { + resetPositioningRuntimeForTests(); + setPositioningRuntimeOverrideForTests(document, 'fallback'); + }); + + afterEach(() => { + resetPositioningRuntimeForTests(); + setPositioningRuntimeOverrideForTests(document, 'native'); + }); + + it('loads the manager with native-normalized behavior and preserves the native Popover element', async () => { + const manager = { + dispose: jest.fn(), + updatePosition: jest.fn(), + }; + type ManagerOptions = Parameters[0]; + const createPositioningManager = jest.fn((_options: ManagerOptions) => manager); + const fallbackRuntime = { + createPositioningManager, + } as unknown as FallbackPositioningRuntime; + setFallbackPositioningLoaderForTests(() => + Promise.resolve({ + fallbackPositioningRuntime: fallbackRuntime, + } as typeof import('./fallbackPositioningRuntime')), + ); + + const positioningRef = React.createRef<{ + setTarget: (target: HTMLElement | null) => void; + updatePosition: () => void; + }>(); + let result: PositioningReturnInternal | undefined; + const Capture = () => { + result = usePositioning({ + align: 'start', + matchTargetSize: 'width', + position: 'above', + positioningRef: positioningRef as PositioningProps['positioningRef'], + }) as PositioningReturnInternal; + return null; + }; + + const { unmount } = render(); + const target = document.createElement('button'); + const container = document.createElement('dialog'); + const arrow = document.createElement('div'); + container.setAttribute('popover', 'auto'); + container.style.width = '320px'; + + act(() => { + result?.targetRef(target); + result?.containerRef(container); + result?.arrowRef(arrow); + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container).toHaveAttribute('popover', 'auto'); + expect(createPositioningManager).toHaveBeenCalledWith( + expect.objectContaining({ + align: 'start', + arrow, + container, + fallbackPositions: ['below-start', 'above-end', 'below-end'], + matchTargetSize: 'width', + position: 'above', + strategy: 'fixed', + target, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableShift: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_flipFallbackStrategy: 'initialPlacement', + useTransform: false, + }), + ); + + const managerOptions = createPositioningManager.mock.calls[0][0]; + managerOptions.onPositioningEnd?.( + new CustomEvent('fui-positioningend', { + detail: { + escaped: false, + placement: 'right-end', + referenceHidden: false, + }, + }), + ); + + expect(container).toHaveAttribute('data-placement', 'after-bottom'); + expect(container).toHaveAttribute('data-positioning-runtime', 'fallback'); + expect(container.style.visibility).toBe(''); + expect(container.style.width).toBe(''); + + positioningRef.current?.updatePosition(); + expect(manager.updatePosition).toHaveBeenCalledTimes(1); + + unmount(); + expect(manager.dispose).toHaveBeenCalledTimes(1); + expect(container).toHaveAttribute('popover', 'auto'); + expect(container.style.width).toBe('320px'); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts index e577ae1f96993f..62f06c0bf8ea88 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/usePositioning.ts @@ -4,22 +4,96 @@ import * as React from 'react'; import { useId, useIsomorphicLayoutEffect } from '@fluentui/react-utilities'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import type { + PositionManager, PositioningImperativeRef, PositioningShorthandValue, PositioningVirtualElement, } from '@fluentui/react-positioning'; + import type { PositioningProps, PositioningReturn } from './types'; +import type { PositioningReturnInternal } from './internalTypes'; import { POSITIONS, ALIGNMENTS, POSITION_AREA_MAP } from './constants'; -import { getPlacementString, normalizeAlign } from './utils/placement'; +import { + getDefaultFallbackPositions, + getLogicalPlacement, + getPlacementString, + normalizeAlign, +} from './utils/placement'; import { applyOffset, getCoverSelfAlignment, resolveElementRef, resolveOffset, shorthandToPositionArea } from './utils'; import { usePlacementObserver } from './usePlacementObserver'; +import { usePositioningRuntime } from './positioningRuntime'; export type TargetElement = HTMLElement | PositioningVirtualElement; const DEFAULT_FLIP = ['flip-block', 'flip-inline', 'flip-block flip-inline']; - const EMPTY_FALLBACK_POSITIONS: PositioningShorthandValue[] = []; +const NATIVE_STYLE_PROPERTIES = [ + 'position', + 'inset', + 'margin', + 'margin-block-start', + 'margin-block-end', + 'margin-inline-start', + 'margin-inline-end', + 'width', + 'position-anchor', + 'position-area', + 'position-try-fallbacks', + 'place-self', + 'align-self', + 'justify-self', +] as const; + +const FALLBACK_STYLE_PROPERTIES = [ + 'position', + 'inset', + 'margin', + 'left', + 'top', + 'transform', + 'visibility', + 'width', + '--fui-match-target-size', +] as const; + +const FALLBACK_ATTRIBUTES = [ + 'data-placement', + 'data-positioning-runtime', + 'data-popper-placement', + 'data-popper-is-intersecting', + 'data-popper-escaped', + 'data-popper-reference-hidden', +] as const; + +const captureStyleProperties = (element: HTMLElement, properties: readonly string[]): (() => void) => { + const previous = properties.map(property => [property, element.style.getPropertyValue(property)] as const); + + return () => { + for (const [property, value] of previous) { + if (value) { + element.style.setProperty(property, value); + } else { + element.style.removeProperty(property); + } + } + }; +}; + +const captureAttributes = (element: HTMLElement, attributes: readonly string[]): (() => void) => { + const previous = attributes.map(attribute => [attribute, element.getAttribute(attribute)] as const); + + return () => { + for (const [attribute, value] of previous) { + if (value === null) { + element.removeAttribute(attribute); + } else { + element.setAttribute(attribute, value); + } + } + }; +}; + /** * Reads the current anchor-name property from an element and parses it into an array of names. * Handles comma-separated values and trimming. @@ -47,7 +121,6 @@ export function usePositioning(options: PositioningProps): PositioningReturn { } = options; const align = normalizeAlign(alignInput); - const { mainAxis, crossAxis } = resolveOffset(offset); const coverAlignment = React.useMemo( () => (coverTarget ? getCoverSelfAlignment(position, align) : null), @@ -56,136 +129,268 @@ export function usePositioning(options: PositioningProps): PositioningReturn { const [triggerEl, setTriggerEl] = React.useState(null); const [containerEl, setContainerEl] = React.useState(null); - const [imperativeTarget, setImperativeTarget] = React.useState(null); - const effectiveTarget = imperativeTarget ?? resolveElementRef(customTarget) ?? triggerEl; + const [arrowEl, setArrowEl] = React.useState(null); + const [imperativeTarget, setImperativeTarget] = React.useState(null); + const fallbackManagerRef = React.useRef(null); + const effectiveTarget = imperativeTarget ?? customTarget ?? triggerEl; + const nativeTarget = resolveElementRef(effectiveTarget); const anchorName = `--${useId('popover-anchor-')}`; const positionArea = POSITION_AREA_MAP[position][align]; const placement = getPlacementString(position, align); - const { targetDocument } = useFluent(); + const { dir, targetDocument } = useFluent(); + const runtime = usePositioningRuntime(targetDocument); + const useNativePositioning = runtime.mode === 'ssr' || runtime.mode === 'native'; + + if (runtime.mode === 'fallback-error') { + throw runtime.error; + } const fallbackAreas = React.useMemo(() => fallbackPositions.map(shorthandToPositionArea), [fallbackPositions]); + const fallbackPlacements = React.useMemo( + () => (fallbackPositions.length > 0 ? fallbackPositions : getDefaultFallbackPositions(position, align)), + [align, fallbackPositions, position], + ); - const requestPlacementUpdate = usePlacementObserver(containerEl, effectiveTarget, targetDocument, coverTarget); + const requestPlacementUpdate = usePlacementObserver( + containerEl, + nativeTarget, + targetDocument, + coverTarget || !useNativePositioning, + ); React.useImperativeHandle( positioningRef, () => ({ - setTarget: (el: TargetElement | null) => { - setImperativeTarget(resolveElementRef(el)); + setTarget: (target: TargetElement | null) => { + setImperativeTarget(target); + }, + updatePosition: () => { + if (runtime.mode === 'fallback-ready') { + fallbackManagerRef.current?.updatePosition(); + } else { + requestPlacementUpdate(); + } }, - updatePosition: requestPlacementUpdate, }), - [requestPlacementUpdate], + [requestPlacementUpdate, runtime.mode], ); useIsomorphicLayoutEffect(() => { - if (!effectiveTarget) { + if (!useNativePositioning || !nativeTarget) { return; } - // `anchor-name` is a comma-separated list. Append this instance's name - // instead of overwriting so that multiple positioned popovers can share a - // single trigger (e.g. a Tooltip on hover and a Menu on click attached to - // the same button) without clobbering each other's anchor. On cleanup we - // remove only our own name, preserving any others still in use. - if (anchorName) { - const names = readAnchorNames(effectiveTarget); - if (!names.includes(anchorName)) { - effectiveTarget.style.setProperty('anchor-name', [...names, anchorName].join(', ')); - } + const names = readAnchorNames(nativeTarget); + if (!names.includes(anchorName)) { + nativeTarget.style.setProperty('anchor-name', [...names, anchorName].join(', ')); } return () => { - if (anchorName) { - const remaining = readAnchorNames(effectiveTarget).filter(name => name !== anchorName); - if (remaining.length > 0) { - effectiveTarget.style.setProperty('anchor-name', remaining.join(', ')); - } else { - effectiveTarget.style.removeProperty('anchor-name'); - } + const remaining = readAnchorNames(nativeTarget).filter(name => name !== anchorName); + if (remaining.length > 0) { + nativeTarget.style.setProperty('anchor-name', remaining.join(', ')); + } else { + nativeTarget.style.removeProperty('anchor-name'); } }; - }, [effectiveTarget, anchorName]); + }, [anchorName, nativeTarget, useNativePositioning]); - const targetRef: React.RefCallback = React.useCallback(node => { - setTriggerEl(node); - }, []); - - const containerRef: React.RefCallback = React.useCallback( - node => { - setContainerEl(node); - - if (!node) { - return; - } + useIsomorphicLayoutEffect(() => { + if (!useNativePositioning || !containerEl) { + return; + } - node.style.setProperty('position', strategy); - node.style.setProperty('inset', 'auto'); - node.style.setProperty('margin', '0'); + const restoreStyles = captureStyleProperties(containerEl, NATIVE_STYLE_PROPERTIES); + const previousPlacement = containerEl.getAttribute('data-placement'); + const previousRuntime = containerEl.getAttribute('data-positioning-runtime'); - applyOffset(node, position, mainAxis, crossAxis); + containerEl.style.setProperty('position', strategy); + containerEl.style.setProperty('inset', 'auto'); + containerEl.style.setProperty('margin', '0'); - if (matchTargetSize === 'width') { - node.style.setProperty('width', 'anchor-size(width)'); - } else { - node.style.removeProperty('width'); - } + applyOffset(containerEl, position, mainAxis, crossAxis); - node.style.setProperty('position-anchor', anchorName); - node.setAttribute('data-placement', placement); + if (matchTargetSize === 'width') { + containerEl.style.setProperty('width', 'anchor-size(width)'); + } else { + containerEl.style.removeProperty('width'); + } - if (coverAlignment) { - node.style.setProperty('position-area', 'center'); - node.style.setProperty('align-self', coverAlignment.alignSelf); - node.style.setProperty('justify-self', coverAlignment.justifySelf); - node.style.removeProperty('position-try-fallbacks'); - return; - } + containerEl.style.setProperty('position-anchor', anchorName); + containerEl.setAttribute('data-placement', placement); + containerEl.setAttribute('data-positioning-runtime', 'native'); - node.style.setProperty('position-area', positionArea); + if (coverAlignment) { + containerEl.style.setProperty('position-area', 'center'); + containerEl.style.setProperty('align-self', coverAlignment.alignSelf); + containerEl.style.setProperty('justify-self', coverAlignment.justifySelf); + containerEl.style.removeProperty('position-try-fallbacks'); + } else { + containerEl.style.setProperty('position-area', positionArea); - /* - * Workaround for https://crbug.com/438334710: Chromium (<=130-ish) doesn't - apply the implicit `anchor-center` self-alignment that the spec defines - for single-keyword `position-area` values (`block-start`, `block-end`, - ` inline-start`, `inline-end`) or `span-all`. - */ if (align === ALIGNMENTS.center) { - node.style.setProperty('place-self', 'anchor-center'); + containerEl.style.setProperty('place-self', 'anchor-center'); } else { - node.style.removeProperty('place-self'); - node.style.removeProperty('align-self'); - node.style.removeProperty('justify-self'); + containerEl.style.removeProperty('place-self'); + containerEl.style.removeProperty('align-self'); + containerEl.style.removeProperty('justify-self'); } if (pinned) { - node.style.removeProperty('position-try-fallbacks'); - return; + containerEl.style.removeProperty('position-try-fallbacks'); + } else if (fallbackAreas.length > 0) { + containerEl.style.setProperty('position-try-fallbacks', fallbackAreas.join(', ')); + } else { + containerEl.style.setProperty('position-try-fallbacks', DEFAULT_FLIP.join(', ')); + } + } + + return () => { + restoreStyles(); + + if (previousPlacement === null) { + containerEl.removeAttribute('data-placement'); + } else { + containerEl.setAttribute('data-placement', previousPlacement); } - if (fallbackAreas.length > 0) { - node.style.setProperty('position-try-fallbacks', fallbackAreas.join(', ')); + if (previousRuntime === null) { + containerEl.removeAttribute('data-positioning-runtime'); } else { - node.style.setProperty('position-try-fallbacks', DEFAULT_FLIP.join(', ')); + containerEl.setAttribute('data-positioning-runtime', previousRuntime); } - }, - [ - anchorName, - positionArea, - placement, - fallbackAreas, - pinned, + }; + }, [ + align, + anchorName, + containerEl, + coverAlignment, + crossAxis, + fallbackAreas, + mainAxis, + matchTargetSize, + pinned, + placement, + position, + positionArea, + strategy, + useNativePositioning, + ]); + + useIsomorphicLayoutEffect(() => { + if ((runtime.mode !== 'fallback-idle' && runtime.mode !== 'fallback-loading') || !containerEl) { + return; + } + + const restoreStyles = captureStyleProperties(containerEl, FALLBACK_STYLE_PROPERTIES); + const restoreAttributes = captureAttributes(containerEl, FALLBACK_ATTRIBUTES); + + containerEl.style.setProperty('position', strategy); + containerEl.style.setProperty('inset', 'auto'); + containerEl.style.setProperty('margin', '0'); + containerEl.style.setProperty('visibility', 'hidden'); + containerEl.setAttribute('data-placement', placement); + containerEl.setAttribute('data-positioning-runtime', 'loading'); + + return () => { + restoreStyles(); + restoreAttributes(); + }; + }, [containerEl, placement, runtime.mode, strategy]); + + useIsomorphicLayoutEffect(() => { + if (runtime.mode !== 'fallback-ready' || !containerEl || !effectiveTarget) { + return; + } + + const restoreStyles = captureStyleProperties(containerEl, FALLBACK_STYLE_PROPERTIES); + const restoreAttributes = captureAttributes(containerEl, FALLBACK_ATTRIBUTES); + const restoreArrowStyles = arrowEl ? captureStyleProperties(arrowEl, ['left', 'top']) : undefined; + const previousVisibility = containerEl.style.visibility; + + containerEl.style.setProperty('position', strategy); + containerEl.style.setProperty('inset', 'auto'); + containerEl.style.setProperty('margin', '0'); + containerEl.style.setProperty('visibility', 'hidden'); + containerEl.setAttribute('data-placement', placement); + containerEl.setAttribute('data-positioning-runtime', 'fallback'); + + if (matchTargetSize === 'width') { + containerEl.style.removeProperty('width'); + } + + const manager = runtime.runtime.createPositioningManager({ + align: alignInput, + arrow: arrowEl, + container: containerEl, + coverTarget, + dir, + fallbackPositions: fallbackPlacements, + matchTargetSize, + offset: { mainAxis, crossAxis }, + onPositioningEnd: event => { + const resolvedPlacement = getLogicalPlacement(event.detail.placement, dir); + if (resolvedPlacement) { + containerEl.setAttribute('data-placement', resolvedPlacement); + } + containerEl.style.visibility = previousVisibility; + }, + pinned: pinned || coverTarget, position, - align, - mainAxis, - crossAxis, - coverAlignment, strategy, - matchTargetSize, - ], - ); + target: effectiveTarget, + targetDocument, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableShift: true, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_flipFallbackStrategy: 'initialPlacement', + useTransform: false, + }); + + fallbackManagerRef.current = manager; + + return () => { + manager.dispose(); + restoreArrowStyles?.(); + restoreStyles(); + restoreAttributes(); + + if (fallbackManagerRef.current === manager) { + fallbackManagerRef.current = null; + } + }; + }, [ + alignInput, + arrowEl, + containerEl, + coverTarget, + crossAxis, + dir, + effectiveTarget, + fallbackPlacements, + mainAxis, + matchTargetSize, + pinned, + placement, + position, + runtime, + strategy, + targetDocument, + ]); + + const targetRef: React.RefCallback = React.useCallback(node => { + setTriggerEl(node); + }, []); + + const containerRef: React.RefCallback = React.useCallback(node => { + setContainerEl(node); + }, []); + + const arrowRef: React.RefCallback = React.useCallback(node => { + setArrowEl(node); + }, []); - return { targetRef, containerRef }; + return { targetRef, containerRef, arrowRef } as PositioningReturnInternal; } diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts index 8506fa430c6121..50525994b74969 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/index.ts @@ -2,5 +2,12 @@ export { computePosition } from './computePosition'; export type { ComputePositionConfig, ComputePositionReturn } from './computePosition'; export { debounce } from './debounce'; export { applyOffset, resolveOffset } from './offset'; -export { getCoverSelfAlignment, getPlacementString, shorthandToPositionArea } from './placement'; +export { + getCoverSelfAlignment, + getDefaultFallbackPositions, + getLogicalPlacement, + getPlacementString, + shorthandToPositionArea, +} from './placement'; export { resolveElementRef } from './resolveElementRef'; +export { resolvePositioningShorthand } from './resolvePositioningShorthand'; diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts index 812e5919fd1238..1e4f0f1ac29265 100644 --- a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/placement.ts @@ -1,7 +1,7 @@ -import { resolvePositioningShorthand } from '@fluentui/react-positioning'; import type { Alignment, Position, PositioningShorthandValue } from '@fluentui/react-positioning'; import type { LogicalAlignment } from '../types'; import { ALIGNMENTS, POSITIONS, POSITION_AREA_MAP } from '../constants'; +import { resolvePositioningShorthand } from './resolvePositioningShorthand'; const ALIGN_ALIASES: Record = { top: ALIGNMENTS.start, @@ -41,6 +41,82 @@ export function shorthandToPositionArea(shorthand: PositioningShorthandValue): s return POSITION_AREA_MAP[position][normalizeAlign(align)]; } +const flipBlock = (position: Position, align: LogicalAlignment): { position: Position; align: LogicalAlignment } => { + if (position === POSITIONS.above) { + return { position: POSITIONS.below, align }; + } + + if (position === POSITIONS.below) { + return { position: POSITIONS.above, align }; + } + + return { + position, + align: align === ALIGNMENTS.start ? ALIGNMENTS.end : align === ALIGNMENTS.end ? ALIGNMENTS.start : align, + }; +}; + +const flipInline = (position: Position, align: LogicalAlignment): { position: Position; align: LogicalAlignment } => { + if (position === POSITIONS.before) { + return { position: POSITIONS.after, align }; + } + + if (position === POSITIONS.after) { + return { position: POSITIONS.before, align }; + } + + return { + position, + align: align === ALIGNMENTS.start ? ALIGNMENTS.end : align === ALIGNMENTS.end ? ALIGNMENTS.start : align, + }; +}; + +export function getDefaultFallbackPositions(position: Position, align: LogicalAlignment): PositioningShorthandValue[] { + const primary = getPlacementString(position, align); + const block = flipBlock(position, align); + const inline = flipInline(position, align); + const both = flipInline(block.position, block.align); + const seen = new Set([primary]); + + return [block, inline, both] + .map(candidate => getPlacementString(candidate.position, candidate.align)) + .filter(candidate => { + if (seen.has(candidate)) { + return false; + } + + seen.add(candidate); + return true; + }); +} + +export function getLogicalPlacement(placement: string, dir: 'ltr' | 'rtl'): PositioningShorthandValue | undefined { + const [side, alignment] = placement.split('-'); + let position: Position; + + if (side === 'top') { + position = POSITIONS.above; + } else if (side === 'bottom') { + position = POSITIONS.below; + } else if (side === 'left') { + position = dir === 'rtl' ? POSITIONS.after : POSITIONS.before; + } else if (side === 'right') { + position = dir === 'rtl' ? POSITIONS.before : POSITIONS.after; + } else { + return undefined; + } + + if (!alignment) { + return position; + } + + if (position === POSITIONS.before || position === POSITIONS.after) { + return `${position}-${alignment === 'start' ? 'top' : 'bottom'}`; + } + + return `${position}-${alignment === 'start' ? 'start' : 'end'}`; +} + export function getCoverSelfAlignment( position: Position, align: LogicalAlignment, diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.test.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.test.ts new file mode 100644 index 00000000000000..06ed26a6cdc89f --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.test.ts @@ -0,0 +1,21 @@ +import { resolvePositioningShorthand } from './resolvePositioningShorthand'; + +describe('resolvePositioningShorthand', () => { + it('resolves string shorthands', () => { + expect(resolvePositioningShorthand('below-start')).toEqual({ + align: 'start', + position: 'below', + }); + }); + + it('returns object shorthands unchanged', () => { + const positioning = { align: 'top', position: 'after' } as const; + + expect(resolvePositioningShorthand(positioning)).toBe(positioning); + }); + + it('resolves nullish values to an empty object', () => { + expect(resolvePositioningShorthand(undefined)).toEqual({}); + expect(resolvePositioningShorthand(null)).toEqual({}); + }); +}); diff --git a/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.ts b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.ts new file mode 100644 index 00000000000000..046c12d96da143 --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/src/hooks/usePositioning/utils/resolvePositioningShorthand.ts @@ -0,0 +1,26 @@ +import type { PositioningProps, PositioningShorthand, PositioningShorthandValue } from '@fluentui/react-positioning'; + +const shorthandLookup: Record> = { + above: { position: 'above', align: 'center' }, + 'above-start': { position: 'above', align: 'start' }, + 'above-end': { position: 'above', align: 'end' }, + below: { position: 'below', align: 'center' }, + 'below-start': { position: 'below', align: 'start' }, + 'below-end': { position: 'below', align: 'end' }, + before: { position: 'before', align: 'center' }, + 'before-top': { position: 'before', align: 'top' }, + 'before-bottom': { position: 'before', align: 'bottom' }, + after: { position: 'after', align: 'center' }, + 'after-top': { position: 'after', align: 'top' }, + 'after-bottom': { position: 'after', align: 'bottom' }, +}; + +export function resolvePositioningShorthand( + shorthand: PositioningShorthand | null | undefined, +): Readonly { + if (shorthand === null || shorthand === undefined) { + return {}; + } + + return typeof shorthand === 'string' ? shorthandLookup[shorthand] : shorthand; +} diff --git a/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts b/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts index 6b5d52f6861a0e..d415317967fd2a 100644 --- a/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts +++ b/packages/react-components/react-headless-components-preview/stories/src/shared/browserSupportNotice.ts @@ -3,7 +3,7 @@ type ConceptKey = 'popover' | 'dialog' | 'anchor-positioning' | 'focusgroup'; const FEATURE_LABELS: Record = { popover: 'Popover API', dialog: 'Native element', - 'anchor-positioning': 'CSS anchor positioning', + 'anchor-positioning': 'CSS anchor positioning (with a lazy JavaScript positioning fallback)', focusgroup: 'Focus group', }; diff --git a/packages/react-components/react-positioning/library/etc/react-positioning.api.md b/packages/react-components/react-positioning/library/etc/react-positioning.api.md index 9a2e65951c1e13..bcb858fef78d28 100644 --- a/packages/react-components/react-positioning/library/etc/react-positioning.api.md +++ b/packages/react-components/react-positioning/library/etc/react-positioning.api.md @@ -32,6 +32,24 @@ export type CreateArrowStylesOptions = { borderColor?: GriffelStyle['borderBottomColor']; }; +// @internal (undocumented) +export function createPositioningManager_unstable(options: CreatePositioningManagerOptions): PositionManager; + +// @internal (undocumented) +export interface CreatePositioningManagerOptions extends PositioningProps { + arrow?: HTMLElement | null; + container: HTMLElement | null; + dir?: 'ltr' | 'rtl'; + enabled?: boolean; + // @deprecated + positionFixed?: boolean; + target: TargetElement | null; + targetDocument?: Document; + unstable_disableShift?: boolean; + unstable_disableTether?: boolean | 'all'; + unstable_flipFallbackStrategy?: 'bestFit' | 'initialPlacement'; +} + // @public @deprecated export function createSlideStyles(mainAxis: number): GriffelStyle; @@ -130,6 +148,14 @@ export type PositioningVirtualElement = { contextElement?: Element; }; +// @internal (undocumented) +export interface PositionManager { + // (undocumented) + dispose: () => void; + // (undocumented) + updatePosition: () => void; +} + // @public (undocumented) export function resolvePositioningShorthand(shorthand: PositioningShorthand | undefined | null): Readonly; diff --git a/packages/react-components/react-positioning/library/src/createPositioningManager_unstable.test.ts b/packages/react-components/react-positioning/library/src/createPositioningManager_unstable.test.ts new file mode 100644 index 00000000000000..7713a1b86fe953 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/createPositioningManager_unstable.test.ts @@ -0,0 +1,149 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { POSITIONING_END_EVENT } from './constants'; +import { createPositionManager } from './createPositionManager'; +import { createPositioningManager_unstable } from './createPositioningManager_unstable'; +import { resolvePositioningOptions } from './resolvePositioningOptions'; +import type { OnPositioningEndEventDetail, PositionManager } from './types'; + +jest.mock('./createPositionManager', () => ({ + createPositionManager: jest.fn(), +})); + +jest.mock('./resolvePositioningOptions', () => ({ + resolvePositioningOptions: jest.fn(), +})); + +const createPositionManagerMock = createPositionManager as jest.MockedFunction; +const resolvePositioningOptionsMock = resolvePositioningOptions as jest.MockedFunction< + typeof resolvePositioningOptions +>; + +function createResolvedManager(): PositionManager { + return { + dispose: jest.fn(), + updatePosition: jest.fn(), + }; +} + +function createPositioningEndEvent(): CustomEvent { + return new CustomEvent(POSITIONING_END_EVENT, { + detail: { + placement: 'bottom', + escaped: false, + referenceHidden: false, + }, + }); +} + +describe('createPositioningManager_unstable', () => { + let resolvedManager: PositionManager; + + beforeEach(() => { + resolvedManager = createResolvedManager(); + + createPositionManagerMock.mockReturnValue(resolvedManager); + resolvePositioningOptionsMock.mockReturnValue({ + placement: 'bottom', + middleware: [], + strategy: 'absolute', + disableUpdateOnResize: true, + useTransform: false, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('creates a manager from resolved options and wires positioning end callbacks', () => { + const container = document.createElement('div'); + const target = document.createElement('button'); + const arrow = document.createElement('div'); + const onPositioningEnd = jest.fn(); + + const manager = createPositioningManager_unstable({ + arrow, + container, + dir: 'rtl', + onPositioningEnd, + position: 'above', + positionFixed: true, + target, + targetDocument: document, + unstable_disableShift: true, + unstable_flipFallbackStrategy: 'initialPlacement', + }); + + expect(resolvePositioningOptionsMock).toHaveBeenCalledWith( + expect.objectContaining({ + arrow, + container, + dir: 'rtl', + position: 'above', + positionFixed: true, + targetDocument: document, + unstable_disableShift: true, + unstable_flipFallbackStrategy: 'initialPlacement', + }), + ); + expect(createPositionManagerMock).toHaveBeenCalledWith({ + arrow, + container, + disableUpdateOnResize: true, + middleware: [], + placement: 'bottom', + strategy: 'absolute', + target, + useTransform: false, + }); + + manager.updatePosition(); + expect(resolvedManager.updatePosition).toHaveBeenCalledTimes(1); + + const event = createPositioningEndEvent(); + container.dispatchEvent(event); + + expect(onPositioningEnd).toHaveBeenCalledWith(event); + }); + + it('removes the positioning end callback listener on dispose', () => { + const container = document.createElement('div'); + const target = document.createElement('button'); + const onPositioningEnd = jest.fn(); + + const manager = createPositioningManager_unstable({ + container, + onPositioningEnd, + target, + }); + + manager.dispose(); + container.dispatchEvent(createPositioningEndEvent()); + + expect(onPositioningEnd).not.toHaveBeenCalled(); + expect(resolvedManager.dispose).toHaveBeenCalledTimes(1); + }); + + it('returns a no-op manager when disabled', () => { + const container = document.createElement('div'); + const target = document.createElement('button'); + const onPositioningEnd = jest.fn(); + + const manager = createPositioningManager_unstable({ + container, + enabled: false, + onPositioningEnd, + target, + }); + + expect(createPositionManagerMock).not.toHaveBeenCalled(); + expect(resolvePositioningOptionsMock).not.toHaveBeenCalled(); + expect(() => { + manager.updatePosition(); + manager.dispose(); + }).not.toThrow(); + + container.dispatchEvent(createPositioningEndEvent()); + expect(onPositioningEnd).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-components/react-positioning/library/src/createPositioningManager_unstable.ts b/packages/react-components/react-positioning/library/src/createPositioningManager_unstable.ts new file mode 100644 index 00000000000000..466341a2b52ad7 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/createPositioningManager_unstable.ts @@ -0,0 +1,73 @@ +'use client'; + +import { canUseDOM } from '@fluentui/react-utilities'; + +import { POSITIONING_END_EVENT } from './constants'; +import { createPositionManager } from './createPositionManager'; +import { resolvePositioningOptions } from './resolvePositioningOptions'; +import type { CreatePositioningManagerOptions, OnPositioningEndEvent, PositionManager } from './types'; + +const noopPositionManager: PositionManager = { + updatePosition: () => undefined, + dispose: () => undefined, +}; + +/** + * @internal + */ +function createPositioningManager(options: CreatePositioningManagerOptions): PositionManager { + const { + arrow = null, + container, + dir, + enabled = true, + onPositioningEnd, + // eslint-disable-next-line @typescript-eslint/no-deprecated + positionFixed, + positioningRef: _positioningRef, + target, + targetDocument, + ...positioningOptions + } = options; + + if (!enabled || !canUseDOM() || !container || !target) { + return noopPositionManager; + } + + const resolvedTargetDocument = targetDocument ?? container.ownerDocument; + const resolvedDir = dir ?? (resolvedTargetDocument?.dir === 'rtl' ? 'rtl' : 'ltr'); + const onPositioningEndListener = onPositioningEnd + ? (event: Event) => onPositioningEnd(event as OnPositioningEndEvent) + : undefined; + + if (onPositioningEndListener) { + container.addEventListener(POSITIONING_END_EVENT, onPositioningEndListener); + } + + const manager = createPositionManager({ + container, + target, + arrow, + ...resolvePositioningOptions({ + container, + arrow, + dir: resolvedDir, + targetDocument: resolvedTargetDocument, + positionFixed, + ...positioningOptions, + }), + }); + + return { + updatePosition: manager.updatePosition, + dispose: () => { + if (onPositioningEndListener) { + container.removeEventListener(POSITIONING_END_EVENT, onPositioningEndListener); + } + + manager.dispose(); + }, + }; +} + +export { createPositioningManager as createPositioningManager_unstable }; diff --git a/packages/react-components/react-positioning/library/src/index.ts b/packages/react-components/react-positioning/library/src/index.ts index 26430e1129759a..bb598f7d3c07c1 100644 --- a/packages/react-components/react-positioning/library/src/index.ts +++ b/packages/react-components/react-positioning/library/src/index.ts @@ -2,6 +2,7 @@ export { createVirtualElementFromClick } from './createVirtualElementFromClick'; export { usePositioningSlideDirection } from './usePositioningSlideDirection'; export { POSITIONING_SLIDE_DIRECTION_VAR_X, POSITIONING_SLIDE_DIRECTION_VAR_Y } from './constants'; export { createArrowHeightStyles, createArrowStyles } from './createArrowStyles'; +export { createPositioningManager_unstable } from './createPositioningManager_unstable'; // eslint-disable-next-line @typescript-eslint/no-deprecated export { createSlideStyles } from './createSlideStyles'; export type { CreateArrowStylesOptions } from './createArrowStyles'; @@ -19,6 +20,7 @@ export type { AutoSize, // eslint-disable-next-line @typescript-eslint/no-deprecated Boundary, + CreatePositioningManagerOptions, Offset, OffsetFunction, OffsetFunctionParam, @@ -27,6 +29,7 @@ export type { Position, PositioningBoundary, PositioningImperativeRef, + PositionManager, PositioningProps, PositioningRect, PositioningShorthand, diff --git a/packages/react-components/react-positioning/library/src/middleware/flip.ts b/packages/react-components/react-positioning/library/src/middleware/flip.ts index 4b59eb16939e67..2173851fdd01ca 100644 --- a/packages/react-components/react-positioning/library/src/middleware/flip.ts +++ b/packages/react-components/react-positioning/library/src/middleware/flip.ts @@ -7,10 +7,18 @@ export interface FlipMiddlewareOptions extends Pick((acc, shorthand) => { const { position, align } = resolvePositioningShorthand(shorthand); @@ -24,7 +32,7 @@ export function flip(options: FlipMiddlewareOptions): Middleware { return baseFlip({ ...(hasScrollableElement && { boundary: 'clippingAncestors' }), ...(flipBoundary && { altBoundary: true, boundary: getBoundary(container, flipBoundary) }), - fallbackStrategy: 'bestFit', + fallbackStrategy, ...(fallbackPlacements.length && { fallbackPlacements }), }); } diff --git a/packages/react-components/react-positioning/library/src/resolvePositioningOptions.test.ts b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.test.ts new file mode 100644 index 00000000000000..585f76a2d264f3 --- /dev/null +++ b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.test.ts @@ -0,0 +1,182 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { devtools } from '@floating-ui/devtools'; +import { arrow as arrowMiddleware, hide as hideMiddleware } from '@floating-ui/dom'; + +import { + coverTarget as coverTargetMiddleware, + flip as flipMiddleware, + intersecting as intersectingMiddleware, + matchTargetSize as matchTargetSizeMiddleware, + maxSize as maxSizeMiddleware, + offset as offsetMiddleware, + resetMaxSize as resetMaxSizeMiddleware, + shift as shiftMiddleware, +} from './middleware'; +import { resolvePositioningOptions } from './resolvePositioningOptions'; + +jest.mock('./middleware', () => ({ + coverTarget: jest.fn(() => ({ name: 'coverTarget' })), + flip: jest.fn(() => ({ name: 'flip' })), + intersecting: jest.fn(() => ({ name: 'intersecting' })), + matchTargetSize: jest.fn(() => ({ name: 'matchTargetSize' })), + maxSize: jest.fn(() => ({ name: 'maxSize' })), + offset: jest.fn(() => ({ name: 'offset' })), + resetMaxSize: jest.fn(() => ({ name: 'resetMaxSize' })), + shift: jest.fn(() => ({ name: 'shift' })), +})); + +jest.mock('@floating-ui/dom', () => ({ + arrow: jest.fn(() => ({ name: 'arrow' })), + hide: jest.fn(({ strategy }: { strategy: string }) => ({ name: `hide:${strategy}` })), +})); + +jest.mock('@floating-ui/devtools', () => ({ + devtools: jest.fn(() => ({ name: 'devtools' })), +})); + +const arrowMiddlewareMock = arrowMiddleware as jest.MockedFunction; +const coverTargetMiddlewareMock = coverTargetMiddleware as jest.MockedFunction; +const devtoolsMock = devtools as jest.MockedFunction; +const flipMiddlewareMock = flipMiddleware as jest.MockedFunction; +const hideMiddlewareMock = hideMiddleware as jest.MockedFunction; +const intersectingMiddlewareMock = intersectingMiddleware as jest.MockedFunction; +const matchTargetSizeMiddlewareMock = matchTargetSizeMiddleware as jest.MockedFunction< + typeof matchTargetSizeMiddleware +>; +const maxSizeMiddlewareMock = maxSizeMiddleware as jest.MockedFunction; +const offsetMiddlewareMock = offsetMiddleware as jest.MockedFunction; +const resetMaxSizeMiddlewareMock = resetMaxSizeMiddleware as jest.MockedFunction; +const shiftMiddlewareMock = shiftMiddleware as jest.MockedFunction; + +function createContainer() { + const container = document.createElement('div'); + document.body.appendChild(container); + return container; +} + +describe('resolvePositioningOptions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('uses default flip and shift behavior', () => { + const container = createContainer(); + + const result = resolvePositioningOptions({ + container, + arrow: null, + targetDocument: document, + }); + + expect(result.placement).toBeUndefined(); + expect(result.strategy).toBe('absolute'); + expect(result.middleware.map(middleware => middleware.name)).toEqual([ + 'flip', + 'shift', + 'intersecting', + 'hide:referenceHidden', + 'hide:escaped', + 'devtools', + ]); + expect(flipMiddlewareMock).toHaveBeenCalledWith( + expect.objectContaining({ + container, + fallbackStrategy: 'bestFit', + hasScrollableElement: false, + isRtl: false, + }), + ); + expect(shiftMiddlewareMock).toHaveBeenCalledWith( + expect.objectContaining({ + container, + hasScrollableElement: false, + isRtl: false, + }), + ); + expect(intersectingMiddlewareMock).toHaveBeenCalledTimes(1); + expect(hideMiddlewareMock).toHaveBeenNthCalledWith(1, { strategy: 'referenceHidden' }); + expect(hideMiddlewareMock).toHaveBeenNthCalledWith(2, { strategy: 'escaped' }); + expect(devtoolsMock).toHaveBeenCalledWith(document, expect.any(Function)); + expect(arrowMiddlewareMock).not.toHaveBeenCalled(); + }); + + it('uses fixed positioning when positionFixed is set and strategy is omitted', () => { + const container = createContainer(); + + const result = resolvePositioningOptions({ + container, + arrow: null, + positionFixed: true, + }); + + expect(result.strategy).toBe('fixed'); + }); + + it('honors native parity overrides without changing explicit strategy', () => { + const container = createContainer(); + const arrow = document.createElement('div'); + + const result = resolvePositioningOptions({ + align: 'top', + arrow, + arrowPadding: 12, + autoSize: 'height', + container, + coverTarget: true, + dir: 'rtl', + disableUpdateOnResize: true, + fallbackPositions: ['above', 'below-end'], + matchTargetSize: 'width', + offset: 4, + position: 'before', + positionFixed: true, + strategy: 'absolute', + targetDocument: document, + unstable_disableShift: true, + unstable_flipFallbackStrategy: 'initialPlacement', + useTransform: false, + }); + + expect(result.placement).toBe('right-start'); + expect(result.strategy).toBe('absolute'); + expect(result.disableUpdateOnResize).toBe(true); + expect(result.useTransform).toBe(false); + expect(result.middleware.map(middleware => middleware.name)).toEqual([ + 'resetMaxSize', + 'matchTargetSize', + 'offset', + 'coverTarget', + 'flip', + 'maxSize', + 'intersecting', + 'arrow', + 'hide:referenceHidden', + 'hide:escaped', + 'devtools', + ]); + expect(resetMaxSizeMiddlewareMock).toHaveBeenCalledWith({ applyMaxWidth: false, applyMaxHeight: true }); + expect(matchTargetSizeMiddlewareMock).toHaveBeenCalledTimes(1); + expect(offsetMiddlewareMock).toHaveBeenCalledWith(4); + expect(coverTargetMiddlewareMock).toHaveBeenCalledTimes(1); + expect(flipMiddlewareMock).toHaveBeenCalledWith( + expect.objectContaining({ + container, + fallbackPositions: ['above', 'below-end'], + fallbackStrategy: 'initialPlacement', + hasScrollableElement: false, + isRtl: true, + }), + ); + expect(shiftMiddlewareMock).not.toHaveBeenCalled(); + expect(maxSizeMiddlewareMock).toHaveBeenCalledWith( + { applyMaxWidth: false, applyMaxHeight: true }, + expect.objectContaining({ container, isRtl: true }), + ); + expect(intersectingMiddlewareMock).toHaveBeenCalledTimes(1); + expect(arrowMiddlewareMock).toHaveBeenCalledWith({ element: arrow, padding: 12 }); + }); +}); diff --git a/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts new file mode 100644 index 00000000000000..f732b6dfa2405c --- /dev/null +++ b/packages/react-components/react-positioning/library/src/resolvePositioningOptions.ts @@ -0,0 +1,112 @@ +import { devtools } from '@floating-ui/devtools'; +import { hide as hideMiddleware, arrow as arrowMiddleware } from '@floating-ui/dom'; +import type { Middleware, Placement, Strategy } from '@floating-ui/dom'; + +import { + shift as shiftMiddleware, + flip as flipMiddleware, + coverTarget as coverTargetMiddleware, + maxSize as maxSizeMiddleware, + resetMaxSize as resetMaxSizeMiddleware, + offset as offsetMiddleware, + intersecting as intersectingMiddleware, + matchTargetSize as matchTargetSizeMiddleware, +} from './middleware'; +import type { PositioningConfigurationFnOptions, PositioningOptions } from './types'; +import { toFloatingUIPlacement, hasScrollParent, normalizeAutoSize } from './utils'; +import { devtoolsCallback } from './utils/devtools'; + +type ResolvePositioningOptionsInput = PositioningConfigurationFnOptions & + Pick & { + container: HTMLElement; + arrow: HTMLElement | null; + dir?: 'ltr' | 'rtl'; + targetDocument?: Document; + }; + +/** + * @internal + */ +export function resolvePositioningOptions(options: ResolvePositioningOptionsInput): { + placement: Placement | undefined; + middleware: Middleware[]; + strategy: Strategy; + disableUpdateOnResize?: boolean; + useTransform?: boolean; +} { + const { + align, + arrow, + arrowPadding, + autoSize, + container, + coverTarget, + dir, + disableUpdateOnResize, + fallbackPositions, + flipBoundary, + matchTargetSize, + offset, + overflowBoundary, + overflowBoundaryPadding, + pinned, + position, + // eslint-disable-next-line @typescript-eslint/no-deprecated + positionFixed, + shiftToCoverTarget, + strategy, + targetDocument, + unstable_disableShift: disableShift, + unstable_disableTether: disableTether, + unstable_flipFallbackStrategy: flipFallbackStrategy, + useTransform, + } = options; + const hasScrollableElement = hasScrollParent(container); + const isRtl = dir === 'rtl'; + const normalizedAutoSize = normalizeAutoSize(autoSize); + + const middleware = [ + normalizedAutoSize && resetMaxSizeMiddleware(normalizedAutoSize), + matchTargetSize && matchTargetSizeMiddleware(), + offset && offsetMiddleware(offset), + coverTarget && coverTargetMiddleware(), + !pinned && + flipMiddleware({ + container, + flipBoundary, + hasScrollableElement, + isRtl, + fallbackPositions, + fallbackStrategy: flipFallbackStrategy ?? 'bestFit', + }), + !disableShift && + shiftMiddleware({ + container, + hasScrollableElement, + overflowBoundary, + disableTether, + overflowBoundaryPadding, + isRtl, + shiftToCoverTarget, + }), + normalizedAutoSize && + maxSizeMiddleware(normalizedAutoSize, { container, overflowBoundary, overflowBoundaryPadding, isRtl }), + intersectingMiddleware(), + arrow && arrowMiddleware({ element: arrow, padding: arrowPadding }), + hideMiddleware({ strategy: 'referenceHidden' }), + hideMiddleware({ strategy: 'escaped' }), + process.env.NODE_ENV !== 'production' && + targetDocument && + devtools(targetDocument, devtoolsCallback({ flipBoundary, overflowBoundary })), + ].filter(Boolean) as Middleware[]; + + const placement = toFloatingUIPlacement(align, position, isRtl); + + return { + placement, + middleware, + strategy: strategy ?? (positionFixed ? ('fixed' as const) : ('absolute' as const)), + disableUpdateOnResize, + useTransform, + }; +} diff --git a/packages/react-components/react-positioning/library/src/types.test.ts b/packages/react-components/react-positioning/library/src/types.test.ts index 1d74fd5116c473..e3885bebc5c9a8 100644 --- a/packages/react-components/react-positioning/library/src/types.test.ts +++ b/packages/react-components/react-positioning/library/src/types.test.ts @@ -1,4 +1,7 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import * as React from 'react'; +import { createPositioningManager_unstable } from './createPositioningManager_unstable'; +import type { CreatePositioningManagerOptions, PositionManager } from './types'; import type { OnPositioningEndEvent } from './types'; import type { PositioningProps } from './types'; @@ -55,3 +58,24 @@ describe('PositioningProps', () => { expect(props.onPositioningEnd).toBeDefined(); }); }); + +describe('createPositioningManager_unstable', () => { + it('should not break the factory option and return types', () => { + const factory: (options: CreatePositioningManagerOptions) => PositionManager = createPositioningManager_unstable; + const options: CreatePositioningManagerOptions = { + arrow: null, + container: document.createElement('div'), + dir: 'rtl', + enabled: true, + positionFixed: true, + target: document.createElement('button'), + targetDocument: document, + unstable_disableShift: true, + unstable_disableTether: 'all', + unstable_flipFallbackStrategy: 'initialPlacement', + }; + + expect(factory).toBeDefined(); + expect(options).toBeTruthy; + }); +}); diff --git a/packages/react-components/react-positioning/library/src/types.ts b/packages/react-components/react-positioning/library/src/types.ts index 762d3153be86f9..57f42d266766a0 100644 --- a/packages/react-components/react-positioning/library/src/types.ts +++ b/packages/react-components/react-positioning/library/src/types.ts @@ -222,6 +222,22 @@ export interface PositioningOptions { // eslint-disable-next-line @typescript-eslint/naming-convention unstable_disableTether?: boolean | 'all'; + /** + * Disables shift middleware. + * Useful for matching native CSS anchor positioning behavior. + * @default false + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableShift?: boolean; + + /** + * Controls the flip middleware fallback strategy. + * Useful for matching native CSS anchor positioning behavior. + * @default 'bestFit' + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_flipFallbackStrategy?: 'bestFit' | 'initialPlacement'; + /** * If flip fails to stop the positioned element from overflowing * its boundaries, use a specified fallback positions. @@ -298,6 +314,70 @@ export interface PositioningProps target?: TargetElement | null; } +/** + * @internal + */ +export interface CreatePositioningManagerOptions extends PositioningProps { + /** + * The positioned element + */ + container: HTMLElement | null; + + /** + * Element that the container will be anchored to + */ + target: TargetElement | null; + + /** + * Arrow that points from the container to the target + */ + arrow?: HTMLElement | null; + + /** + * Text direction used to resolve logical placements + */ + dir?: 'ltr' | 'rtl'; + + /** + * Document used for devtools integration + */ + targetDocument?: Document; + + /** + * If false, does not position anything + */ + enabled?: boolean; + + /** + * Enables the positioned element to use CSS position: fixed + * @deprecated use `strategy` instead + */ + positionFixed?: boolean; + + /** + * When the reference element or the viewport is outside viewport allows a positioned element to be fully in viewport. + * "all" enables this behavior for all axis. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableTether?: boolean | 'all'; + + /** + * Disables shift middleware. + * Useful for matching native CSS anchor positioning behavior. + * @default false + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableShift?: boolean; + + /** + * Controls the flip middleware fallback strategy. + * Useful for matching native CSS anchor positioning behavior. + * @default 'bestFit' + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_flipFallbackStrategy?: 'bestFit' | 'initialPlacement'; +} + export type PositioningShorthandValue = | 'above' | 'above-start' diff --git a/packages/react-components/react-positioning/library/src/usePositioning.test.tsx b/packages/react-components/react-positioning/library/src/usePositioning.test.tsx index d92b4736def382..d6fa578c2138f0 100644 --- a/packages/react-components/react-positioning/library/src/usePositioning.test.tsx +++ b/packages/react-components/react-positioning/library/src/usePositioning.test.tsx @@ -1,5 +1,6 @@ import { act, render } from '@testing-library/react'; import * as React from 'react'; +import { createPositionManager } from './createPositionManager'; import { usePositioning } from './usePositioning'; import { POSITIONING_END_EVENT } from './constants'; import type { OnPositioningEndEvent, OnPositioningEndEventDetail, PositioningProps } from './types'; @@ -32,8 +33,21 @@ jest.mock('./createPositionManager', () => ({ }), })); -const TestComponent: React.FC<{ onPositioningEnd?: PositioningProps['onPositioningEnd'] }> = ({ onPositioningEnd }) => { - const { targetRef, containerRef } = usePositioning({ onPositioningEnd }); +const createPositionManagerMock = createPositionManager as jest.MockedFunction; + +type TestComponentProps = { + enabled?: boolean; + onPositioningEnd?: PositioningProps['onPositioningEnd']; +}; + +const flushMicrotasks = async () => { + await act(async () => { + await new Promise(process.nextTick); + }); +}; + +const TestComponent = ({ enabled, onPositioningEnd }: TestComponentProps) => { + const { targetRef, containerRef } = usePositioning({ enabled, onPositioningEnd }); return ( <> @@ -48,16 +62,17 @@ const TestComponent: React.FC<{ onPositioningEnd?: PositioningProps['onPositioni }; describe('usePositioning', () => { + beforeEach(() => { + createPositionManagerMock.mockClear(); + }); + describe('onPositioningEnd', () => { it('calls onPositioningEnd with the positioning event', async () => { const onPositioningEnd = jest.fn(); render(); - // Flush microtasks so the async dispatch fires - await act(async () => { - await new Promise(process.nextTick); - }); + await flushMicrotasks(); expect(onPositioningEnd).toHaveBeenCalled(); @@ -74,9 +89,41 @@ describe('usePositioning', () => { // Should not throw render(); - await act(async () => { - await new Promise(process.nextTick); + await flushMicrotasks(); + }); + + it('uses the latest onPositioningEnd callback after rerender', async () => { + const firstOnPositioningEnd = jest.fn(); + const secondOnPositioningEnd = jest.fn(); + const { getByTestId, rerender } = render(); + + await flushMicrotasks(); + + const initialCallCount = firstOnPositioningEnd.mock.calls.length; + + expect(initialCallCount).toBeGreaterThan(0); + + rerender(); + + const event = new CustomEvent(POSITIONING_END_EVENT, { + detail: { + placement: 'top', + escaped: true, + referenceHidden: true, + }, }); + + getByTestId('container').dispatchEvent(event); + + expect(firstOnPositioningEnd).toHaveBeenCalledTimes(initialCallCount); + expect(secondOnPositioningEnd).toHaveBeenCalledTimes(1); + expect(secondOnPositioningEnd).toHaveBeenCalledWith(event); + }); + + it('does not create a manager when disabled', () => { + render(); + + expect(createPositionManagerMock).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/react-components/react-positioning/library/src/usePositioningOptions.ts b/packages/react-components/react-positioning/library/src/usePositioningOptions.ts index ef8719797caca0..fb6dca7caf001d 100644 --- a/packages/react-components/react-positioning/library/src/usePositioningOptions.ts +++ b/packages/react-components/react-positioning/library/src/usePositioningOptions.ts @@ -1,25 +1,12 @@ 'use client'; -import { devtools } from '@floating-ui/devtools'; -import { hide as hideMiddleware, arrow as arrowMiddleware } from '@floating-ui/dom'; import type { Middleware, Placement, Strategy } from '@floating-ui/dom'; import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts'; import * as React from 'react'; -import { - shift as shiftMiddleware, - flip as flipMiddleware, - coverTarget as coverTargetMiddleware, - maxSize as maxSizeMiddleware, - resetMaxSize as resetMaxSizeMiddleware, - offset as offsetMiddleware, - intersecting as intersectingMiddleware, - matchTargetSize as matchTargetSizeMiddleware, -} from './middleware'; import type { PositioningConfigurationFn, PositioningConfigurationFnOptions, PositioningOptions } from './types'; -import { toFloatingUIPlacement, hasScrollParent, normalizeAutoSize } from './utils'; -import { devtoolsCallback } from './utils/devtools'; import { usePositioningConfiguration } from './PositioningConfigurationContext'; +import { resolvePositioningOptions } from './resolvePositioningOptions'; /** * This is redundant and exists only to manage React dependencies properly & avoid leaking individual options to the @@ -43,7 +30,11 @@ function usePositioningConfigFn( pinned, position, // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableShift, + // eslint-disable-next-line @typescript-eslint/naming-convention unstable_disableTether, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_flipFallbackStrategy, strategy, overflowBoundaryPadding, fallbackPositions, @@ -75,7 +66,11 @@ function usePositioningConfigFn( shiftToCoverTarget, position, // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_disableShift, + // eslint-disable-next-line @typescript-eslint/naming-convention unstable_disableTether, + // eslint-disable-next-line @typescript-eslint/naming-convention + unstable_flipFallbackStrategy, }, }); }, @@ -97,6 +92,8 @@ function usePositioningConfigFn( shiftToCoverTarget, position, unstable_disableTether, + unstable_disableShift, + unstable_flipFallbackStrategy, configFn, ], ); @@ -116,7 +113,6 @@ export function usePositioningOptions(options: PositioningOptions): ( useTransform?: boolean; } { const { dir, targetDocument } = useFluent(); - const isRtl = dir === 'rtl'; const configFn = usePositioningConfigFn(usePositioningConfiguration(), options); const { @@ -126,68 +122,16 @@ export function usePositioningOptions(options: PositioningOptions): ( return React.useCallback( (container: HTMLElement, arrow: HTMLElement | null) => { - const hasScrollableElement = hasScrollParent(container); - const optionsAfterEnhancement = configFn(container, arrow); - const { - autoSize, - disableUpdateOnResize, - matchTargetSize, - offset, - coverTarget, - flipBoundary, - overflowBoundary, - useTransform, - overflowBoundaryPadding, - pinned, - position, - arrowPadding, - strategy, - align, - fallbackPositions, - shiftToCoverTarget, - // eslint-disable-next-line @typescript-eslint/naming-convention - unstable_disableTether, - } = optionsAfterEnhancement; - const normalizedAutoSize = normalizeAutoSize(autoSize); - - const middleware = [ - normalizedAutoSize && resetMaxSizeMiddleware(normalizedAutoSize), - matchTargetSize && matchTargetSizeMiddleware(), - offset && offsetMiddleware(offset), - coverTarget && coverTargetMiddleware(), - !pinned && flipMiddleware({ container, flipBoundary, hasScrollableElement, isRtl, fallbackPositions }), - shiftMiddleware({ - container, - hasScrollableElement, - overflowBoundary, - disableTether: unstable_disableTether, - overflowBoundaryPadding, - isRtl, - shiftToCoverTarget, - }), - normalizedAutoSize && - maxSizeMiddleware(normalizedAutoSize, { container, overflowBoundary, overflowBoundaryPadding, isRtl }), - intersectingMiddleware(), - arrow && arrowMiddleware({ element: arrow, padding: arrowPadding }), - hideMiddleware({ strategy: 'referenceHidden' }), - hideMiddleware({ strategy: 'escaped' }), - process.env.NODE_ENV !== 'production' && - targetDocument && - devtools(targetDocument, devtoolsCallback(optionsAfterEnhancement)), - ].filter(Boolean) as Middleware[]; - - const placement = toFloatingUIPlacement(align, position, isRtl); - - return { - placement, - middleware, - strategy: strategy ?? positionFixed ? ('fixed' as const) : ('absolute' as const), - - disableUpdateOnResize, - useTransform, - }; + return resolvePositioningOptions({ + container, + arrow, + dir, + targetDocument, + positionFixed, + ...optionsAfterEnhancement, + }); }, - [configFn, isRtl, targetDocument, positionFixed], + [configFn, dir, targetDocument, positionFixed], ); } diff --git a/tools/workspace-plugin/src/executors/build/lib/cjs-extension.spec.ts b/tools/workspace-plugin/src/executors/build/lib/cjs-extension.spec.ts index 51ddeae07ed391..54f469901eaad6 100644 --- a/tools/workspace-plugin/src/executors/build/lib/cjs-extension.spec.ts +++ b/tools/workspace-plugin/src/executors/build/lib/cjs-extension.spec.ts @@ -65,11 +65,15 @@ describe('cjs-extension', () => { }); describe('renameToCjs', () => { - it('renames *.js -> *.cjs and rewrites relative requires + sourceMappingURL comment', async () => { + it('renames *.js -> *.cjs and rewrites relative requires, imports, and sourceMappingURL comments', async () => { await mkdir(join(projectRoot, 'lib-commonjs')); await writeFile( join(projectRoot, 'lib-commonjs/index.js'), - [`var other = require("./other.js");`, `//# sourceMappingURL=index.js.map`].join('\n'), + [ + `var other = require("./other.js");`, + `const lazy = () => import("./lazy.js");`, + `//# sourceMappingURL=index.js.map`, + ].join('\n'), ); await renameToCjs(join(projectRoot, 'lib-commonjs/index.js')); @@ -77,6 +81,7 @@ describe('cjs-extension', () => { expect(await exists(join(projectRoot, 'lib-commonjs/index.js'))).toBe(false); const index = await readFile(join(projectRoot, 'lib-commonjs/index.cjs'), 'utf-8'); expect(index).toContain(`require("./other.cjs")`); + expect(index).toContain(`import("./lazy.cjs")`); expect(index).toContain(`//# sourceMappingURL=index.cjs.map`); }); diff --git a/tools/workspace-plugin/src/executors/build/lib/cjs-extension.ts b/tools/workspace-plugin/src/executors/build/lib/cjs-extension.ts index 0e2438ab8ece16..3bdfd1fbccc35b 100644 --- a/tools/workspace-plugin/src/executors/build/lib/cjs-extension.ts +++ b/tools/workspace-plugin/src/executors/build/lib/cjs-extension.ts @@ -8,6 +8,7 @@ import { type Transform } from './swc'; // rewrite only RELATIVE specifiers (./ or ../) ending in .js -> .cjs const RELATIVE_REQUIRE = /(require\(\s*["'])(\.[^"']+?)\.js(["']\s*\))/g; +const RELATIVE_DYNAMIC_IMPORT = /(import\(\s*["'])(\.[^"']+?)\.js(["']\s*\))/g; async function exists(path: string) { try { @@ -35,8 +36,8 @@ async function* walk(dir: string): AsyncGenerator { * * Wired in as an SWC `Transform` (see `cjsRenameTransforms`) so each `commonjs` file is renamed * right after it's written, instead of a separate postprocessing pass over the whole output dir. - * Renames `*.js` -> `*.cjs` (incl. `*.styles.raw.js`), rewrites relative `require("./x.js")` -> - * `require("./x.cjs")`, and renames the adjacent `*.js.map` -> `*.cjs.map`. + * Renames `*.js` -> `*.cjs` (incl. `*.styles.raw.js`), rewrites relative `require("./x.js")` and + * `import("./x.js")` specifiers to `.cjs`, and renames the adjacent `*.js.map` -> `*.cjs.map`. */ export const renameToCjs: Transform = async filePath => { if (filePath.endsWith('.js.map')) { @@ -50,7 +51,9 @@ export const renameToCjs: Transform = async filePath => { } if (filePath.endsWith('.js')) { - const code = (await readFile(filePath, 'utf-8')).replace(RELATIVE_REQUIRE, '$1$2.cjs$3'); + const code = (await readFile(filePath, 'utf-8')) + .replace(RELATIVE_REQUIRE, '$1$2.cjs$3') + .replace(RELATIVE_DYNAMIC_IMPORT, '$1$2.cjs$3'); await writeFile( filePath.replace(/\.js$/, '.cjs'), code.replace(/(\/\/#\s*sourceMappingURL=.*?)\.js\.map$/m, '$1.cjs.map'), diff --git a/tools/workspace-plugin/src/executors/build/lib/swc.spec.ts b/tools/workspace-plugin/src/executors/build/lib/swc.spec.ts new file mode 100644 index 00000000000000..aea90bb99a2790 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/lib/swc.spec.ts @@ -0,0 +1,63 @@ +import { transformFile } from '@swc/core'; +import { readJsonFile } from '@nx/devkit'; +import { globSync } from 'fast-glob'; + +import { compileSwc } from './swc'; +import type { NormalizedOptions } from './shared'; + +jest.mock('@swc/core', () => ({ + transformFile: jest.fn(async () => ({ code: 'export {};' })), +})); +jest.mock('@nx/devkit', () => ({ + logger: { log: jest.fn(), verbose: jest.fn() }, + readJsonFile: jest.fn(() => ({ jsc: {} })), +})); +jest.mock('fast-glob', () => ({ + globSync: jest.fn(() => ['index.ts']), +})); +jest.mock('node:fs/promises', () => ({ + mkdir: jest.fn(async () => undefined), + writeFile: jest.fn(async () => undefined), +})); + +const transformFileMock = transformFile as jest.MockedFunction; + +const createOptions = (preserveDynamicImport: boolean): NormalizedOptions => + ({ + absoluteProjectRoot: '/repo/package', + absoluteSourceRoot: '/repo/package/src', + preserveDynamicImport, + } as NormalizedOptions); + +describe('compileSwc dynamic imports', () => { + beforeEach(() => { + transformFileMock.mockClear(); + (readJsonFile as jest.Mock).mockClear(); + (globSync as jest.Mock).mockClear(); + }); + + it('preserves dynamic imports for opted-in CommonJS output', async () => { + await compileSwc({ module: 'commonjs', outputPath: 'lib-commonjs' }, createOptions(true)); + + expect(transformFileMock).toHaveBeenCalledWith( + '/repo/package/src/index.ts', + expect.objectContaining({ + module: expect.objectContaining({ + ignoreDynamic: true, + type: 'commonjs', + }), + }), + ); + }); + + it('keeps existing CommonJS behavior by default', async () => { + await compileSwc({ module: 'commonjs', outputPath: 'lib-commonjs' }, createOptions(false)); + + expect(transformFileMock).toHaveBeenCalledWith( + '/repo/package/src/index.ts', + expect.objectContaining({ + module: expect.not.objectContaining({ ignoreDynamic: true }), + }), + ); + }); +}); diff --git a/tools/workspace-plugin/src/executors/build/lib/swc.ts b/tools/workspace-plugin/src/executors/build/lib/swc.ts index ea4b85a04e51bf..64a084f85a5585 100644 --- a/tools/workspace-plugin/src/executors/build/lib/swc.ts +++ b/tools/workspace-plugin/src/executors/build/lib/swc.ts @@ -48,7 +48,11 @@ export async function compileSwc( } const result = await transformFile(srcFilePath, { - module: { type: module, resolveFully: Boolean(swcConfig.jsc?.baseUrl) }, + module: { + type: module, + resolveFully: Boolean(swcConfig.jsc?.baseUrl), + ...(module === 'commonjs' && normalizedOptions.preserveDynamicImport && { ignoreDynamic: true }), + }, // srcFilePath is absolute path so outputPath needs to be as well in order to properly emit relative path within .map (eg: `"sources":["../src/utils/createDarkTheme.ts"]`) outputPath: join(normalizedOptions.absoluteProjectRoot, outputPath), }); diff --git a/tools/workspace-plugin/src/executors/build/schema.d.ts b/tools/workspace-plugin/src/executors/build/schema.d.ts index 4058ba93c08da8..19e78f61fe0f03 100644 --- a/tools/workspace-plugin/src/executors/build/schema.d.ts +++ b/tools/workspace-plugin/src/executors/build/schema.d.ts @@ -44,6 +44,10 @@ export interface BuildExecutorSchema { * When enabled, source is processed with React Compiler as a separate Babel pass before Griffel AOT. */ reactCompiler?: boolean; + /** + * Keep dynamic import() expressions in CommonJS output so consuming bundlers can retain asynchronous chunk boundaries. + */ + preserveDynamicImport?: boolean; /** * TEMPORARY/INTERNAL: opts this build into ESM-first postprocessing (.cjs extension rewrite + .d.cts types) ahead of the package's own package.json declaring "type": "module". Exists so the infra can be exercised on a package before it actually flips to ESM. Will be removed once the real migration lands and every opted-in package sets "type": "module" itself. */ diff --git a/tools/workspace-plugin/src/executors/build/schema.json b/tools/workspace-plugin/src/executors/build/schema.json index 6c6d34b0899eb5..ee6eeb0d803a23 100644 --- a/tools/workspace-plugin/src/executors/build/schema.json +++ b/tools/workspace-plugin/src/executors/build/schema.json @@ -67,6 +67,11 @@ "description": "Enable babel-plugin-react-compiler processing. When enabled, source is processed with React Compiler as a separate Babel pass before Griffel AOT.", "default": false }, + "preserveDynamicImport": { + "type": "boolean", + "description": "Keep dynamic import() expressions in CommonJS output so consuming bundlers can retain asynchronous chunk boundaries.", + "default": false + }, "__esmFirst": { "type": "boolean", "description": "TEMPORARY/INTERNAL: opts this build into ESM-first postprocessing (.cjs extension rewrite + .d.cts types) ahead of the package's own package.json declaring \"type\": \"module\". Exists so the infra can be exercised on a package before it actually flips to ESM. Will be removed once the real migration lands and every opted-in package sets \"type\": \"module\" itself.", diff --git a/yarn.lock b/yarn.lock index 07b8e32e859e65..90126a60e99492 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3207,6 +3207,15 @@ __metadata: languageName: unknown linkType: soft +"@fluentui/popover-tests-headless@workspace:apps/popover-tests-headless": + version: 0.0.0-use.local + resolution: "@fluentui/popover-tests-headless@workspace:apps/popover-tests-headless" + dependencies: + "@fluentui/react-components": "npm:*" + "@fluentui/react-headless-components-preview": "npm:*" + languageName: unknown + linkType: soft + "@fluentui/pr-deploy-site@workspace:apps/pr-deploy-site": version: 0.0.0-use.local resolution: "@fluentui/pr-deploy-site@workspace:apps/pr-deploy-site"