From 84f723a2a3a5acd287d552252a923a0719dbc557 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Fri, 22 May 2026 17:49:00 +0200 Subject: [PATCH 01/27] chore(storybook): add nav entries for migration analysis docs Adds sidebar entries for accessibility migration analysis docs on action-button, button-group, close-button, grid, and infield-button, plus the link migration plan, so they appear under their components in the Storybook navigation. --- 2nd-gen/packages/swc/.storybook/preview.ts | 23 +++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index 73e4673e0ab..8b708c19aba 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -330,7 +330,10 @@ const preview = { 'Rendering and styling migration analysis', ], 'Action button', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Action group', ['Rendering and styling migration analysis'], 'Action menu', @@ -360,9 +363,14 @@ const preview = { 'Rendering and styling migration analysis', ], 'Button group', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Checkbox', ['Rendering and styling migration analysis'], + 'Close button', + ['Accessibility migration analysis'], 'Color field', ['Rendering and styling migration analysis'], 'Color loupe', @@ -382,6 +390,11 @@ const preview = { ['Rendering and styling migration analysis'], 'Field label', ['Rendering and styling migration analysis'], + 'Grid', + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Help text', ['Rendering and styling migration analysis'], 'Illustrated message', @@ -391,12 +404,16 @@ const preview = { 'Rendering and styling migration analysis', ], 'Infield button', - ['Rendering and styling migration analysis'], + [ + 'Accessibility migration analysis', + 'Rendering and styling migration analysis', + ], 'Infield progress circle', ['Rendering and styling migration analysis'], 'Link', [ 'Accessibility migration analysis', + 'Migration plan', 'Rendering and styling migration analysis', ], 'Menu', From 859656abc2ec7041e14480defbf3bd96c70f3976 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Fri, 22 May 2026 17:49:20 +0200 Subject: [PATCH 02/27] feat(placement-controller): add PlacementController for 2nd-gen Extracts Floating UI-based positioning from 1st-gen overlay's PlacementController into a standalone reactive controller under core/controllers. Adds start/stop/recompute API, hyphenated Placement union aligned with Floating UI and swc-popover, opt-in constrainSize for picker/menu scroll cases, and VirtualTrigger support for virtual anchors. Wires the floating-ui/dom dependency and package.json export for the new controller subpath. Also lands the supporting Storybook ApiTable block plus the ConditionalAPIReference template change, and migrates the focusgroup-navigation-controller stories to the new controllerApi parameter pattern. SWC-1996 --- ...ocusgroup-navigation-controller.stories.ts | 92 +- 2nd-gen/packages/core/controllers/index.ts | 10 + .../controllers/placement-controller/index.ts | 22 + .../src/fallback-placements.ts | 41 + .../placement-controller/src/index.ts | 23 + .../src/placement-controller.ts | 296 ++++ .../src/placement-conversion.ts | 94 ++ .../placement-controller/src/types.ts | 162 +++ .../stories/demo-hosts.ts | 1209 +++++++++++++++++ .../stories/placement-controller.stories.ts | 518 +++++++ .../test/placement-controller.test.ts | 167 +++ 2nd-gen/packages/core/package.json | 15 + 2nd-gen/packages/core/vite.config.js | 1 + .../swc/.storybook/DocumentTemplate.mdx | 44 +- .../swc/.storybook/blocks/ApiTable.tsx | 333 ++++- yarn.lock | 1 + 16 files changed, 2945 insertions(+), 83 deletions(-) create mode 100644 2nd-gen/packages/core/controllers/placement-controller/index.ts create mode 100644 2nd-gen/packages/core/controllers/placement-controller/src/fallback-placements.ts create mode 100644 2nd-gen/packages/core/controllers/placement-controller/src/index.ts create mode 100644 2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts create mode 100644 2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts create mode 100644 2nd-gen/packages/core/controllers/placement-controller/src/types.ts create mode 100644 2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts create mode 100644 2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts create mode 100644 2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts diff --git a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts index 2b02ab0760b..3598192062c 100644 --- a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts @@ -60,6 +60,54 @@ const argTypes = { }, }; +const controllerApi = { + methods: [ + { + member: 'setOptions(partial)', + description: 'Merge new options and reapply roving tabindex.', + }, + { + member: 'refresh()', + description: 'Re-query items and sync tabindex (call after DOM changes).', + }, + { + member: 'setActiveItem(element)', + description: + 'Set roving `tabindex` to the given eligible item (does **not** call `focus()`). Returns `false` if ineligible.', + }, + { + member: 'focusFirstItemByTextPrefix(prefix)', + description: + 'Set roving `tabindex` to the first eligible item matching prefix (case-insensitive). Does **not** call `focus()`. Returns `false` if no match.', + }, + { + member: 'getActiveItem()', + description: 'Returns the eligible item with `tabindex="0"`, if any.', + }, + ], + additionalOptions: [ + { + name: 'getItems', + type: '() => HTMLElement[]', + default: '(required)', + description: 'Current navigable items.', + }, + { + name: 'onActiveItemChange', + type: '(el) => void', + default: '—', + description: 'Callback when active item changes.', + }, + ], + events: [ + { + name: 'swc-focusgroup-navigation-active-change', + description: + 'Dispatched on the host as `focusgroupNavigationActiveChange` with `detail: { activeElement }` when the active item changes. The event bubbles and is composed.', + }, + ], +}; + /** * `FocusgroupNavigationController` implements the * [roving `tabindex` pattern](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#managingfocuswithincomponentsusingarovingtabindex) @@ -84,6 +132,7 @@ const meta: Meta = { > `, parameters: { + controllerApi, docs: { subtitle: 'Roving tabindex and directional keys for composite widgets (APG-aligned, focusgroup-like).', @@ -405,49 +454,6 @@ export const TextPrefixFocus: Story = { }, }; -/** - * ### Methods - * - * | Member | Description | - * |---|---| - * | `setOptions(partial)` | Merge new options and reapply roving tabindex. | - * | `refresh()` | Re-query items and sync tabindex (call after DOM changes). | - * | `setActiveItem(element)` | Set roving `tabindex` to the given eligible item (does **not** call `focus()`). Returns `false` if ineligible. | - * | `focusFirstItemByTextPrefix(prefix)` | Set roving `tabindex` to the first eligible item matching prefix (case-insensitive). Does **not** call `focus()`. Returns `false` if no match. | - * | `getActiveItem()` | Returns the eligible item with `tabindex="0"`, if any. | - * ### Events - * - * The controller dispatches **`swc-focusgroup-navigation-active-change`** - * (`focusgroupNavigationActiveChange`) on the host with `detail: { activeElement }` when the - * active item changes. The event bubbles and is composed. - * - * ```typescript - * import { focusgroupNavigationActiveChange } from - * '@spectrum-web-components/core/controllers/focusgroup-navigation-controller.js'; - * - * host.addEventListener(focusgroupNavigationActiveChange, (event) => { - * console.log('Active item:', event.detail.activeElement); - * }); - * ``` - * - * ### Options - * - * | Option | Type | Default | Description | - * |---|---|---|---| - * | `getItems` | `() => HTMLElement[]` | (required) | Current navigable items. | - * | `direction` | `'horizontal'` \| `'vertical'` \| `'both'` \| `'grid'` | (required) | Arrow-key mode. **`both`**: Left/Right and Up/Down on the same `getItems()` sequence. | - * | `wrap` | `boolean` | `false` | Wrap at ends. | - * | `memory` | `boolean` | `true` | Remember last focused for re-entry via Tab. | - * | `skipDisabled` | `boolean` | `false` | Skip `disabled` / `aria-disabled="true"` items. | - * | `pageStep` | `number` | — | Non-zero: **Page Up** / **Page Down** move this many items (linear) or rows (**grid**). `0` / omitted / non-finite: disabled. | - * | `onActiveItemChange` | `(el) => void` | — | Callback when active item changes. | - * - * See the Controls table above for interactive demos of the configurable options. - */ -export const API: Story = { - tags: ['api', 'description-only'], -}; - // ──────────────────────────────── // ACCESSIBILITY STORIES // ──────────────────────────────── diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index 71efb81b073..dc49f55b6b8 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -25,3 +25,13 @@ export { LanguageResolutionController, languageResolverUpdatedSymbol, } from './language-resolution.js'; +export { + ALL_PLACEMENTS, + fromFloatingPlacement, + PlacementController, + toFloatingPlacement, + toPlacementClassSuffix, + type Placement, + type PlacementOptions, + type VirtualTrigger, +} from './placement-controller/index.js'; diff --git a/2nd-gen/packages/core/controllers/placement-controller/index.ts b/2nd-gen/packages/core/controllers/placement-controller/index.ts new file mode 100644 index 00000000000..d74b08417c3 --- /dev/null +++ b/2nd-gen/packages/core/controllers/placement-controller/index.ts @@ -0,0 +1,22 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export { + ALL_PLACEMENTS, + fromFloatingPlacement, + PlacementController, + toFloatingPlacement, + toPlacementClassSuffix, + type Placement, + type PlacementOptions, + type VirtualTrigger, +} from './src/placement-controller.js'; diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/fallback-placements.ts b/2nd-gen/packages/core/controllers/placement-controller/src/fallback-placements.ts new file mode 100644 index 00000000000..a6dba17e3c0 --- /dev/null +++ b/2nd-gen/packages/core/controllers/placement-controller/src/fallback-placements.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { Placement as FloatingPlacement } from '@floating-ui/dom'; + +const FALLBACKS: Record = { + left: ['right', 'bottom', 'top'], + 'left-start': ['right-start', 'bottom', 'top'], + 'left-end': ['right-end', 'bottom', 'top'], + right: ['left', 'bottom', 'top'], + 'right-start': ['left-start', 'bottom', 'top'], + 'right-end': ['left-end', 'bottom', 'top'], + top: ['bottom', 'left', 'right'], + 'top-start': ['bottom-start', 'left', 'right'], + 'top-end': ['bottom-end', 'left', 'right'], + bottom: ['top', 'left', 'right'], + 'bottom-start': ['top-start', 'left', 'right'], + 'bottom-end': ['top-end', 'left', 'right'], +}; + +/** + * Look up the fallback placement order for Floating UI's `flip` middleware + * when the anchor is a {@link VirtualTrigger}. + * + * @param placement - Normalized Floating UI placement for the current side. + * @returns Ordered list of placements to try when the primary side does not fit. + */ +export function getFallbackPlacements( + placement: FloatingPlacement +): FloatingPlacement[] { + return FALLBACKS[placement] ?? [placement]; +} diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/index.ts b/2nd-gen/packages/core/controllers/placement-controller/src/index.ts new file mode 100644 index 00000000000..2ea2e19e6dc --- /dev/null +++ b/2nd-gen/packages/core/controllers/placement-controller/src/index.ts @@ -0,0 +1,23 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export { + ALL_PLACEMENTS, + fromFloatingPlacement, + PlacementController, + toFloatingPlacement, + toPlacementClassSuffix, + type Placement, + type PlacementOptions, + type VirtualTrigger, +} from './placement-controller.js'; +export { getFallbackPlacements } from './fallback-placements.js'; diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts new file mode 100644 index 00000000000..a817be91fc7 --- /dev/null +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts @@ -0,0 +1,296 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import { + autoUpdate, + computePosition, + flip, + offset, + shift, + size, +} from '@floating-ui/dom'; + +import { getFallbackPlacements } from './fallback-placements.js'; +import { + fromFloatingPlacement, + toFloatingPlacement, +} from './placement-conversion.js'; +import type { Placement, PlacementOptions, VirtualTrigger } from './types.js'; + +/** Minimum height for the floating content when `constrainSize` is enabled. */ +const MIN_FLOATING_HEIGHT = 100; + +const DEFAULT_PLACEMENT: Placement = 'bottom'; +const DEFAULT_OFFSET = 8; +const DEFAULT_CROSS_OFFSET = 0; +const DEFAULT_CONTAINER_PADDING = 12; +const DEFAULT_SHOULD_FLIP = true; + +/** + * Round a pixel value to the nearest device-pixel boundary so translated + * coordinates stay sharp on high-DPR displays. + * + * @param num - Coordinate in CSS pixels. + * @returns The value rounded to the nearest physical pixel. + */ +function roundByDPR(num: number): number { + const dpr = window.devicePixelRatio || 1; + return Math.round(num * dpr) / dpr; +} + +/** + * Detect WebKit-based browsers (Safari, iOS) where `visualViewport` offset + * correction is required after `computePosition`. + * + * @returns True when the runtime appears to be WebKit without Chrome. + */ +function isWebKit(): boolean { + if (typeof navigator === 'undefined') { + return false; + } + const ua = navigator.userAgent; + return /AppleWebKit/.test(ua) && !/Chrome/.test(ua); +} + +type ActiveSession = { + trigger: HTMLElement | VirtualTrigger; + floating: HTMLElement; + options: PlacementOptions; +}; + +/** + * **PlacementController** — Lit reactive controller that positions a floating + * element relative to a trigger using [Floating UI](https://floating-ui.com/) + * (`computePosition` + `autoUpdate`). + * + * The public API uses hyphenated placements aligned with `` and + * Floating UI. Logical sides (`start`, `end`) normalize to physical sides for + * positioning math; RTL is handled in CSS at the consumer layer. + * + * @example + * ```typescript + * this.placement.start(this.trigger, this.dialog, { + * placement: 'bottom-start', + * offset: 8, + * crossOffset: 0, + * shouldFlip: true, + * onPlacementChange: (p) => { + * this.actualPlacement = p; + * }, + * }); + * ``` + */ +export class PlacementController implements ReactiveController { + private cleanup?: () => void; + private session: ActiveSession | null = null; + + /** + * The computed placement after `flip` reorients (hyphenated). `null` when + * {@link stop} has been called. + */ + public actualPlacement: Placement | null = null; + + /** + * Whether {@link PlacementOptions.constrainSize} clamped the floating + * element's height on the last compute. + */ + public isConstrained = false; + + /** + * Registers this controller on `host` via `addController`. + * + * @param host - Reactive element that owns the floating surface lifecycle. + */ + constructor(host: ReactiveControllerHost) { + host.addController(this); + } + + /** + * Begin positioning `floating` relative to `trigger`. + * + * Tears down any prior session, stores the new options, and subscribes to + * Floating UI `autoUpdate` so placement stays correct on scroll and resize. + * + * @param trigger - Anchor element or {@link VirtualTrigger}. + * @param floating - Element to position (`position: fixed` or top-layer). + * @param options - {@link PlacementOptions}; omitted properties use defaults. + */ + public start( + trigger: HTMLElement | VirtualTrigger, + floating: HTMLElement, + options: PlacementOptions = {} + ): void { + this.stop(); + this.session = { trigger, floating, options }; + this.actualPlacement = options.placement ?? DEFAULT_PLACEMENT; + + this.cleanup = autoUpdate(trigger, floating, () => { + void this.computePlacement(); + }); + } + + /** + * Stop positioning: tear down `autoUpdate`, clear {@link actualPlacement}, + * and reset {@link isConstrained}. Safe to call multiple times. + */ + public stop(): void { + this.cleanup?.(); + this.cleanup = undefined; + this.session = null; + this.actualPlacement = null; + this.isConstrained = false; + } + + /** + * Force one recomputation outside the `autoUpdate` callback. + * + * Use when floating content reflows internally or when a + * {@link VirtualTrigger} moves without a DOM mutation. No-op if + * {@link start} has not been called. + */ + public recompute(): void { + void this.computePlacement(); + } + + /** + * Lit lifecycle hook — tears down positioning when the host disconnects. + * + * @internal + */ + public hostDisconnected(): void { + this.stop(); + } + + /** + * Run Floating UI `computePosition`, apply the result to the floating element, + * and update {@link actualPlacement} when `flip` reorients. + * + * Waits for web fonts and a WebKit animation frame before measuring. Skips + * when the floating element has zero dimensions. Aborts if {@link stop} or a + * new {@link start} call replaces the active session while awaiting async work. + */ + private async computePlacement(): Promise { + const session = this.session; + if (!session) { + return; + } + + const { trigger, floating, options } = session; + + if (document.fonts) { + await document.fonts.ready; + } + if (this.session !== session) { + return; + } + + if (isWebKit()) { + await new Promise((resolve) => + requestAnimationFrame(() => resolve()) + ); + if (this.session !== session) { + return; + } + } + + const floatingRect = floating.getBoundingClientRect(); + if (floatingRect.width === 0 && floatingRect.height === 0) { + return; + } + + const requestedPlacement = options.placement ?? DEFAULT_PLACEMENT; + const floatingPlacement = toFloatingPlacement(requestedPlacement); + const containerPadding = + options.containerPadding ?? DEFAULT_CONTAINER_PADDING; + const shouldFlip = options.shouldFlip ?? DEFAULT_SHOULD_FLIP; + const mainAxis = options.offset ?? DEFAULT_OFFSET; + const crossAxis = options.crossOffset ?? DEFAULT_CROSS_OFFSET; + + const flipMiddleware = + shouldFlip && + (!(trigger instanceof HTMLElement) + ? flip({ + padding: containerPadding, + fallbackPlacements: getFallbackPlacements(floatingPlacement), + fallbackStrategy: 'bestFit', + }) + : flip({ + padding: containerPadding, + fallbackStrategy: 'bestFit', + })); + + const middleware = [ + offset({ mainAxis, crossAxis }), + ...(flipMiddleware ? [flipMiddleware] : []), + shift({ padding: containerPadding }), + ...(options.constrainSize + ? [ + size({ + padding: containerPadding, + apply: ({ availableHeight, availableWidth, rects }) => { + const maxHeight = Math.max( + MIN_FLOATING_HEIGHT, + Math.floor(availableHeight) + ); + const actualHeight = rects.floating.height; + this.isConstrained = + actualHeight >= maxHeight || + Math.floor(availableHeight) <= MIN_FLOATING_HEIGHT; + Object.assign(floating.style, { + maxHeight: `${maxHeight}px`, + maxWidth: `${Math.floor(availableWidth)}px`, + }); + }, + }), + ] + : []), + ]; + + const { x, y, placement } = await computePosition(trigger, floating, { + placement: floatingPlacement, + middleware, + strategy: 'fixed', + }); + if (this.session !== session) { + return; + } + + let translateX = x; + let translateY = y; + const visualViewport = window.visualViewport; + if (visualViewport && isWebKit()) { + translateX -= visualViewport.offsetLeft; + translateY -= visualViewport.offsetTop; + } + + Object.assign(floating.style, { + top: '0px', + left: '0px', + translate: `${roundByDPR(translateX)}px ${roundByDPR(translateY)}px`, + }); + + const nextPlacement = fromFloatingPlacement(placement); + if (nextPlacement !== this.actualPlacement) { + this.actualPlacement = nextPlacement; + options.onPlacementChange?.(nextPlacement); + } + } +} + +export type { Placement, PlacementOptions, VirtualTrigger } from './types.js'; +export { ALL_PLACEMENTS } from './types.js'; +export { + fromFloatingPlacement, + toFloatingPlacement, + toPlacementClassSuffix, +} from './placement-conversion.js'; diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts new file mode 100644 index 00000000000..4654ba3e613 --- /dev/null +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts @@ -0,0 +1,94 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type { Placement as FloatingPlacement } from '@floating-ui/dom'; + +import type { Placement } from './types.js'; + +const LOGICAL_SIDES = new Set(['start', 'end']); + +const LOGICAL_TO_PHYSICAL: Record = { + start: 'left', + end: 'right', +}; + +/** Physical cross-axis labels mapped to Floating UI start/end on each primary side. */ +const PHYSICAL_ALIGNMENT_TO_FLOATING: Record< + string, + Record +> = { + bottom: { left: 'bottom-start', right: 'bottom-end' }, + top: { left: 'top-start', right: 'top-end' }, + left: { top: 'left-start', bottom: 'left-end' }, + right: { top: 'right-start', bottom: 'right-end' }, +}; + +/** + * Normalize a hyphenated {@link Placement} for Floating UI's `computePosition`. + * + * - Logical **sides** (`start`, `end`) map to `left` / `right`. + * - Logical **alignments** (`bottom-start`, `top-end`) pass through unchanged. + * - Physical alignments (`bottom-left`, `left-top`) map to the nearest Floating + * UI placement (LTR positioning math; RTL styling stays in CSS). + * + * @param placement - Customer-facing hyphenated placement. + * @returns Placement value understood by Floating UI. + */ +export function toFloatingPlacement(placement: Placement): FloatingPlacement { + const [primary, alignment] = placement.split('-') as [string, string?]; + + if (LOGICAL_SIDES.has(primary)) { + const physicalPrimary = LOGICAL_TO_PHYSICAL[primary] ?? primary; + if (!alignment) { + return physicalPrimary as FloatingPlacement; + } + const physicalAlignment = LOGICAL_TO_PHYSICAL[alignment] ?? alignment; + return `${physicalPrimary}-${physicalAlignment}` as FloatingPlacement; + } + + if (!alignment) { + return primary as FloatingPlacement; + } + + if (alignment === 'start' || alignment === 'end') { + return `${primary}-${alignment}` as FloatingPlacement; + } + + const mapped = PHYSICAL_ALIGNMENT_TO_FLOATING[primary]?.[alignment]; + if (mapped) { + return mapped; + } + + return `${primary}-${alignment}` as FloatingPlacement; +} + +/** + * Surface a Floating UI placement as the public hyphenated {@link Placement} + * form for `actualPlacement` and `onPlacementChange`. + * + * @param placement - Placement returned by Floating UI after `flip`. + * @returns Customer-facing hyphenated placement. + */ +export function fromFloatingPlacement(placement: FloatingPlacement): Placement { + return placement as Placement; +} + +/** + * Derive a CSS modifier suffix from a hyphenated placement (for example + * `.swc-Popover--bottom-start`). + * + * @param placement - Customer-facing hyphenated placement. + * @returns Class suffix safe to append after `--` in a BEM modifier. + */ +export function toPlacementClassSuffix(placement: Placement): string { + return placement; +} diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts new file mode 100644 index 00000000000..d862ca2921e --- /dev/null +++ b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts @@ -0,0 +1,162 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// ───────────────────────── +// PLACEMENT +// ───────────────────────── + +/** + * The full set of hyphenated placement values accepted by the public API + * (`'bottom'`, `'bottom-start'`, `'start-top'`, etc.), aligned with + * `` and Floating UI. + * + * Physical-alignment variants (`top-left`, `bottom-right`) are distinct from + * logical-alignment variants (`top-start`, `bottom-end`) — physical stays fixed + * regardless of direction; logical reverses in RTL via CSS. + */ +export const ALL_PLACEMENTS = [ + 'bottom', + 'bottom-left', + 'bottom-right', + 'bottom-start', + 'bottom-end', + 'top', + 'top-left', + 'top-right', + 'top-start', + 'top-end', + 'left', + 'left-top', + 'left-bottom', + 'start', + 'start-top', + 'start-bottom', + 'right', + 'right-top', + 'right-bottom', + 'end', + 'end-top', + 'end-bottom', +] as const; + +/** + * The placement of the element with respect to its anchor element + * (hyphenated form). + */ +export type Placement = (typeof ALL_PLACEMENTS)[number]; + +// ───────────────────────── +// VIRTUAL TRIGGER +// ───────────────────────── + +/** + * A virtual trigger reference accepted by {@link PlacementController}. Mirrors + * the Floating UI virtual-element shape: anything that can report its bounding + * rect and (optionally) its context element is a valid anchor. + */ +export interface VirtualTrigger { + getBoundingClientRect(): DOMRect; + contextElement?: Element; +} + +// ───────────────────────── +// OPTIONS +// ───────────────────────── + +/** + * Options passed to {@link PlacementController.start}. Matches the positioning + * surface of `` (`placement`, `offset`, `crossOffset`, + * `containerPadding`, `shouldFlip`). + */ +export interface PlacementOptions { + /** + * Preferred side and alignment of the floating element relative to the + * **trigger** (hyphenated, for example `'bottom'`, `'bottom-start'`). + * + * This is the requested placement. When {@link shouldFlip} is enabled, + * Floating UI may compute a different side if the requested one does not fit. + * The result is surfaced on {@link PlacementController.actualPlacement} and + * via {@link onPlacementChange}. + * + * @default 'bottom' + */ + placement?: Placement; + + /** + * Gap along the **placement direction** between the trigger and the floating + * element, in pixels (Floating UI `offset` main axis). + * + * For `'bottom'`, this is the space below the trigger. This is **not** + * viewport padding — see {@link containerPadding} for edge inset when + * `flip` / `shift` run. + * + * @default 8 + */ + offset?: number; + + /** + * Slide along the **trigger edge**, perpendicular to the placement + * direction, in pixels (Floating UI `offset` cross axis). + * + * Adjusts alignment such as `'bottom-start'` vs `'bottom-end'` without + * changing viewport inset. This is **not** {@link containerPadding}. + * + * @default 0 + */ + crossOffset?: number; + + /** + * Minimum inset from the **overflow boundary**, in pixels, used by Floating UI + * `flip`, `shift`, and (when enabled) `size` middleware. + * + * Floating UI applies this as padding around the boundary it uses for collision + * detection. By default that is the floating element's clipping ancestors, capped + * by the visual viewport — so fixed or top-layer popovers usually behave like + * screen-edge inset, while surfaces inside a scrollable clipping parent use that + * container's edges instead. + * + * This is **not** the gap from the trigger — use {@link offset} and + * {@link crossOffset} for trigger-relative spacing. + * + * Matches ``'s `container-padding` attribute; consumer + * components expose this publicly and pass it through to the controller. + * + * @default 12 + */ + containerPadding?: number; + + /** + * Whether Floating UI may **flip** the floating element to the opposite side when the + * requested {@link placement} does not fit within the overflow boundary. + * + * When `false`, the floating element stays on the requested side even if it + * overflows — useful for tooltips that must not jump above the trigger. + * + * @default true + */ + shouldFlip?: boolean; + + /** + * When `true`, applies Floating UI's `size` middleware so long content + * scrolls inside the viewport. Not part of the `` public API; + * used by picker / menu patterns that compose the controller directly. + * + * @default false + */ + constrainSize?: boolean; + + /** + * Called whenever the computed placement changes. Receives the hyphenated + * placement value. + */ + onPlacementChange?: (placement: Placement) => void; +} diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts new file mode 100644 index 00000000000..b35c7ef6501 --- /dev/null +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts @@ -0,0 +1,1209 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + css, + html, + LitElement, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +import { + type Placement, + PlacementController, + type PlacementOptions, + type VirtualTrigger, +} from '../index.js'; + +declare global { + interface HTMLElementTagNameMap { + 'demo-placement-playground': DemoPlacementPlayground; + 'demo-placement-flip': DemoPlacementFlip; + 'demo-placement-no-flip': DemoPlacementNoFlip; + 'demo-placement-offset': DemoPlacementOffset; + 'demo-placement-constrain-size': DemoPlacementConstrainSize; + 'demo-placement-virtual-trigger': DemoPlacementVirtualTrigger; + 'demo-placement-placements': DemoPlacementPlacements; + 'demo-placement-cell': DemoPlacementCell; + } +} + +const sharedStyles = css` + :host { + display: block; + box-sizing: border-box; + padding: 24px; + } + + .floating { + position: fixed; + inset: 0 auto auto 0; + min-width: 120px; + padding: 12px; + border: 1px solid currentcolor; + background: Canvas; + color: CanvasText; + pointer-events: none; + } + + .meta { + margin-block-start: 8px; + } +`; + +function bindController( + controller: PlacementController, + triggerEl: HTMLElement | VirtualTrigger, + floatingEl: HTMLElement, + options: PlacementOptions, + onPlacement?: (next: Placement) => void +): void { + controller.start(triggerEl, floatingEl, { + ...options, + onPlacementChange: (next) => { + onPlacement?.(next); + options.onPlacementChange?.(next); + }, + }); +} + +const PLAYGROUND_DEFAULTS = { + placement: 'bottom' as Placement, + offset: 8, + crossOffset: 0, + containerPadding: 12, + shouldFlip: true, + constrainSize: false, +}; + +/** Spectrum typography utility classes (requires `typography.css` in Storybook). */ +const FIELD_TITLE = 'swc-Detail swc-Detail--sizeXS swc-Typography--emphasized'; +const FIELD_HINT = 'swc-Detail swc-Detail--sizeXS'; +const BODY_XS = 'swc-Body swc-Body--sizeXS'; +const BODY_XS_EMPHASIZED = + 'swc-Body swc-Body--sizeXS swc-Typography--emphasized'; +const DETAIL_XS = 'swc-Detail swc-Detail--sizeXS'; +const CODE_XS = 'swc-Code swc-Code--sizeXS'; + +function demoClasses( + ...parts: Array | string | undefined> +): Record { + const classes: Record = {}; + + for (const part of parts) { + if (!part) { + continue; + } + + if (typeof part === 'string') { + for (const name of part.split(/\s+/).filter(Boolean)) { + classes[name] = true; + } + continue; + } + + for (const [name, active] of Object.entries(part)) { + if (active) { + classes[name] = true; + } + } + } + + return classes; +} + +@customElement('demo-placement-playground') +export class DemoPlacementPlayground extends LitElement { + static override styles = [ + sharedStyles, + css` + .controls { + margin-block-end: 16px; + } + + .controls-layout { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr)); + gap: 20px; + align-items: start; + } + + .controls-group { + display: flex; + flex-direction: column; + gap: 12px; + min-inline-size: 0; + } + + .field { + display: flex; + flex-direction: column; + gap: 8px; + min-inline-size: 0; + } + + .field-label-group { + display: flex; + flex-direction: column; + gap: 2px; + } + + .field-label-group :is(h3, p) { + margin: 0; + } + + .field-control { + display: flex; + align-items: center; + gap: 6px; + } + + .field input[type='number'] { + box-sizing: border-box; + inline-size: 4rem; + padding: 3px 5px; + } + + .field-checkbox .field-control { + align-items: flex-start; + } + + .field-checkbox input[type='checkbox'] { + margin-block-start: 2px; + } + + .placement-picker { + display: grid; + grid-template-columns: repeat(5, 24px); + grid-template-rows: repeat(5, 24px); + gap: 3px; + padding: 8px; + border-radius: 4px; + background: color-mix(in srgb, CanvasText 8%, Canvas); + inline-size: fit-content; + } + + .placement-cell { + box-sizing: border-box; + block-size: 24px; + inline-size: 24px; + border: 1px solid color-mix(in srgb, CanvasText 55%, Canvas); + background: Canvas; + padding: 0; + } + + .placement-cell:hover { + background: color-mix(in srgb, CanvasText 12%, Canvas); + } + + .placement-cell[aria-pressed='true'] { + background: CanvasText; + border-color: CanvasText; + } + + .demo-surface { + block-size: 220px; + display: grid; + place-items: center; + } + + .demo-surface button.trigger { + box-sizing: border-box; + inline-size: 48px; + block-size: 48px; + min-inline-size: 48px; + min-block-size: 48px; + padding: 0; + } + + .floating { + min-width: 72px; + padding: 6px; + } + `, + ]; + + @property({ type: String, reflect: true }) + placement: Placement = PLAYGROUND_DEFAULTS.placement; + + @property({ type: Number, reflect: true }) + offset = PLAYGROUND_DEFAULTS.offset; + + @property({ type: Number, attribute: 'cross-offset', reflect: true }) + crossOffset = PLAYGROUND_DEFAULTS.crossOffset; + + @property({ type: Number, attribute: 'container-padding', reflect: true }) + containerPadding = PLAYGROUND_DEFAULTS.containerPadding; + + @property({ type: Boolean, attribute: 'should-flip', reflect: true }) + shouldFlip = PLAYGROUND_DEFAULTS.shouldFlip; + + @property({ type: Boolean, attribute: 'constrain-size', reflect: true }) + constrainSize = PLAYGROUND_DEFAULTS.constrainSize; + + @property({ type: String, attribute: 'actual-placement', reflect: true }) + actualPlacement: Placement | null = null; + + @query('button.trigger') triggerEl!: HTMLButtonElement; + @query('.floating') floatingEl!: HTMLDivElement; + + private controller = new PlacementController(this); + + protected override firstUpdated(): void { + this.bind(); + } + + protected override updated(changed: PropertyValues): void { + if ( + changed.has('placement') || + changed.has('offset') || + changed.has('crossOffset') || + changed.has('containerPadding') || + changed.has('shouldFlip') || + changed.has('constrainSize') + ) { + this.bind(); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback?.(); + this.controller.stop(); + } + + private bind(): void { + if (!this.triggerEl || !this.floatingEl) { + return; + } + bindController( + this.controller, + this.triggerEl, + this.floatingEl, + { + placement: this.placement, + offset: this.offset, + crossOffset: this.crossOffset, + containerPadding: this.containerPadding, + shouldFlip: this.shouldFlip, + constrainSize: this.constrainSize, + }, + (next) => { + this.actualPlacement = next; + } + ); + } + + private onPlacementCellClick(placement: Placement): void { + this.placement = placement; + } + + private onNumberInput( + property: 'offset' | 'crossOffset' | 'containerPadding', + event: Event + ): void { + const value = Number((event.target as HTMLInputElement).value); + if (Number.isFinite(value)) { + this[property] = value; + } + } + + private onShouldFlipChange(event: Event): void { + this.shouldFlip = (event.target as HTMLInputElement).checked; + } + + private renderPlacementPickerCell( + placement: Placement, + style: string + ): TemplateResult { + const selected = this.placement === placement; + return html` + + `; + } + + protected override render(): TemplateResult { + return html` +
+
+
+
+
+

Placement

+

+ Preferred side and alignment relative to the trigger. The + computed value may differ when should flip reorients. +

+
+
+ ${this.renderPlacementPickerCell( + 'top-start', + 'grid-area: 1 / 2' + )} + ${this.renderPlacementPickerCell('top', 'grid-area: 1 / 3')} + ${this.renderPlacementPickerCell('top-end', 'grid-area: 1 / 4')} + ${this.renderPlacementPickerCell( + 'left-top', + 'grid-area: 2 / 1' + )} + ${this.renderPlacementPickerCell( + 'right-top', + 'grid-area: 2 / 5' + )} + ${this.renderPlacementPickerCell('left', 'grid-area: 3 / 1')} + ${this.renderPlacementPickerCell('right', 'grid-area: 3 / 5')} + ${this.renderPlacementPickerCell( + 'left-bottom', + 'grid-area: 4 / 1' + )} + ${this.renderPlacementPickerCell( + 'right-bottom', + 'grid-area: 4 / 5' + )} + ${this.renderPlacementPickerCell( + 'bottom-start', + 'grid-area: 5 / 2' + )} + ${this.renderPlacementPickerCell('bottom', 'grid-area: 5 / 3')} + ${this.renderPlacementPickerCell( + 'bottom-end', + 'grid-area: 5 / 4' + )} +
+
+
+
+

Should flip

+

+ When enabled, move the floating element to the opposite side + if the requested placement does not fit in the viewport. + Disable to keep the requested side even when it overflows. +

+
+ +
+
+
+
+
+

Offset

+

+ Gap along the placement direction between the trigger and the + floating element (px). For example, space below the trigger + when placement is + bottom + . +

+
+ +
+
+
+

+ Cross offset +

+

+ Slide along the trigger edge (px). Changes alignment such as + + bottom-start + + vs + + bottom-end + + without changing viewport inset. +

+
+ +
+
+
+

+ Container padding +

+

+ Minimum inset from the overflow boundary (px) when flip or + shift keeps the floating element on screen. Uses clipping + ancestors capped by the visual viewport by default. +

+
+ +
+
+
+
+
+ +
+ + ${this.placement} + +
+
+ `; + } +} + +const FLIP_DEMO_ITEMS = Array.from({ length: 10 }, (_, i) => `Item ${i + 1}`); + +const flipDemoStyles = css` + .demo { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; + } + + .surface { + inline-size: 100%; + max-inline-size: 14rem; + block-size: 200px; + display: grid; + align-items: end; + justify-items: center; + outline: 1px dashed color-mix(in srgb, currentcolor 35%, transparent); + padding: 16px; + overflow: hidden; + } + + .floating { + min-width: 10rem; + pointer-events: auto; + } + + .item { + padding: 4px 0; + border-block-end: 1px solid + color-mix(in srgb, currentcolor 20%, transparent); + } + + .caption { + margin: 0; + text-align: center; + } +`; + +@customElement('demo-placement-flip') +export class DemoPlacementFlip extends LitElement { + static override styles = [sharedStyles, flipDemoStyles]; + + @property({ type: String, attribute: 'actual-placement', reflect: true }) + actualPlacement: Placement | null = null; + + @query('button') triggerEl!: HTMLButtonElement; + @query('.floating') floatingEl!: HTMLDivElement; + + private controller = new PlacementController(this); + + protected override firstUpdated(): void { + bindController( + this.controller, + this.triggerEl, + this.floatingEl, + { shouldFlip: true }, + (next) => { + this.actualPlacement = next; + } + ); + } + + override disconnectedCallback(): void { + super.disconnectedCallback?.(); + this.controller.stop(); + } + + protected override render(): TemplateResult { + return html` +
+

+ shouldFlip: true +

+
+ +
+
+ computed: ${this.actualPlacement} +
+ ${FLIP_DEMO_ITEMS.map( + (label) => html` +
+ ${label} +
+ ` + )} +
+
+
+ `; + } +} + +@customElement('demo-placement-no-flip') +export class DemoPlacementNoFlip extends LitElement { + static override styles = [ + sharedStyles, + flipDemoStyles, + css` + :host { + display: block; + margin-block-start: 32px; + } + `, + ]; + + @property({ type: String, attribute: 'actual-placement', reflect: true }) + actualPlacement: Placement | null = null; + + @query('button') triggerEl!: HTMLButtonElement; + @query('.floating') floatingEl!: HTMLDivElement; + + private controller = new PlacementController(this); + + protected override firstUpdated(): void { + bindController( + this.controller, + this.triggerEl, + this.floatingEl, + { shouldFlip: false }, + (next) => { + this.actualPlacement = next; + } + ); + } + + override disconnectedCallback(): void { + super.disconnectedCallback?.(); + this.controller.stop(); + } + + protected override render(): TemplateResult { + return html` +
+

+ shouldFlip: false +

+
+ +
+
+ computed: ${this.actualPlacement} +
+ ${FLIP_DEMO_ITEMS.map( + (label) => html` +
+ ${label} +
+ ` + )} +
+
+
+ `; + } +} + +@customElement('demo-placement-offset') +export class DemoPlacementOffset extends LitElement { + static override styles = [ + sharedStyles, + css` + :host .floating { + min-width: 7rem; + padding: 8px 10px; + } + + .surface { + display: flex; + flex-direction: column; + gap: 32px; + } + + .section-title { + margin: 0 0 12px; + text-align: center; + } + + .compare { + display: grid; + grid-template-columns: repeat(2, minmax(11rem, 1fr)); + gap: 16px 64px; + justify-content: center; + max-inline-size: 28rem; + margin-inline: auto; + } + + .example { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + text-align: center; + } + + .caption { + margin: 0; + min-block-size: 2.5rem; + } + + .anchor { + display: flex; + flex-direction: column; + align-items: center; + inline-size: 10rem; + } + + .trigger { + inline-size: 10rem; + block-size: 2rem; + margin: 0; + padding: 0; + } + + /* Reserve space below each trigger for fixed-position floating panels. */ + .anchor-space { + inline-size: 100%; + block-size: 7rem; + } + + .section--cross { + margin-block-start: 16px; + } + `, + ]; + + @query('[data-offset-demo="default-main"]') + defaultMainTrigger!: HTMLButtonElement; + @query('.floating-default-main') + defaultMainFloating!: HTMLDivElement; + @query('[data-offset-demo="large-main"]') + largeMainTrigger!: HTMLButtonElement; + @query('.floating-large-main') + largeMainFloating!: HTMLDivElement; + @query('[data-offset-demo="default-cross"]') + defaultCrossTrigger!: HTMLButtonElement; + @query('.floating-default-cross') + defaultCrossFloating!: HTMLDivElement; + @query('[data-offset-demo="large-cross"]') + largeCrossTrigger!: HTMLButtonElement; + @query('.floating-large-cross') + largeCrossFloating!: HTMLDivElement; + + private defaultMainController = new PlacementController(this); + private largeMainController = new PlacementController(this); + private defaultCrossController = new PlacementController(this); + private largeCrossController = new PlacementController(this); + + protected override firstUpdated(): void { + bindController( + this.defaultMainController, + this.defaultMainTrigger, + this.defaultMainFloating, + {} + ); + bindController( + this.largeMainController, + this.largeMainTrigger, + this.largeMainFloating, + { offset: 48 } + ); + bindController( + this.defaultCrossController, + this.defaultCrossTrigger, + this.defaultCrossFloating, + {} + ); + bindController( + this.largeCrossController, + this.largeCrossTrigger, + this.largeCrossFloating, + { crossOffset: 48 } + ); + } + + override disconnectedCallback(): void { + super.disconnectedCallback?.(); + this.defaultMainController.stop(); + this.largeMainController.stop(); + this.defaultCrossController.stop(); + this.largeCrossController.stop(); + } + + protected override render(): TemplateResult { + return html` +
+
+

+ Main axis (offset) +

+
+
+

+ Default — 8 px below +

+
+ +
+ default +
+ +
+
+
+

+ offset: 48 +

+
+ +
+ + offset: 48 + +
+ +
+
+
+
+
+

+ Cross axis (crossOffset) +

+
+
+

+ Default — centered +

+
+ +
+ default +
+ +
+
+
+

+ crossOffset: 48 +

+
+ +
+ + crossOffset: 48 + +
+ +
+
+
+
+
+ `; + } +} + +@customElement('demo-placement-constrain-size') +export class DemoPlacementConstrainSize extends LitElement { + static override styles = [ + sharedStyles, + css` + .surface { + block-size: 180px; + display: grid; + place-items: center; + outline: 1px dashed currentcolor; + overflow: hidden; + } + + .floating { + overflow: auto; + pointer-events: auto; + } + + .item { + padding: 4px 0; + border-block-end: 1px solid + color-mix(in srgb, currentcolor 20%, transparent); + } + `, + ]; + + @property({ type: Boolean, attribute: 'is-constrained', reflect: true }) + isConstrained = false; + + @query('button') triggerEl!: HTMLButtonElement; + @query('.floating') floatingEl!: HTMLDivElement; + + private controller = new PlacementController(this); + + protected override firstUpdated(): void { + bindController( + this.controller, + this.triggerEl, + this.floatingEl, + { constrainSize: true }, + () => { + this.isConstrained = this.controller.isConstrained; + } + ); + } + + override disconnectedCallback(): void { + super.disconnectedCallback?.(); + this.controller.stop(); + } + + protected override render(): TemplateResult { + const items = Array.from({ length: 24 }, (_, i) => `Item ${i + 1}`); + return html` +
+ +
+ ${items.map( + (label) => html` +
+ ${label} +
+ ` + )} +
+
+

+ isConstrained: ${this.isConstrained} +

+ `; + } +} + +@customElement('demo-placement-virtual-trigger') +export class DemoPlacementVirtualTrigger extends LitElement { + static override styles = [ + sharedStyles, + css` + .surface { + position: relative; + block-size: 280px; + display: grid; + place-items: center; + outline: 1px dashed currentcolor; + cursor: crosshair; + } + + .anchor-mark { + position: absolute; + inline-size: 16px; + block-size: 16px; + transform: translate(-50%, -50%); + pointer-events: none; + } + + .anchor-mark::before, + .anchor-mark::after { + content: ''; + position: absolute; + inset-block-start: 50%; + inset-inline-start: 50%; + inline-size: 14px; + block-size: 2px; + background: currentcolor; + transform-origin: center; + } + + .anchor-mark::before { + transform: translate(-50%, -50%) rotate(45deg); + } + + .anchor-mark::after { + transform: translate(-50%, -50%) rotate(-45deg); + } + `, + ]; + + @property({ type: Number, reflect: true }) + x = 80; + + @property({ type: Number, reflect: true }) + y = 80; + + @query('.surface') surfaceEl!: HTMLDivElement; + @query('.floating') floatingEl!: HTMLDivElement; + + private controller = new PlacementController(this); + + private virtualTrigger: VirtualTrigger = { + getBoundingClientRect: () => { + const rect = this.surfaceEl.getBoundingClientRect(); + return new DOMRect(rect.left + this.x, rect.top + this.y, 0, 0); + }, + }; + + protected override firstUpdated(): void { + this.virtualTrigger.contextElement = this.surfaceEl; + bindController(this.controller, this.virtualTrigger, this.floatingEl, {}); + } + + override disconnectedCallback(): void { + super.disconnectedCallback?.(); + this.controller.stop(); + } + + private moveAnchor(clientX: number, clientY: number): void { + const rect = this.surfaceEl.getBoundingClientRect(); + this.x = clientX - rect.left; + this.y = clientY - rect.top; + this.controller.recompute(); + } + + private onClick(event: MouseEvent): void { + this.moveAnchor(event.clientX, event.clientY); + } + + private onKeydown(event: KeyboardEvent): void { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + event.preventDefault(); + const rect = this.surfaceEl.getBoundingClientRect(); + this.moveAnchor(rect.left + rect.width / 2, rect.top + rect.height / 2); + } + + protected override render(): TemplateResult { + return html` +
+ + Click to move anchor + + +
+
+ + (${this.x}, ${this.y}) + +
+ `; + } +} + +@customElement('demo-placement-placements') +export class DemoPlacementPlacements extends LitElement { + static override styles = [ + sharedStyles, + css` + .grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 16px; + } + + .cell { + block-size: 120px; + display: grid; + place-items: center; + outline: 1px dashed color-mix(in srgb, currentcolor 40%, transparent); + position: relative; + } + + .floating { + min-width: 0; + padding: 6px 8px; + } + `, + ]; + + private placements: Placement[] = [ + 'top', + 'top-start', + 'top-end', + 'bottom', + 'bottom-start', + 'bottom-end', + 'left', + 'right', + ]; + + protected override render(): TemplateResult { + return html` +
+ ${this.placements.map( + (placement) => html` + + ` + )} +
+ `; + } +} + +@customElement('demo-placement-cell') +class DemoPlacementCell extends LitElement { + static override styles = [ + sharedStyles, + css` + :host { + padding: 0; + block-size: 100%; + } + + .cell { + block-size: 100%; + display: grid; + place-items: center; + } + `, + ]; + + @property({ type: String, reflect: true }) + placement: Placement = 'bottom'; + + @query('button') triggerEl!: HTMLButtonElement; + @query('.floating') floatingEl!: HTMLDivElement; + + private controller = new PlacementController(this); + + protected override firstUpdated(): void { + bindController(this.controller, this.triggerEl, this.floatingEl, { + placement: this.placement, + shouldFlip: false, + }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback?.(); + this.controller.stop(); + } + + protected override render(): TemplateResult { + return html` +
+ +
+ + ${this.placement} + +
+
+ `; + } +} diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts new file mode 100644 index 00000000000..940d28d19e8 --- /dev/null +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts @@ -0,0 +1,518 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { html } from 'lit'; +import type { Meta, StoryObj } from '@storybook/web-components'; + +import './demo-hosts.js'; + +import { ALL_PLACEMENTS, type Placement } from '../index.js'; + +// ──────────────── +// METADATA +// ──────────────── + +const placements = ALL_PLACEMENTS; + +const args = { + placement: 'bottom' as Placement, + offset: 8, + crossOffset: 0, + containerPadding: 12, + shouldFlip: true, + constrainSize: false, +}; + +const argTypes = { + placement: { + control: 'select', + options: placements, + description: + 'Preferred side and alignment relative to the **trigger** (hyphenated). May differ from the computed value when `shouldFlip` reorients.', + table: { + category: 'Options', + type: { summary: 'Placement' }, + defaultValue: { summary: 'bottom' }, + }, + }, + offset: { + control: 'number', + description: + 'Gap along the **placement direction** between trigger and floating element (px). For example, space below the trigger when placement is `bottom`. Not trigger padding.', + table: { + category: 'Options', + type: { summary: 'number' }, + defaultValue: { summary: '8' }, + }, + }, + crossOffset: { + control: 'number', + description: + 'Slide along the **trigger edge** (px), perpendicular to the placement direction. Adjusts alignments such as `bottom-start` vs `bottom-end`. Not trigger padding.', + table: { + category: 'Options', + type: { summary: 'number' }, + defaultValue: { summary: '0' }, + }, + }, + containerPadding: { + control: 'number', + description: + 'Minimum inset (px) from the overflow boundary for `flip`, `shift`, and `size`. Defaults to clipping ancestors capped by the visual viewport; not trigger gap. `swc-popover` exposes this as `container-padding`.', + table: { + category: 'Options', + type: { summary: 'number' }, + defaultValue: { summary: '12' }, + }, + }, + shouldFlip: { + control: 'boolean', + description: + 'When `true`, flip to the opposite side if the requested placement does not fit within the overflow boundary. When `false`, keep the requested side even if it overflows.', + table: { + category: 'Options', + type: { summary: 'boolean' }, + defaultValue: { summary: 'true' }, + }, + }, + constrainSize: { + control: 'boolean', + description: + 'When `true`, sets `maxHeight` / `maxWidth` on the floating element so long content scrolls inside the viewport.', + table: { + category: 'Options', + type: { summary: 'boolean' }, + defaultValue: { summary: 'false' }, + }, + }, +} satisfies Meta['argTypes']; + +const controllerApi = { + methods: [ + { + member: 'start(trigger, floating, options?)', + description: + 'Begin positioning; tear down any prior session and subscribe to Floating UI `autoUpdate`. Skips compute when the floating element has zero dimensions until size is available.', + }, + { + member: 'stop()', + description: + 'Tear down positioning, unsubscribe from `autoUpdate`, and clear `actualPlacement` and `isConstrained`. Called from `hostDisconnected` when the Lit host disconnects.', + }, + { + member: 'recompute()', + description: + 'Force one `computePosition` pass outside `autoUpdate` — for example after floating content reflows or a virtual trigger moves without layout events. No-op if not started.', + }, + ], + readonlyProperties: [ + { + name: 'actualPlacement', + type: 'Placement or null', + description: + 'Computed placement after `flip` (hyphenated). `null` when stopped.', + }, + { + name: 'isConstrained', + type: 'boolean', + description: + 'Whether `constrainSize` clamped height on the last compute.', + }, + ], + additionalOptions: [ + { + name: 'onPlacementChange', + type: 'callback', + default: '—', + description: + 'Called when computed placement changes. Receives the hyphenated `Placement`.', + }, + ], + types: [ + { + name: 'Placement', + description: + 'Hyphenated placement union (22 values, `swc-popover` / Floating UI).', + }, + { + name: 'VirtualTrigger', + description: + '{ getBoundingClientRect(): DOMRect; contextElement?: Element }', + }, + ], +}; + +/** + * `PlacementController` positions a floating element relative to a trigger using + * [Floating UI](https://floating-ui.com/) (`computePosition` + `autoUpdate`). Use it inside + * Lit-based custom elements for popover, picker, menu, tooltip, and other anchored + * floating UI patterns. + * + * The controller owns **geometry only** — open/close lifecycle, ARIA, focus, and dismissal + * remain the caller's responsibility. + */ +const meta: Meta = { + title: 'Controllers/Placement controller', + component: 'demo-placement-playground', + args, + argTypes, + render: (a) => html` + + `, + parameters: { + controllerApi, + docs: { + subtitle: + 'Floating UI-backed positioning for popover, picker, menu, and other anchored patterns.', + canvas: { sourceState: 'none' }, + }, + }, + tags: ['migrated', 'controller'], +}; + +export default meta; + +type Story = StoryObj; + +// ────────────────────────── +// AUTODOCS STORY +// ────────────────────────── + +export const Playground: Story = { + tags: ['autodocs', 'dev'], +}; + +// ────────────────────────── +// OVERVIEW STORY +// ────────────────────────── + +export const Overview: Story = { + tags: ['overview'], +}; + +// ────────────────────────── +// BASIC USAGE STORY +// ────────────────────────── + +/** + * ## What it does + * + * ### Positioning + * + * - Computes `top`, `left`, and `translate` on the **floating element** using + * `strategy: 'fixed'` (suitable for native popover top-layer surfaces). + * - Subscribes to scroll, resize, and layout shift via Floating UI's `autoUpdate` while + * {@link PlacementController.start} is active. + * - Waits for `document.fonts.ready` and applies iOS WebKit `visualViewport` correction + * before measuring. + * + * ### Middleware stack + * + * 1. **`offset`** — trigger-relative gap along the placement direction (`offset` option) and along the trigger edge (`crossOffset` option). + * 2. **`flip`** (when `shouldFlip: true`) — reorients when there is not enough room, respecting `containerPadding` inset from the overflow boundary. + * 3. **`shift`** — slides the floating element along the axis to stay inside the boundary, using `containerPadding` as inset. + * 4. **`size`** (when `constrainSize: true`) — clamps `maxHeight` / `maxWidth` for scrollable lists. + * + * ### What the caller owns + * + * - Showing and hiding the floating surface (`showPopover`, `hidden`, etc.). + * - ARIA (`aria-controls`, `aria-expanded`, focus trap, escape dismissal). + * - Placement styling — use `onPlacementChange` to apply CSS modifier classes; the controller + * does **not** write `placement` attributes on the floating element. + * + * ## Basic usage + * + * 1. Construct the controller in your element's constructor: `new PlacementController(this)`. + * 2. When positioning starts, call `start(trigger, floating, options)`. + * 3. When it closes or the host disconnects, call `stop()` (also runs from `hostDisconnected`). + * 4. Call `recompute()` when floating content reflows without a layout event the controller + * would observe. + * + * ```typescript + * import { LitElement, html } from 'lit'; + * import { customElement, query } from 'lit/decorators.js'; + * import { PlacementController } from + * '@spectrum-web-components/core/controllers/placement-controller.js'; + * + * @customElement('my-picker') + * export class MyPicker extends LitElement { + * @query('#trigger') trigger!: HTMLButtonElement; + * @query('#listbox') listbox!: HTMLDivElement; + * + * private placement = new PlacementController(this); + * actualPlacement: Placement = 'bottom'; + * + * open(): void { + * this.listbox.hidden = false; + * this.placement.start(this.trigger, this.listbox, { + * placement: 'bottom-start', + * onPlacementChange: (p) => { + * this.actualPlacement = p; + * }, + * }); + * } + * + * close(): void { + * this.placement.stop(); + * this.listbox.hidden = true; + * } + * } + * ``` + */ +export const Usage: Story = { + tags: ['usage', 'description-only'], +}; + +// ────────────────────────── +// BEHAVIORS STORIES +// ────────────────────────── + +/** + * Pass **`placement`** to choose the preferred side and alignment **relative to the trigger** + * in hyphenated form (`'bottom'`, `'bottom-start'`, `'top-end'`, etc.). Values align with + * `` and Floating UI (22 total). This is the **requested** placement — when + * **`shouldFlip`** is enabled, Floating UI may compute a different side; read + * **`actualPlacement`** or use **`onPlacementChange`** for the result after **`flip`**. + * + * Logical alignments (`bottom-start`, `top-end`) reverse in RTL via CSS at the consumer layer; + * physical alignments (`bottom-left`, `left-top`) stay fixed. Logical sides (`start`, `end`, + * `start-top`) normalize to physical left/right for `computePosition`. + * + * ```typescript + * this.placement.start(this.trigger, this.panel, { + * placement: 'bottom-start', + * }); + * ``` + */ +export const RequestedPlacement: Story = { + tags: ['behaviors', 'description-only'], + parameters: { 'section-order': 1 }, +}; + +/** + * Use **`offset`** for gap along the **placement direction** between trigger and floating + * element (for example 8 px below the trigger when placement is `'bottom'`). Use + * **`crossOffset`** to slide along the **trigger edge**, perpendicular to that direction + * (for example nudge a `'bottom-start'` panel further toward the trigger's start edge). + * + * Neither option sets boundary inset — use **`containerPadding`** for inset from the + * overflow boundary when **`flip`** or **`shift`** runs. + * + * ```typescript + * this.placement.start(this.trigger, this.panel, { + * offset: 48, + * }); + * + * this.placement.start(this.trigger, this.panel, { + * crossOffset: 48, + * }); + * ``` + */ +export const Offset: Story = { + tags: ['behaviors'], + render: () => html` + + `, + parameters: { 'section-order': 2 }, +}; + +/** + * Use **`containerPadding`** for minimum inset from the **overflow boundary** (px) — **not** the gap from the trigger. Passed as `padding` to Floating UI **`flip`**, **`shift`**, and (when **`constrainSize`** is enabled) **`size`** middleware. + * + * By default, Floating UI uses clipping ancestors capped by the **visual viewport** as that boundary. For typical fixed or top-layer popovers, this behaves like screen-edge inset. Inside a scrollable clipping parent, inset is measured from that container's edges instead. + * + * `` exposes this as the **`container-padding`** attribute. Use the playground **Container padding** control with the trigger near an edge to see **`flip`** and **`shift`** keep the panel further inside the boundary. + * + * ```typescript + * this.placement.start(this.trigger, this.panel, { + * containerPadding: 32, + * }); + * ``` + */ +export const ContainerPadding: Story = { + tags: ['behaviors', 'description-only'], + parameters: { 'section-order': 3 }, +}; + +/** + * With **`shouldFlip: true`** (default), Floating UI **`flip`** middleware may move the floating + * element to the **opposite side** when the requested **`placement`** does not fit within the + * overflow boundary. Typical for popovers, menus, and pickers that must stay fully visible. + * **`actualPlacement`** and **`onPlacementChange`** reflect the computed side after flip. + * + * With **`shouldFlip: false`**, the floating element stays on the requested side even if it + * overflows — useful for tooltips that must never jump above the trigger, or when a pointer / + * design asset is tied to a specific side. Disabling flip does **not** disable **`shift`**; + * the panel may still slide along an axis to reduce clipping. + * + * ```typescript + * // Popover / menu — flip when needed + * this.placement.start(this.trigger, this.panel, { + * shouldFlip: true, + * }); + * + * // Tooltip — stay on requested side + * this.placement.start(this.trigger, this.tip, { + * shouldFlip: false, + * }); + * ``` + */ +export const ShouldFlip: Story = { + tags: ['behaviors'], + render: () => html` + + + `, + parameters: { 'section-order': 4 }, +}; + +/** + * With **`constrainSize: true`**, Floating UI **`size`** middleware sets inline + * **`maxHeight`** and **`maxWidth`** on the floating element so long list content scrolls + * inside the viewport instead of overflowing off-screen. Readonly **`isConstrained`** is + * `true` when height was clamped on the last compute. + * + * Use for picker, menu, and combobox list surfaces. Leave disabled for compact popovers and + * tooltips. Not part of the `` public API — enable only on hosts that compose + * the controller directly. + * + * ```typescript + * this.placement.start(this.trigger, this.listbox, { + * constrainSize: true, + * }); + * ``` + */ +export const ConstrainSize: Story = { + tags: ['behaviors'], + render: () => html` + + `, + parameters: { 'section-order': 5 }, +}; + +/** + * Use **`onPlacementChange`** when styling or host state must track the **computed** placement + * after middleware runs — on first open and whenever **`flip`** reorients after scroll or + * resize. The callback receives the same hyphenated value as **`actualPlacement`**; the + * controller does **not** write placement classes or attributes on the floating element. + * + * ```typescript + * this.placement.start(this.trigger, this.panel, { + * onPlacementChange: (placement) => { + * this.actualPlacement = placement; + * }, + * }); + * ``` + */ +export const OnPlacementChange: Story = { + tags: ['behaviors', 'description-only'], + parameters: { 'section-order': 6 }, +}; + +/** + * Pass a **`VirtualTrigger`** instead of a DOM element when the anchor is a **virtual point** + * — for example a click coordinate, text selection rect, or canvas hit-test. Implement + * {@link VirtualTrigger} with **`getBoundingClientRect()`** returning viewport coordinates; + * optionally set **`contextElement`** as the clipping root inside scrollable regions. + * + * Virtual triggers use a curated fallback list for **`flip`** (see `getFallbackPlacements()`) + * because there is no element box to measure. Call **`recompute()`** when coordinates change + * without DOM mutation (for example after the user clicks a new point). + * + * ```typescript + * const virtualTrigger: VirtualTrigger = { + * getBoundingClientRect: () => + * DOMRect.fromRect({ x: clientX, y: clientY, width: 0, height: 0 }), + * contextElement: this.surface, + * }; + * + * this.placement.start(virtualTrigger, this.menu); + * ``` + */ +export const VirtualTrigger: Story = { + tags: ['behaviors'], + render: () => html` + + `, + parameters: { 'section-order': 7 }, +}; + +// ──────────────────────────────── +// ACCESSIBILITY STORIES +// ──────────────────────────────── + +/** + * ### Features + * + * The `PlacementController` handles **geometry only** — it does not manage focus, + * keyboard dismissal, or ARIA for floating surfaces. Consumer components remain + * responsible for accessible open/close behavior. + * + * #### What the controller provides + * + * - Keeps the floating element positioned relative to its trigger while open, including + * after scroll, resize, and layout shift via Floating UI `autoUpdate`. + * - Surfaces computed placement on **`actualPlacement`** and **`onPlacementChange`** so + * callers can apply side-specific styling without guessing orientation. + * + * #### What the caller must provide + * + * - **`aria-expanded`**, **`aria-controls`**, and appropriate roles on trigger and + * floating content (`dialog`, `listbox`, `tooltip`, etc.). + * - Focus management — move focus into modal popovers, restore focus on close, trap + * focus when required. + * - Keyboard dismissal — typically Escape to close interactive surfaces. + * - Visibility — the controller does not show or hide the floating element; use + * `hidden`, `showPopover()`, or similar in the host. + * + * ### Best practices + * + * - Do not rely on placement alone for accessible naming — provide labels on the + * trigger and floating content independently of position. + * - When **`shouldFlip`** is enabled, ensure visual affordances (if any) follow + * **`actualPlacement`**, not only the requested **`placement`** option. + * - For tooltips, pair **`shouldFlip: false`** with host logic that avoids obscuring + * essential trigger labels when overflow occurs. + * - Call **`stop()`** when the floating surface closes so `autoUpdate` does not keep + * running for detached or hidden content. + */ +export const Accessibility: Story = { + tags: ['a11y', 'description-only'], +}; + +// ────────────────────────── +// APPENDIX +// ────────────────────────── + +/** + * ### Relationship to 1st-gen `PlacementController` + * + * The 2nd-gen controller is a **focused subset** of the 1st-gen + * `PlacementController`: single `autoUpdate` channel, hyphenated placements aligned + * with ``, callback-based placement surfacing, and opt-in `constrainSize`. + * + * ### See also + * + * - [Floating UI documentation](https://floating-ui.com/docs/computePosition) + * - [Floating UI middleware](https://floating-ui.com/docs/middleware) + * - [Popover migration plan](https://github.com/adobe/spectrum-web-components/blob/main/CONTRIBUTOR-DOCS/03_project-planning/03_components/popover/migration-plan.md) + */ +export const Appendix: Story = { + tags: ['description-only', 'appendix'], +}; diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts new file mode 100644 index 00000000000..2a6de8f11ba --- /dev/null +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -0,0 +1,167 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { expect } from '@storybook/test'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; + +import '../stories/demo-hosts.js'; + +import { getComponent } from '../../../../swc/utils/test-utils.js'; +import type { + DemoPlacementFlip, + DemoPlacementPlayground, + DemoPlacementVirtualTrigger, +} from '../stories/demo-hosts.js'; +import meta, { + Playground, + ShouldFlip, + VirtualTrigger, +} from '../stories/placement-controller.stories.js'; + +function readTranslate(el: HTMLElement): [number, number] { + const match = el.style.translate.match(/^(-?\d*\.?\d+)px\s+(-?\d*\.?\d+)px$/); + if (!match) { + return [0, 0]; + } + return [Number(match[1]), Number(match[2])]; +} + +function nextFrames(count = 2): Promise { + return new Promise((resolve) => { + let remaining = count; + const tick = (): void => { + remaining -= 1; + if (remaining <= 0) { + resolve(); + } else { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }); +} + +export default { + ...meta, + title: 'Controllers/Placement controller/Tests', + parameters: { ...meta.parameters, docs: { disable: true, page: null } }, + tags: ['!autodocs', 'dev'], +} as Meta; + +export const AlignsStartAndEnd: Story = { + ...Playground, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-playground' + ); + await nextFrames(); + + await step( + 'bottom-start and bottom-end differ on the cross axis', + async () => { + host.placement = 'bottom-start'; + await nextFrames(); + const [startX] = readTranslate(host.floatingEl); + + host.placement = 'bottom-end'; + await nextFrames(); + const [endX] = readTranslate(host.floatingEl); + + expect(startX).not.toBe(endX); + } + ); + }, +}; + +export const PositionsBelowTrigger: Story = { + ...Playground, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-playground' + ); + await nextFrames(); + + await step('reports computed placement', async () => { + expect(host.actualPlacement).toBe('bottom'); + }); + + await step('floating element sits below trigger', async () => { + const triggerRect = host.triggerEl.getBoundingClientRect(); + const [, y] = readTranslate(host.floatingEl); + expect(y).toBeGreaterThanOrEqual(Math.floor(triggerRect.bottom)); + }); + }, +}; + +export const FlipReorients: Story = { + ...ShouldFlip, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-flip' + ); + await nextFrames(); + + await step('flips away from bottom when no room', async () => { + expect(host.actualPlacement).toBeTruthy(); + expect(host.actualPlacement).not.toBe('bottom'); + }); + }, +}; + +export const VirtualTriggerMoves: Story = { + ...VirtualTrigger, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-virtual-trigger' + ); + await nextFrames(); + + const [, initialY] = readTranslate(host.floatingEl); + + await step('click moves the floating element', async () => { + const rect = host.surfaceEl.getBoundingClientRect(); + host.surfaceEl.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + composed: true, + clientX: rect.left + 200, + clientY: rect.top + 200, + }) + ); + await nextFrames(); + const [, nextY] = readTranslate(host.floatingEl); + expect(nextY).toBeGreaterThan(initialY); + }); + }, +}; + +export const StopOnDisconnect: Story = { + ...Playground, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-playground' + ); + await nextFrames(); + const before = host.floatingEl.style.translate; + + await step('disconnect freezes translate', async () => { + host.remove(); + window.dispatchEvent(new Event('resize')); + await nextFrames(); + expect(host.floatingEl.style.translate).toBe(before); + }); + }, +}; diff --git a/2nd-gen/packages/core/package.json b/2nd-gen/packages/core/package.json index 86b4009e996..d3177cac51c 100644 --- a/2nd-gen/packages/core/package.json +++ b/2nd-gen/packages/core/package.json @@ -75,6 +75,14 @@ "types": "./dist/controllers/language-resolution.d.ts", "import": "./dist/controllers/language-resolution.js" }, + "./controllers/placement-controller": { + "types": "./dist/controllers/placement-controller/index.d.ts", + "import": "./dist/controllers/placement-controller/index.js" + }, + "./controllers/placement-controller/index.js": { + "types": "./dist/controllers/placement-controller/index.d.ts", + "import": "./dist/controllers/placement-controller/index.js" + }, "./element": { "types": "./dist/element/index.d.ts", "import": "./dist/element/index.js" @@ -190,6 +198,12 @@ "controllers/language-resolution.js": [ "dist/controllers/language-resolution.d.ts" ], + "controllers/placement-controller": [ + "dist/controllers/placement-controller/index.d.ts" + ], + "controllers/placement-controller/index.js": [ + "dist/controllers/placement-controller/index.d.ts" + ], "element": [ "dist/element/index.d.ts" ], @@ -235,6 +249,7 @@ } }, "dependencies": { + "@floating-ui/dom": "1.7.4", "@lit-labs/observers": "2.0.2", "lit": "^2.5.0 || ^3.1.3" }, diff --git a/2nd-gen/packages/core/vite.config.js b/2nd-gen/packages/core/vite.config.js index f91fd99b164..0fc7c640d43 100644 --- a/2nd-gen/packages/core/vite.config.js +++ b/2nd-gen/packages/core/vite.config.js @@ -107,6 +107,7 @@ export default defineConfig({ id.startsWith('lit/') || id.startsWith('@lit/') || id.startsWith('@lit-labs/') || + id.startsWith('@floating-ui/') || id.startsWith('@spectrum-web-components/core/') ); }, diff --git a/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx b/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx index 274cda1adb8..dd7f1302e50 100644 --- a/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx +++ b/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx @@ -1,10 +1,7 @@ import { Meta, Title, - Primary, - Controls, Stories, - ArgTypes, Description, Subtitle, HeaderMdx, @@ -65,37 +62,16 @@ export const ConditionalSection = ({ tag, title, hideTitle = false }) => { ); }; -export const ConditionalAPISection = () => { -const resolvedOf = useOf('meta', ['meta']); -const tags = resolvedOf?.csfFile?.meta?.tags ?? []; -const hasCustomAPIDocs = tags.includes('api') || Object.values(resolvedOf.csfFile.stories).some( -(story) => story.tags?.includes('api') +export const ConditionalAPIReference = () => ( + +<> + + API + + + ); - if (!hasCustomAPIDocs) { - return ( - <> - API - - - - - ); - } - - return ( - <> - API - - - -
- - - ); - -}; - export const ConditionalGettingStarted = () => { const resolvedOf = useOf('meta', ['meta']); @@ -124,9 +100,7 @@ export const ConditionalGettingStarted = () => { -## API - - + diff --git a/2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx b/2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx index 0b7e53d27c7..1d334bb8676 100644 --- a/2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx +++ b/2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { HeaderMdx, useOf } from '@storybook/addon-docs/blocks'; +import { HeaderMdx, Markdown, useOf } from '@storybook/addon-docs/blocks'; import type { Attribute, ClassField, @@ -78,11 +78,313 @@ const tableStyle: React.CSSProperties = { /** Storybook argType shape (subset we care about). */ interface ArgType { options?: string[]; + description?: string; + control?: string | { type?: string }; table?: { + category?: string; type?: { summary?: string }; + defaultValue?: { summary?: string }; }; } +interface ControllerApiMethod { + member: string; + description: string; +} + +interface ControllerApiOption { + name: string; + type?: string; + default?: string; + description: string; +} + +interface ControllerApiType { + name: string; + description: string; +} + +interface ControllerApiEvent { + name: string; + description: string; +} + +export interface ControllerApiReference { + methods?: ControllerApiMethod[]; + readonlyProperties?: ControllerApiOption[]; + additionalOptions?: ControllerApiOption[]; + types?: ControllerApiType[]; + events?: ControllerApiEvent[]; +} + +function inferArgTypeSummary(name: string, argType: ArgType): string { + if (argType.table?.type?.summary) { + return argType.table.type.summary; + } + + if (argType.options?.length) { + return argType.options.map((option) => `'${option}'`).join(' | '); + } + + const control = argType.control; + const controlType = typeof control === 'string' ? control : control?.type; + + if (controlType === 'boolean') { + return 'boolean'; + } + + if (controlType === 'number') { + return 'number'; + } + + return name; +} + +function DescriptionCell({ children }: { children?: string }) { + if (!children) { + return null; + } + + return {children}; +} + +function ControllerMethodsTable({ + methods, +}: { + methods: ControllerApiMethod[]; +}) { + if (methods.length === 0) { + return null; + } + + return ( + <> + + Methods + +
+ + + + + + + + + {methods.map((method) => ( + + + + + ))} + +
MemberDescription
+ {method.member} + + {method.description} +
+
+ + ); +} + +function ControllerReadonlyPropertiesTable({ + properties, +}: { + properties: ControllerApiOption[]; +}) { + if (properties.length === 0) { + return null; + } + + return ( + <> + + Readonly properties + +
+ + + + + + + + + + {properties.map((property) => ( + + + + + + ))} + +
PropertyTypeDescription
+ {property.name} + {property.type ? {property.type} : '-'} + {property.description} +
+
+ + ); +} + +function ControllerOptionsTable({ + argTypes, + additionalOptions = [], +}: { + argTypes: Record; + additionalOptions?: ControllerApiOption[]; +}) { + const rows: ControllerApiOption[] = [ + ...Object.entries(argTypes) + .filter(([, argType]) => argType.table?.category === 'Options') + .map(([name, argType]) => ({ + name, + type: inferArgTypeSummary(name, argType), + default: argType.table?.defaultValue?.summary ?? '—', + description: argType.description ?? '', + })), + ...additionalOptions, + ]; + + if (rows.length === 0) { + return null; + } + + return ( + <> + + Options + +
+ + + + + + + + + + + {rows.map((option) => ( + + + + + + + ))} + +
OptionTypeDefaultDescription
+ {option.name} + {option.type ? {option.type} : '-'} + {option.default != null ? {option.default} : '-'} + + {option.description} +
+
+ + ); +} + +function ControllerTypesTable({ types }: { types: ControllerApiType[] }) { + if (types.length === 0) { + return null; + } + + return ( + <> + + Types + +
+ + + + + + + + + {types.map((type) => ( + + + + + ))} + +
TypeDescription
+ {type.name} + + {type.description} +
+
+ + ); +} + +function ControllerEventsTable({ events }: { events: ControllerApiEvent[] }) { + if (events.length === 0) { + return null; + } + + return ( + <> + + Events + +
+ + + + + + + + + {events.map((event) => ( + + + + + ))} + +
NameDescription
+ {event.name} + + {event.description} +
+
+ + ); +} + +function ControllerApiTables({ + controllerApi, + argTypes, +}: { + controllerApi: ControllerApiReference; + argTypes: Record; +}) { + return ( + <> + + + + + + + ); +} + function PropertiesTable({ members, attributes, @@ -303,19 +605,40 @@ function CssPartsTable({ cssParts }: { cssParts: CssPart[] }) { // ──────────────────────────── /** - * Custom API reference tables sourced directly from the Custom Elements - * Manifest. Renders categorized, read-only tables for Properties, Slots, - * Events, CSS Custom Properties, and CSS Parts. + * Custom API reference tables sourced from the Custom Elements Manifest for + * components, or from `parameters.controllerApi` plus playground `argTypes` for + * controllers. */ export function ApiTable() { const resolvedOf = useOf('meta', ['meta']); const meta = resolvedOf.csfFile?.meta as { component?: string; argTypes?: Record; + tags?: string[]; + parameters?: { + controllerApi?: ControllerApiReference; + }; }; - const tagName = meta?.component; + const tags = resolvedOf.preparedMeta?.tags ?? meta?.tags ?? []; const argTypes = resolvedOf.preparedMeta?.argTypes ?? meta?.argTypes ?? {}; + const controllerApi = + resolvedOf.preparedMeta?.parameters?.controllerApi ?? + meta?.parameters?.controllerApi; + + if (tags.includes('controller')) { + if (!controllerApi) { + return

No controller API data available.

; + } + + return ( + } + /> + ); + } + const tagName = meta?.component; const cem = window.__STORYBOOK_CUSTOM_ELEMENTS_MANIFEST__; if (!cem || !tagName) { return

No API data available.

; diff --git a/yarn.lock b/yarn.lock index 161b576460f..1d5ec403e77 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6656,6 +6656,7 @@ __metadata: version: 0.0.0-use.local resolution: "@spectrum-web-components/core@workspace:2nd-gen/packages/core" dependencies: + "@floating-ui/dom": "npm:1.7.4" "@lit-labs/observers": "npm:2.0.2" glob: "npm:11.0.3" lit: "npm:^2.5.0 || ^3.1.3" From 0d36de64c3659d1bfead69545469c5aa99587acb Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Fri, 22 May 2026 18:21:47 +0200 Subject: [PATCH 03/27] fix(placement-controller): apply review findings Address correctness bugs, plan-deviating defaults, an iOS regression risk, and minor structural issues surfaced during code review. Correctness - toFloatingPlacement: logical-side + physical-alignment placements (start-top, start-bottom, end-top, end-bottom) previously returned invalid Floating UI strings (left-top, left-bottom, right-top, right-bottom) because the logical-side branch did not consult PHYSICAL_ALIGNMENT_TO_FLOATING. They now resolve correctly to left-start / left-end / right-start / right-end. - fromFloatingPlacement: Floating UI's left-start, left-end, right-start, right-end are not in the SWC Placement union; the function now maps them back to the physical equivalents (left-top / left-bottom / right-top / right-bottom). The previously unchecked cast is gone. Defaults aligned with the popover migration plan - DEFAULT_OFFSET: 8 to 0 (controller is now neutral; each consuming component sets its own pattern-specific default). - DEFAULT_CONTAINER_PADDING: 12 to 8 (matches 1st-gen REQUIRED_DISTANCE_TO_EDGE and Spectrum guidance). - Storybook args and argType defaults updated to match. Behavior - Restored an iOS WebKit visualViewport listener (passive, rAF-coalesced) so positioning stays correct when the URL bar, pinch-zoom, or virtual keyboard shifts the visual viewport without triggering events that Floating UI's autoUpdate observes. The matching offset compensation in computePlacement was already present; only the trigger was missing. - Comment on the single autoUpdate channel explaining the intentional difference from 1st-gen, which closed the overlay on ancestor scroll rather than repositioning. Structure - Added a reserved PlacementHostConfig interface and updated the constructor to accept an optional config argument, so future integration hooks (e.g. tip-element resolver for arrow middleware) can be added without a constructor signature change. - Cached isWebKit() per session so computePlacement no longer runs a UA regex per autoUpdate tick. - Removed the internal-only getFallbackPlacements re-export from src/index.ts (it is not part of the public surface). - Expanded JSDoc on actualPlacement (initial-value semantics), fromFloatingPlacement (invariants), and toPlacementClassSuffix (why the indirection exists). Tests - Added play-function stories covering the placement-conversion fix end to end, the logical-side placements computing valid coordinates, constrainSize applying max-height, shouldFlip: false preserving the requested side, and rapid start() calls replacing the prior session. SWC-1996 --- 2nd-gen/packages/core/controllers/index.ts | 1 + .../controllers/placement-controller/index.ts | 1 + .../placement-controller/src/index.ts | 5 +- .../src/placement-controller.ts | 125 +++++++++++- .../src/placement-conversion.ts | 49 ++++- .../placement-controller/src/types.ts | 25 ++- .../stories/demo-hosts.ts | 4 +- .../stories/placement-controller.stories.ts | 10 +- .../test/placement-controller.test.ts | 184 ++++++++++++++++++ 9 files changed, 378 insertions(+), 26 deletions(-) diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index dc49f55b6b8..6d1935416b3 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -32,6 +32,7 @@ export { toFloatingPlacement, toPlacementClassSuffix, type Placement, + type PlacementHostConfig, type PlacementOptions, type VirtualTrigger, } from './placement-controller/index.js'; diff --git a/2nd-gen/packages/core/controllers/placement-controller/index.ts b/2nd-gen/packages/core/controllers/placement-controller/index.ts index d74b08417c3..fd8667e3ca1 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/index.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/index.ts @@ -17,6 +17,7 @@ export { toFloatingPlacement, toPlacementClassSuffix, type Placement, + type PlacementHostConfig, type PlacementOptions, type VirtualTrigger, } from './src/placement-controller.js'; diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/index.ts b/2nd-gen/packages/core/controllers/placement-controller/src/index.ts index 2ea2e19e6dc..7ca4a8e0a82 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/index.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/index.ts @@ -17,7 +17,10 @@ export { toFloatingPlacement, toPlacementClassSuffix, type Placement, + type PlacementHostConfig, type PlacementOptions, type VirtualTrigger, } from './placement-controller.js'; -export { getFallbackPlacements } from './fallback-placements.js'; +// Note: `getFallbackPlacements` is intentionally not re-exported. It is an +// implementation detail of the `flip` middleware composition and is not part +// of the public surface. diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts index a817be91fc7..53f2541933e 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts @@ -25,15 +25,28 @@ import { fromFloatingPlacement, toFloatingPlacement, } from './placement-conversion.js'; -import type { Placement, PlacementOptions, VirtualTrigger } from './types.js'; +import type { + Placement, + PlacementHostConfig, + PlacementOptions, + VirtualTrigger, +} from './types.js'; /** Minimum height for the floating content when `constrainSize` is enabled. */ const MIN_FLOATING_HEIGHT = 100; const DEFAULT_PLACEMENT: Placement = 'bottom'; -const DEFAULT_OFFSET = 8; +// Neutral by design — controller does not impose a trigger gap. Each +// consuming component (popover, picker, menu, …) sets its own default per the +// popover migration plan: "we default to 0 to make the controller-host +// contract neutral. Each downstream first-party component sets the +// pattern-specific default in its own migration." +const DEFAULT_OFFSET = 0; const DEFAULT_CROSS_OFFSET = 0; -const DEFAULT_CONTAINER_PADDING = 12; +// Matches 1st-gen `REQUIRED_DISTANCE_TO_EDGE` and Spectrum guidance for +// minimum distance from the overflow boundary. Used by `flip`, `shift`, and +// (when enabled) `size` middleware. +const DEFAULT_CONTAINER_PADDING = 8; const DEFAULT_SHOULD_FLIP = true; /** @@ -66,6 +79,12 @@ type ActiveSession = { trigger: HTMLElement | VirtualTrigger; floating: HTMLElement; options: PlacementOptions; + + /** + * Cached `isWebKit()` result so {@link computePlacement} doesn't run a UA + * regex per `autoUpdate` tick. + */ + isWebKit: boolean; }; /** @@ -81,7 +100,7 @@ type ActiveSession = { * ```typescript * this.placement.start(this.trigger, this.dialog, { * placement: 'bottom-start', - * offset: 8, + * offset: 0, * crossOffset: 0, * shouldFlip: true, * onPlacementChange: (p) => { @@ -97,6 +116,14 @@ export class PlacementController implements ReactiveController { /** * The computed placement after `flip` reorients (hyphenated). `null` when * {@link stop} has been called. + * + * Set synchronously to the requested {@link PlacementOptions.placement} + * (or {@link DEFAULT_PLACEMENT}) when {@link start} is called, then + * updated to the value returned by `computePosition` once measurement + * resolves. {@link PlacementOptions.onPlacementChange} fires only when the + * computed value differs from the synchronous initial value — consumers + * that need a "first compute resolved" signal should read this property + * after their own open transition completes. */ public actualPlacement: Placement | null = null; @@ -106,12 +133,23 @@ export class PlacementController implements ReactiveController { */ public isConstrained = false; + /** + * Reserved configuration object. Currently unused; declared for forward + * compatibility so consuming components can pass host-level integration + * hooks (e.g. a tip-element resolver for future `arrow` middleware + * integration) without a constructor signature change. + */ + private readonly config: PlacementHostConfig; + /** * Registers this controller on `host` via `addController`. * * @param host - Reactive element that owns the floating surface lifecycle. + * @param config - Optional host configuration. Reserved for forward + * compatibility (currently has no required fields). */ - constructor(host: ReactiveControllerHost) { + constructor(host: ReactiveControllerHost, config: PlacementHostConfig = {}) { + this.config = config; host.addController(this); } @@ -131,12 +169,74 @@ export class PlacementController implements ReactiveController { options: PlacementOptions = {} ): void { this.stop(); - this.session = { trigger, floating, options }; + const session: ActiveSession = { + trigger, + floating, + options, + isWebKit: isWebKit(), + }; + this.session = session; this.actualPlacement = options.placement ?? DEFAULT_PLACEMENT; - this.cleanup = autoUpdate(trigger, floating, () => { + // Single `autoUpdate` channel — receives `ancestorScroll`, + // `ancestorResize`, `elementResize`, and `layoutShift` by default. This + // is a deliberate change from 1st-gen, which used a second channel with + // `ancestorScroll: false` purely to detect ancestor scroll for the + // "close overlay on ancestor update" behavior baked into the 1st-gen + // overlay model. The 2nd-gen controller owns geometry only — the + // caller decides whether ancestor scroll should close the surface — + // so we just reposition like any other event. + const autoUpdateCleanup = autoUpdate(trigger, floating, () => { void this.computePlacement(); }); + + // iOS WebKit `visualViewport` recompute channel. Floating UI's + // `autoUpdate` doesn't observe `window.visualViewport`, so without this + // an open floating element can drift away from its trigger on WebKit + // (iOS Safari, WKWebView, desktop Safari while pinch-zoomed) when the + // URL bar shows/hides, the virtual keyboard opens, etc. — until the + // next event `autoUpdate` does observe. Listeners are passive and + // rAF-coalesced so a burst of resize/scroll events compresses to one + // compute per frame. The matching `visualViewport.offset*` correction + // lives in `computePlacement`. + let visualViewportCleanup: (() => void) | undefined; + const visualViewport = window.visualViewport; + if (session.isWebKit && visualViewport) { + let rafId = 0; + let cancelled = false; + const onViewportChange = (): void => { + if (cancelled || rafId) { + return; + } + rafId = requestAnimationFrame(() => { + rafId = 0; + if (cancelled) { + return; + } + void this.computePlacement(); + }); + }; + visualViewport.addEventListener('resize', onViewportChange, { + passive: true, + }); + visualViewport.addEventListener('scroll', onViewportChange, { + passive: true, + }); + visualViewportCleanup = () => { + cancelled = true; + if (rafId) { + cancelAnimationFrame(rafId); + rafId = 0; + } + visualViewport.removeEventListener('resize', onViewportChange); + visualViewport.removeEventListener('scroll', onViewportChange); + }; + } + + this.cleanup = () => { + visualViewportCleanup?.(); + autoUpdateCleanup(); + }; } /** @@ -194,7 +294,7 @@ export class PlacementController implements ReactiveController { return; } - if (isWebKit()) { + if (session.isWebKit) { await new Promise((resolve) => requestAnimationFrame(() => resolve()) ); @@ -268,7 +368,7 @@ export class PlacementController implements ReactiveController { let translateX = x; let translateY = y; const visualViewport = window.visualViewport; - if (visualViewport && isWebKit()) { + if (visualViewport && session.isWebKit) { translateX -= visualViewport.offsetLeft; translateY -= visualViewport.offsetTop; } @@ -287,7 +387,12 @@ export class PlacementController implements ReactiveController { } } -export type { Placement, PlacementOptions, VirtualTrigger } from './types.js'; +export type { + Placement, + PlacementHostConfig, + PlacementOptions, + VirtualTrigger, +} from './types.js'; export { ALL_PLACEMENTS } from './types.js'; export { fromFloatingPlacement, diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts index 4654ba3e613..a745e329f0c 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts @@ -21,7 +21,12 @@ const LOGICAL_TO_PHYSICAL: Record = { end: 'right', }; -/** Physical cross-axis labels mapped to Floating UI start/end on each primary side. */ +/** + * Physical cross-axis labels mapped to Floating UI's start/end alignment on + * each primary side. Floating UI only supports `start` / `end` alignment + * suffixes; SWC exposes physical (`top`/`bottom`/`left`/`right`) cross-axis + * names that this table translates. + */ const PHYSICAL_ALIGNMENT_TO_FLOATING: Record< string, Record @@ -32,13 +37,29 @@ const PHYSICAL_ALIGNMENT_TO_FLOATING: Record< right: { top: 'right-start', bottom: 'right-end' }, }; +/** + * Reverse lookup for Floating UI placements that aren't valid SWC Placement + * values. Used by {@link fromFloatingPlacement} to map the four Floating UI + * outputs that have no direct SWC equivalent (`left-start`, `left-end`, + * `right-start`, `right-end`) back into the SWC union. Other Floating UI + * placements are already valid Placement values and pass through unchanged. + */ +const FLOATING_TO_SWC_PLACEMENT: Partial> = + { + 'left-start': 'left-top', + 'left-end': 'left-bottom', + 'right-start': 'right-top', + 'right-end': 'right-bottom', + }; + /** * Normalize a hyphenated {@link Placement} for Floating UI's `computePosition`. * * - Logical **sides** (`start`, `end`) map to `left` / `right`. * - Logical **alignments** (`bottom-start`, `top-end`) pass through unchanged. - * - Physical alignments (`bottom-left`, `left-top`) map to the nearest Floating - * UI placement (LTR positioning math; RTL styling stays in CSS). + * - Physical alignments (`bottom-left`, `left-top`, `start-top`, etc.) map to + * the nearest Floating UI placement (LTR positioning math; RTL styling + * stays in CSS). * * @param placement - Customer-facing hyphenated placement. * @returns Placement value understood by Floating UI. @@ -51,6 +72,14 @@ export function toFloatingPlacement(placement: Placement): FloatingPlacement { if (!alignment) { return physicalPrimary as FloatingPlacement; } + // For logical sides combined with a physical sub-alignment + // (e.g. `start-top`, `end-bottom`), use the physical-alignment table on + // the resolved physical side. Without this, the function returns + // strings like `left-top` that aren't valid Floating UI placements. + const mapped = PHYSICAL_ALIGNMENT_TO_FLOATING[physicalPrimary]?.[alignment]; + if (mapped) { + return mapped; + } const physicalAlignment = LOGICAL_TO_PHYSICAL[alignment] ?? alignment; return `${physicalPrimary}-${physicalAlignment}` as FloatingPlacement; } @@ -75,16 +104,24 @@ export function toFloatingPlacement(placement: Placement): FloatingPlacement { * Surface a Floating UI placement as the public hyphenated {@link Placement} * form for `actualPlacement` and `onPlacementChange`. * + * Floating UI emits four placements that don't appear in the SWC `Placement` + * union (`left-start`, `left-end`, `right-start`, `right-end`) — those map + * back to their physical equivalents (`left-top`, `left-bottom`, `right-top`, + * `right-bottom`). The remaining eight Floating UI placements are already + * valid SWC `Placement` values. + * * @param placement - Placement returned by Floating UI after `flip`. - * @returns Customer-facing hyphenated placement. + * @returns Customer-facing hyphenated placement in the SWC `Placement` union. */ export function fromFloatingPlacement(placement: FloatingPlacement): Placement { - return placement as Placement; + return FLOATING_TO_SWC_PLACEMENT[placement] ?? (placement as Placement); } /** * Derive a CSS modifier suffix from a hyphenated placement (for example - * `.swc-Popover--bottom-start`). + * `.swc-Popover--bottom-start`). Currently a pass-through — kept as an + * indirection so consuming components can adopt a single helper instead of + * inlining the placement-to-class conversion. * * @param placement - Customer-facing hyphenated placement. * @returns Class suffix safe to append after `--` in a BEM modifier. diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts index d862ca2921e..eca137f83df 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts @@ -68,6 +68,22 @@ export interface VirtualTrigger { contextElement?: Element; } +// ───────────────────────── +// HOST CONFIG +// ───────────────────────── + +/** + * Host-level configuration passed once to the {@link PlacementController} + * constructor. Reserved for forward compatibility — currently has no + * required fields. Future hooks (for example a `tipElement` resolver for + * `arrow` middleware) will be added here so existing call sites do not need + * to change. + */ +export interface PlacementHostConfig { + /** Reserved for future use. */ + readonly _reserved?: never; +} + // ───────────────────────── // OPTIONS // ───────────────────────── @@ -99,7 +115,12 @@ export interface PlacementOptions { * viewport padding — see {@link containerPadding} for edge inset when * `flip` / `shift` run. * - * @default 8 + * Defaults to `0` so the controller-host contract stays neutral; each + * consuming component sets its own pattern-specific default (for example, + * `` defaults `offset` to `0`, downstream patterns like + * `` may raise it). + * + * @default 0 */ offset?: number; @@ -130,7 +151,7 @@ export interface PlacementOptions { * Matches ``'s `container-padding` attribute; consumer * components expose this publicly and pass it through to the controller. * - * @default 12 + * @default 8 */ containerPadding?: number; diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts index b35c7ef6501..199bb10b3db 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts @@ -81,9 +81,9 @@ function bindController( const PLAYGROUND_DEFAULTS = { placement: 'bottom' as Placement, - offset: 8, + offset: 0, crossOffset: 0, - containerPadding: 12, + containerPadding: 8, shouldFlip: true, constrainSize: false, }; diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts index 940d28d19e8..d21099b0610 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts @@ -25,9 +25,9 @@ const placements = ALL_PLACEMENTS; const args = { placement: 'bottom' as Placement, - offset: 8, + offset: 0, crossOffset: 0, - containerPadding: 12, + containerPadding: 8, shouldFlip: true, constrainSize: false, }; @@ -47,11 +47,11 @@ const argTypes = { offset: { control: 'number', description: - 'Gap along the **placement direction** between trigger and floating element (px). For example, space below the trigger when placement is `bottom`. Not trigger padding.', + 'Gap along the **placement direction** between trigger and floating element (px). For example, space below the trigger when placement is `bottom`. Not trigger padding. Defaults to `0` so the controller stays neutral — each consuming component sets its own pattern-specific default.', table: { category: 'Options', type: { summary: 'number' }, - defaultValue: { summary: '8' }, + defaultValue: { summary: '0' }, }, }, crossOffset: { @@ -71,7 +71,7 @@ const argTypes = { table: { category: 'Options', type: { summary: 'number' }, - defaultValue: { summary: '12' }, + defaultValue: { summary: '8' }, }, }, shouldFlip: { diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts index 2a6de8f11ba..dd803bd24e5 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -15,12 +15,16 @@ import type { Meta, StoryObj as Story } from '@storybook/web-components'; import '../stories/demo-hosts.js'; import { getComponent } from '../../../../swc/utils/test-utils.js'; +import { fromFloatingPlacement, toFloatingPlacement } from '../index.js'; import type { + DemoPlacementConstrainSize, DemoPlacementFlip, + DemoPlacementNoFlip, DemoPlacementPlayground, DemoPlacementVirtualTrigger, } from '../stories/demo-hosts.js'; import meta, { + ConstrainSize, Playground, ShouldFlip, VirtualTrigger, @@ -165,3 +169,183 @@ export const StopOnDisconnect: Story = { }); }, }; + +/** + * Direct exercise of the placement-conversion functions for the four + * logical-side + physical-alignment placements that previously produced + * invalid Floating UI strings (`start-top`, `start-bottom`, `end-top`, + * `end-bottom`), plus the reverse-mapping path that converts Floating UI's + * `left-start`/`left-end`/`right-start`/`right-end` back into the SWC union. + */ +export const ConversionFunctionsRoundTrip: Story = { + ...Playground, + play: async ({ step }) => { + await step( + 'logical-side placements produce valid Floating UI placements', + () => { + expect(toFloatingPlacement('start-top')).toBe('left-start'); + expect(toFloatingPlacement('start-bottom')).toBe('left-end'); + expect(toFloatingPlacement('end-top')).toBe('right-start'); + expect(toFloatingPlacement('end-bottom')).toBe('right-end'); + } + ); + + await step('logical sides without alignment map to physical', () => { + expect(toFloatingPlacement('start')).toBe('left'); + expect(toFloatingPlacement('end')).toBe('right'); + }); + + await step('physical alignments map to Floating UI start/end', () => { + expect(toFloatingPlacement('bottom-left')).toBe('bottom-start'); + expect(toFloatingPlacement('bottom-right')).toBe('bottom-end'); + expect(toFloatingPlacement('left-top')).toBe('left-start'); + expect(toFloatingPlacement('right-bottom')).toBe('right-end'); + }); + + await step( + 'fromFloatingPlacement maps left/right start/end back to physical', + () => { + expect(fromFloatingPlacement('left-start')).toBe('left-top'); + expect(fromFloatingPlacement('left-end')).toBe('left-bottom'); + expect(fromFloatingPlacement('right-start')).toBe('right-top'); + expect(fromFloatingPlacement('right-end')).toBe('right-bottom'); + } + ); + + await step( + 'fromFloatingPlacement passes through already-valid SWC placements', + () => { + expect(fromFloatingPlacement('bottom')).toBe('bottom'); + expect(fromFloatingPlacement('top-start')).toBe('top-start'); + expect(fromFloatingPlacement('bottom-end')).toBe('bottom-end'); + } + ); + }, +}; + +/** + * Verifies the four previously-broken logical-side placements compute a + * non-zero translate (i.e. Floating UI received a valid placement). Before + * the fix, `toFloatingPlacement('start-top')` returned the invalid + * `'left-top'` and `computePosition` produced nonsensical coordinates. + */ +export const LogicalSidePlacementsCompute: Story = { + ...Playground, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-playground' + ); + host.shouldFlip = false; + await nextFrames(); + + const cases: Array<{ + requested: Parameters[0]; + expected: string; + }> = [ + { requested: 'start-top', expected: 'left-top' }, + { requested: 'start-bottom', expected: 'left-bottom' }, + { requested: 'end-top', expected: 'right-top' }, + { requested: 'end-bottom', expected: 'right-bottom' }, + ]; + + for (const { requested, expected } of cases) { + await step(`${requested} → ${expected}`, async () => { + host.placement = requested; + await nextFrames(); + const [tx, ty] = readTranslate(host.floatingEl); + // Translate must be a real number (not NaN, which is what invalid + // Floating UI placements produced before the fix). + expect(Number.isFinite(tx)).toBe(true); + expect(Number.isFinite(ty)).toBe(true); + // And the resolved placement comes back through the SWC union. + expect(host.actualPlacement).toBe(expected); + }); + } + }, +}; + +/** + * `constrainSize: true` installs Floating UI's `size` middleware, which + * writes `max-height` (and `max-width`) on the floating element when the + * available space is smaller than the floating content. + */ +export const ConstrainSizeAppliesMaxHeight: Story = { + ...ConstrainSize, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-constrain-size' + ); + await nextFrames(); + + await step('floating element receives a numeric max-height', () => { + const maxHeight = host.floatingEl.style.maxHeight; + expect(maxHeight).toMatch(/^\d+px$/); + }); + + await step( + 'isConstrained reflects when content exceeds the surface', + () => { + // The demo lists 24 items in a 180px surface, so size middleware + // clamps height and reports `isConstrained`. + expect(host.isConstrained).toBe(true); + } + ); + }, +}; + +/** + * With `shouldFlip: false`, the controller must keep the requested side + * even when the floating element would overflow the boundary. The + * `demo-placement-no-flip` host sits in a tight container that would + * normally trigger flip; this verifies it stays put. + */ +export const NoFlipKeepsRequestedSide: Story = { + ...ShouldFlip, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-no-flip' + ); + await nextFrames(); + + await step( + 'requested placement is preserved without flip middleware', + () => { + // The no-flip demo uses the default `bottom` placement and + // explicitly opts out of flip. `actualPlacement` should still + // resolve to `'bottom'` even though the surface is constrained. + expect(host.actualPlacement).toBe('bottom'); + } + ); + }, +}; + +/** + * Calling `start()` again replaces the active session. The prior + * `autoUpdate` cleanup must have run so its callback no longer drives + * compute. We verify this indirectly: cycle through several placements + * rapidly and observe that the final placement is reflected (i.e. no + * stale `start-*` session writes coordinates after `end-*` is requested). + */ +export const RapidStartReplacesPriorSession: Story = { + ...Playground, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-playground' + ); + host.shouldFlip = false; + await nextFrames(); + + await step('final placement wins after a burst of changes', async () => { + host.placement = 'top'; + host.placement = 'left'; + host.placement = 'right'; + host.placement = 'bottom-end'; + await nextFrames(); + expect(host.actualPlacement).toBe('bottom-end'); + }); + }, +}; From 2b0b69411f33573ac2985165ec9122385226bf3f Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Tue, 26 May 2026 13:15:17 +0200 Subject: [PATCH 04/27] refactor(placement-controller): trim public surface and doc noise - Drop toPlacementClassSuffix. It was a pass-through that just returned its input; consumers can do the BEM string interpolation themselves. - Strip {@link Foo} JSDoc annotations across placement-controller files; they don't render in this environment. Replaced with plain backtick references. - Remove popover-specific phrasing from controller comments and JSDoc so the controller stays host-agnostic. Generic mentions of popover, picker, menu, etc. as example use cases in story descriptions are retained where they help the reader. - Revert the focusgroup-navigation-controller stories file. The controllerApi parameter pattern was not required for the placement-controller API table to render; the focusgroup keeps its prior inline-JSDoc API documentation. --- ...ocusgroup-navigation-controller.stories.ts | 92 +++++++++---------- 2nd-gen/packages/core/controllers/index.ts | 1 - .../controllers/placement-controller/index.ts | 1 - .../src/fallback-placements.ts | 2 +- .../placement-controller/src/index.ts | 1 - .../src/placement-controller.ts | 57 +++++------- .../src/placement-conversion.ts | 19 +--- .../placement-controller/src/types.ts | 42 ++++----- .../stories/placement-controller.stories.ts | 31 +++---- 9 files changed, 101 insertions(+), 145 deletions(-) diff --git a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts index 3598192062c..2b02ab0760b 100644 --- a/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/focusgroup-navigation-controller/stories/focusgroup-navigation-controller.stories.ts @@ -60,54 +60,6 @@ const argTypes = { }, }; -const controllerApi = { - methods: [ - { - member: 'setOptions(partial)', - description: 'Merge new options and reapply roving tabindex.', - }, - { - member: 'refresh()', - description: 'Re-query items and sync tabindex (call after DOM changes).', - }, - { - member: 'setActiveItem(element)', - description: - 'Set roving `tabindex` to the given eligible item (does **not** call `focus()`). Returns `false` if ineligible.', - }, - { - member: 'focusFirstItemByTextPrefix(prefix)', - description: - 'Set roving `tabindex` to the first eligible item matching prefix (case-insensitive). Does **not** call `focus()`. Returns `false` if no match.', - }, - { - member: 'getActiveItem()', - description: 'Returns the eligible item with `tabindex="0"`, if any.', - }, - ], - additionalOptions: [ - { - name: 'getItems', - type: '() => HTMLElement[]', - default: '(required)', - description: 'Current navigable items.', - }, - { - name: 'onActiveItemChange', - type: '(el) => void', - default: '—', - description: 'Callback when active item changes.', - }, - ], - events: [ - { - name: 'swc-focusgroup-navigation-active-change', - description: - 'Dispatched on the host as `focusgroupNavigationActiveChange` with `detail: { activeElement }` when the active item changes. The event bubbles and is composed.', - }, - ], -}; - /** * `FocusgroupNavigationController` implements the * [roving `tabindex` pattern](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#managingfocuswithincomponentsusingarovingtabindex) @@ -132,7 +84,6 @@ const meta: Meta = { > `, parameters: { - controllerApi, docs: { subtitle: 'Roving tabindex and directional keys for composite widgets (APG-aligned, focusgroup-like).', @@ -454,6 +405,49 @@ export const TextPrefixFocus: Story = { }, }; +/** + * ### Methods + * + * | Member | Description | + * |---|---| + * | `setOptions(partial)` | Merge new options and reapply roving tabindex. | + * | `refresh()` | Re-query items and sync tabindex (call after DOM changes). | + * | `setActiveItem(element)` | Set roving `tabindex` to the given eligible item (does **not** call `focus()`). Returns `false` if ineligible. | + * | `focusFirstItemByTextPrefix(prefix)` | Set roving `tabindex` to the first eligible item matching prefix (case-insensitive). Does **not** call `focus()`. Returns `false` if no match. | + * | `getActiveItem()` | Returns the eligible item with `tabindex="0"`, if any. | + * ### Events + * + * The controller dispatches **`swc-focusgroup-navigation-active-change`** + * (`focusgroupNavigationActiveChange`) on the host with `detail: { activeElement }` when the + * active item changes. The event bubbles and is composed. + * + * ```typescript + * import { focusgroupNavigationActiveChange } from + * '@spectrum-web-components/core/controllers/focusgroup-navigation-controller.js'; + * + * host.addEventListener(focusgroupNavigationActiveChange, (event) => { + * console.log('Active item:', event.detail.activeElement); + * }); + * ``` + * + * ### Options + * + * | Option | Type | Default | Description | + * |---|---|---|---| + * | `getItems` | `() => HTMLElement[]` | (required) | Current navigable items. | + * | `direction` | `'horizontal'` \| `'vertical'` \| `'both'` \| `'grid'` | (required) | Arrow-key mode. **`both`**: Left/Right and Up/Down on the same `getItems()` sequence. | + * | `wrap` | `boolean` | `false` | Wrap at ends. | + * | `memory` | `boolean` | `true` | Remember last focused for re-entry via Tab. | + * | `skipDisabled` | `boolean` | `false` | Skip `disabled` / `aria-disabled="true"` items. | + * | `pageStep` | `number` | — | Non-zero: **Page Up** / **Page Down** move this many items (linear) or rows (**grid**). `0` / omitted / non-finite: disabled. | + * | `onActiveItemChange` | `(el) => void` | — | Callback when active item changes. | + * + * See the Controls table above for interactive demos of the configurable options. + */ +export const API: Story = { + tags: ['api', 'description-only'], +}; + // ──────────────────────────────── // ACCESSIBILITY STORIES // ──────────────────────────────── diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index 6d1935416b3..0b60b000101 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -30,7 +30,6 @@ export { fromFloatingPlacement, PlacementController, toFloatingPlacement, - toPlacementClassSuffix, type Placement, type PlacementHostConfig, type PlacementOptions, diff --git a/2nd-gen/packages/core/controllers/placement-controller/index.ts b/2nd-gen/packages/core/controllers/placement-controller/index.ts index fd8667e3ca1..7e973f2eccd 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/index.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/index.ts @@ -15,7 +15,6 @@ export { fromFloatingPlacement, PlacementController, toFloatingPlacement, - toPlacementClassSuffix, type Placement, type PlacementHostConfig, type PlacementOptions, diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/fallback-placements.ts b/2nd-gen/packages/core/controllers/placement-controller/src/fallback-placements.ts index a6dba17e3c0..d75a976d719 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/fallback-placements.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/fallback-placements.ts @@ -29,7 +29,7 @@ const FALLBACKS: Record = { /** * Look up the fallback placement order for Floating UI's `flip` middleware - * when the anchor is a {@link VirtualTrigger}. + * when the anchor is a `VirtualTrigger`. * * @param placement - Normalized Floating UI placement for the current side. * @returns Ordered list of placements to try when the primary side does not fit. diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/index.ts b/2nd-gen/packages/core/controllers/placement-controller/src/index.ts index 7ca4a8e0a82..43ae57d79ed 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/index.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/index.ts @@ -15,7 +15,6 @@ export { fromFloatingPlacement, PlacementController, toFloatingPlacement, - toPlacementClassSuffix, type Placement, type PlacementHostConfig, type PlacementOptions, diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts index 53f2541933e..201af95302a 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts @@ -36,16 +36,10 @@ import type { const MIN_FLOATING_HEIGHT = 100; const DEFAULT_PLACEMENT: Placement = 'bottom'; -// Neutral by design — controller does not impose a trigger gap. Each -// consuming component (popover, picker, menu, …) sets its own default per the -// popover migration plan: "we default to 0 to make the controller-host -// contract neutral. Each downstream first-party component sets the -// pattern-specific default in its own migration." +// Neutral default — controller does not impose a trigger gap. Consuming +// components set their own pattern-specific default. const DEFAULT_OFFSET = 0; const DEFAULT_CROSS_OFFSET = 0; -// Matches 1st-gen `REQUIRED_DISTANCE_TO_EDGE` and Spectrum guidance for -// minimum distance from the overflow boundary. Used by `flip`, `shift`, and -// (when enabled) `size` middleware. const DEFAULT_CONTAINER_PADDING = 8; const DEFAULT_SHOULD_FLIP = true; @@ -81,7 +75,7 @@ type ActiveSession = { options: PlacementOptions; /** - * Cached `isWebKit()` result so {@link computePlacement} doesn't run a UA + * Cached `isWebKit()` result so `computePlacement` doesn't run a UA * regex per `autoUpdate` tick. */ isWebKit: boolean; @@ -92,9 +86,9 @@ type ActiveSession = { * element relative to a trigger using [Floating UI](https://floating-ui.com/) * (`computePosition` + `autoUpdate`). * - * The public API uses hyphenated placements aligned with `` and - * Floating UI. Logical sides (`start`, `end`) normalize to physical sides for - * positioning math; RTL is handled in CSS at the consumer layer. + * Hyphenated placements are accepted on the public API. Logical sides + * (`start`, `end`) normalize to physical sides for positioning math; RTL is + * handled in CSS at the consumer layer. * * @example * ```typescript @@ -115,12 +109,12 @@ export class PlacementController implements ReactiveController { /** * The computed placement after `flip` reorients (hyphenated). `null` when - * {@link stop} has been called. + * `stop` has been called. * - * Set synchronously to the requested {@link PlacementOptions.placement} - * (or {@link DEFAULT_PLACEMENT}) when {@link start} is called, then + * Set synchronously to the requested `PlacementOptions.placement` + * (or `DEFAULT_PLACEMENT`) when `start` is called, then * updated to the value returned by `computePosition` once measurement - * resolves. {@link PlacementOptions.onPlacementChange} fires only when the + * resolves. `PlacementOptions.onPlacementChange` fires only when the * computed value differs from the synchronous initial value — consumers * that need a "first compute resolved" signal should read this property * after their own open transition completes. @@ -128,7 +122,7 @@ export class PlacementController implements ReactiveController { public actualPlacement: Placement | null = null; /** - * Whether {@link PlacementOptions.constrainSize} clamped the floating + * Whether `PlacementOptions.constrainSize` clamped the floating * element's height on the last compute. */ public isConstrained = false; @@ -159,9 +153,9 @@ export class PlacementController implements ReactiveController { * Tears down any prior session, stores the new options, and subscribes to * Floating UI `autoUpdate` so placement stays correct on scroll and resize. * - * @param trigger - Anchor element or {@link VirtualTrigger}. + * @param trigger - Anchor element or `VirtualTrigger`. * @param floating - Element to position (`position: fixed` or top-layer). - * @param options - {@link PlacementOptions}; omitted properties use defaults. + * @param options - `PlacementOptions`; omitted properties use defaults. */ public start( trigger: HTMLElement | VirtualTrigger, @@ -179,13 +173,9 @@ export class PlacementController implements ReactiveController { this.actualPlacement = options.placement ?? DEFAULT_PLACEMENT; // Single `autoUpdate` channel — receives `ancestorScroll`, - // `ancestorResize`, `elementResize`, and `layoutShift` by default. This - // is a deliberate change from 1st-gen, which used a second channel with - // `ancestorScroll: false` purely to detect ancestor scroll for the - // "close overlay on ancestor update" behavior baked into the 1st-gen - // overlay model. The 2nd-gen controller owns geometry only — the - // caller decides whether ancestor scroll should close the surface — - // so we just reposition like any other event. + // `ancestorResize`, `elementResize`, and `layoutShift` by default. The + // controller owns geometry only and just repositions on every event; + // the caller decides whether ancestor scroll should close the surface. const autoUpdateCleanup = autoUpdate(trigger, floating, () => { void this.computePlacement(); }); @@ -240,8 +230,8 @@ export class PlacementController implements ReactiveController { } /** - * Stop positioning: tear down `autoUpdate`, clear {@link actualPlacement}, - * and reset {@link isConstrained}. Safe to call multiple times. + * Stop positioning: tear down `autoUpdate`, clear `actualPlacement`, + * and reset `isConstrained`. Safe to call multiple times. */ public stop(): void { this.cleanup?.(); @@ -255,8 +245,8 @@ export class PlacementController implements ReactiveController { * Force one recomputation outside the `autoUpdate` callback. * * Use when floating content reflows internally or when a - * {@link VirtualTrigger} moves without a DOM mutation. No-op if - * {@link start} has not been called. + * `VirtualTrigger` moves without a DOM mutation. No-op if + * `start` has not been called. */ public recompute(): void { void this.computePlacement(); @@ -273,11 +263,11 @@ export class PlacementController implements ReactiveController { /** * Run Floating UI `computePosition`, apply the result to the floating element, - * and update {@link actualPlacement} when `flip` reorients. + * and update `actualPlacement` when `flip` reorients. * * Waits for web fonts and a WebKit animation frame before measuring. Skips - * when the floating element has zero dimensions. Aborts if {@link stop} or a - * new {@link start} call replaces the active session while awaiting async work. + * when the floating element has zero dimensions. Aborts if `stop` or a + * new `start` call replaces the active session while awaiting async work. */ private async computePlacement(): Promise { const session = this.session; @@ -397,5 +387,4 @@ export { ALL_PLACEMENTS } from './types.js'; export { fromFloatingPlacement, toFloatingPlacement, - toPlacementClassSuffix, } from './placement-conversion.js'; diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts index a745e329f0c..c0c322eec0e 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts @@ -39,7 +39,7 @@ const PHYSICAL_ALIGNMENT_TO_FLOATING: Record< /** * Reverse lookup for Floating UI placements that aren't valid SWC Placement - * values. Used by {@link fromFloatingPlacement} to map the four Floating UI + * values. Used by `fromFloatingPlacement` to map the four Floating UI * outputs that have no direct SWC equivalent (`left-start`, `left-end`, * `right-start`, `right-end`) back into the SWC union. Other Floating UI * placements are already valid Placement values and pass through unchanged. @@ -53,7 +53,7 @@ const FLOATING_TO_SWC_PLACEMENT: Partial> = }; /** - * Normalize a hyphenated {@link Placement} for Floating UI's `computePosition`. + * Normalize a hyphenated `Placement` for Floating UI's `computePosition`. * * - Logical **sides** (`start`, `end`) map to `left` / `right`. * - Logical **alignments** (`bottom-start`, `top-end`) pass through unchanged. @@ -101,7 +101,7 @@ export function toFloatingPlacement(placement: Placement): FloatingPlacement { } /** - * Surface a Floating UI placement as the public hyphenated {@link Placement} + * Surface a Floating UI placement as the public hyphenated `Placement` * form for `actualPlacement` and `onPlacementChange`. * * Floating UI emits four placements that don't appear in the SWC `Placement` @@ -116,16 +116,3 @@ export function toFloatingPlacement(placement: Placement): FloatingPlacement { export function fromFloatingPlacement(placement: FloatingPlacement): Placement { return FLOATING_TO_SWC_PLACEMENT[placement] ?? (placement as Placement); } - -/** - * Derive a CSS modifier suffix from a hyphenated placement (for example - * `.swc-Popover--bottom-start`). Currently a pass-through — kept as an - * indirection so consuming components can adopt a single helper instead of - * inlining the placement-to-class conversion. - * - * @param placement - Customer-facing hyphenated placement. - * @returns Class suffix safe to append after `--` in a BEM modifier. - */ -export function toPlacementClassSuffix(placement: Placement): string { - return placement; -} diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts index eca137f83df..8dc9771adac 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts @@ -16,8 +16,7 @@ /** * The full set of hyphenated placement values accepted by the public API - * (`'bottom'`, `'bottom-start'`, `'start-top'`, etc.), aligned with - * `` and Floating UI. + * (`'bottom'`, `'bottom-start'`, `'start-top'`, etc.). * * Physical-alignment variants (`top-left`, `bottom-right`) are distinct from * logical-alignment variants (`top-start`, `bottom-end`) — physical stays fixed @@ -59,7 +58,7 @@ export type Placement = (typeof ALL_PLACEMENTS)[number]; // ───────────────────────── /** - * A virtual trigger reference accepted by {@link PlacementController}. Mirrors + * A virtual trigger reference accepted by `PlacementController`. Mirrors * the Floating UI virtual-element shape: anything that can report its bounding * rect and (optionally) its context element is a valid anchor. */ @@ -73,11 +72,9 @@ export interface VirtualTrigger { // ───────────────────────── /** - * Host-level configuration passed once to the {@link PlacementController} + * Host-level configuration passed once to the `PlacementController` * constructor. Reserved for forward compatibility — currently has no - * required fields. Future hooks (for example a `tipElement` resolver for - * `arrow` middleware) will be added here so existing call sites do not need - * to change. + * required fields. */ export interface PlacementHostConfig { /** Reserved for future use. */ @@ -89,19 +86,17 @@ export interface PlacementHostConfig { // ───────────────────────── /** - * Options passed to {@link PlacementController.start}. Matches the positioning - * surface of `` (`placement`, `offset`, `crossOffset`, - * `containerPadding`, `shouldFlip`). + * Options passed to `PlacementController.start`. */ export interface PlacementOptions { /** * Preferred side and alignment of the floating element relative to the * **trigger** (hyphenated, for example `'bottom'`, `'bottom-start'`). * - * This is the requested placement. When {@link shouldFlip} is enabled, + * This is the requested placement. When `shouldFlip` is enabled, * Floating UI may compute a different side if the requested one does not fit. - * The result is surfaced on {@link PlacementController.actualPlacement} and - * via {@link onPlacementChange}. + * The result is surfaced on `PlacementController.actualPlacement` and + * via `onPlacementChange`. * * @default 'bottom' */ @@ -112,13 +107,11 @@ export interface PlacementOptions { * element, in pixels (Floating UI `offset` main axis). * * For `'bottom'`, this is the space below the trigger. This is **not** - * viewport padding — see {@link containerPadding} for edge inset when + * viewport padding — see `containerPadding` for edge inset when * `flip` / `shift` run. * * Defaults to `0` so the controller-host contract stays neutral; each - * consuming component sets its own pattern-specific default (for example, - * `` defaults `offset` to `0`, downstream patterns like - * `` may raise it). + * consuming component sets its own pattern-specific default. * * @default 0 */ @@ -129,7 +122,7 @@ export interface PlacementOptions { * direction, in pixels (Floating UI `offset` cross axis). * * Adjusts alignment such as `'bottom-start'` vs `'bottom-end'` without - * changing viewport inset. This is **not** {@link containerPadding}. + * changing viewport inset. This is **not** `containerPadding`. * * @default 0 */ @@ -145,11 +138,8 @@ export interface PlacementOptions { * screen-edge inset, while surfaces inside a scrollable clipping parent use that * container's edges instead. * - * This is **not** the gap from the trigger — use {@link offset} and - * {@link crossOffset} for trigger-relative spacing. - * - * Matches ``'s `container-padding` attribute; consumer - * components expose this publicly and pass it through to the controller. + * This is **not** the gap from the trigger — use `offset` and + * `crossOffset` for trigger-relative spacing. * * @default 8 */ @@ -157,7 +147,7 @@ export interface PlacementOptions { /** * Whether Floating UI may **flip** the floating element to the opposite side when the - * requested {@link placement} does not fit within the overflow boundary. + * requested `placement` does not fit within the overflow boundary. * * When `false`, the floating element stays on the requested side even if it * overflows — useful for tooltips that must not jump above the trigger. @@ -168,8 +158,8 @@ export interface PlacementOptions { /** * When `true`, applies Floating UI's `size` middleware so long content - * scrolls inside the viewport. Not part of the `` public API; - * used by picker / menu patterns that compose the controller directly. + * scrolls inside the viewport. Opt-in — consumers that need it (list + * surfaces such as menus, pickers, comboboxes) enable it explicitly. * * @default false */ diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts index d21099b0610..b60ebeb6578 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts @@ -67,7 +67,7 @@ const argTypes = { containerPadding: { control: 'number', description: - 'Minimum inset (px) from the overflow boundary for `flip`, `shift`, and `size`. Defaults to clipping ancestors capped by the visual viewport; not trigger gap. `swc-popover` exposes this as `container-padding`.', + 'Minimum inset (px) from the overflow boundary for `flip`, `shift`, and `size`. Defaults to clipping ancestors capped by the visual viewport; not trigger gap.', table: { category: 'Options', type: { summary: 'number' }, @@ -140,8 +140,7 @@ const controllerApi = { types: [ { name: 'Placement', - description: - 'Hyphenated placement union (22 values, `swc-popover` / Floating UI).', + description: 'Hyphenated placement union (22 values).', }, { name: 'VirtualTrigger', @@ -218,7 +217,7 @@ export const Overview: Story = { * - Computes `top`, `left`, and `translate` on the **floating element** using * `strategy: 'fixed'` (suitable for native popover top-layer surfaces). * - Subscribes to scroll, resize, and layout shift via Floating UI's `autoUpdate` while - * {@link PlacementController.start} is active. + * `PlacementController.start` is active. * - Waits for `document.fonts.ready` and applies iOS WebKit `visualViewport` correction * before measuring. * @@ -285,10 +284,10 @@ export const Usage: Story = { /** * Pass **`placement`** to choose the preferred side and alignment **relative to the trigger** - * in hyphenated form (`'bottom'`, `'bottom-start'`, `'top-end'`, etc.). Values align with - * `` and Floating UI (22 total). This is the **requested** placement — when - * **`shouldFlip`** is enabled, Floating UI may compute a different side; read - * **`actualPlacement`** or use **`onPlacementChange`** for the result after **`flip`**. + * in hyphenated form (`'bottom'`, `'bottom-start'`, `'top-end'`, etc.; 22 values total). + * This is the **requested** placement — when **`shouldFlip`** is enabled, Floating UI may + * compute a different side; read **`actualPlacement`** or use **`onPlacementChange`** for the + * result after **`flip`**. * * Logical alignments (`bottom-start`, `top-end`) reverse in RTL via CSS at the consumer layer; * physical alignments (`bottom-left`, `left-top`) stay fixed. Logical sides (`start`, `end`, @@ -335,9 +334,9 @@ export const Offset: Story = { /** * Use **`containerPadding`** for minimum inset from the **overflow boundary** (px) — **not** the gap from the trigger. Passed as `padding` to Floating UI **`flip`**, **`shift`**, and (when **`constrainSize`** is enabled) **`size`** middleware. * - * By default, Floating UI uses clipping ancestors capped by the **visual viewport** as that boundary. For typical fixed or top-layer popovers, this behaves like screen-edge inset. Inside a scrollable clipping parent, inset is measured from that container's edges instead. + * By default, Floating UI uses clipping ancestors capped by the **visual viewport** as that boundary. For typical fixed or top-layer surfaces, this behaves like screen-edge inset. Inside a scrollable clipping parent, inset is measured from that container's edges instead. * - * `` exposes this as the **`container-padding`** attribute. Use the playground **Container padding** control with the trigger near an edge to see **`flip`** and **`shift`** keep the panel further inside the boundary. + * Use the playground **Container padding** control with the trigger near an edge to see **`flip`** and **`shift`** keep the panel further inside the boundary. * * ```typescript * this.placement.start(this.trigger, this.panel, { @@ -389,8 +388,7 @@ export const ShouldFlip: Story = { * `true` when height was clamped on the last compute. * * Use for picker, menu, and combobox list surfaces. Leave disabled for compact popovers and - * tooltips. Not part of the `` public API — enable only on hosts that compose - * the controller directly. + * tooltips. Opt-in — hosts enable it only when their content can overflow. * * ```typescript * this.placement.start(this.trigger, this.listbox, { @@ -428,7 +426,7 @@ export const OnPlacementChange: Story = { /** * Pass a **`VirtualTrigger`** instead of a DOM element when the anchor is a **virtual point** * — for example a click coordinate, text selection rect, or canvas hit-test. Implement - * {@link VirtualTrigger} with **`getBoundingClientRect()`** returning viewport coordinates; + * `VirtualTrigger` with **`getBoundingClientRect()`** returning viewport coordinates; * optionally set **`contextElement`** as the clipping root inside scrollable regions. * * Virtual triggers use a curated fallback list for **`flip`** (see `getFallbackPlacements()`) @@ -504,14 +502,15 @@ export const Accessibility: Story = { * ### Relationship to 1st-gen `PlacementController` * * The 2nd-gen controller is a **focused subset** of the 1st-gen - * `PlacementController`: single `autoUpdate` channel, hyphenated placements aligned - * with ``, callback-based placement surfacing, and opt-in `constrainSize`. + * `PlacementController`: single `autoUpdate` channel, hyphenated placements, + * callback-based placement surfacing, and opt-in `constrainSize`. It owns + * geometry only — open/close lifecycle, ARIA, focus, and dismissal remain + * the caller's responsibility. * * ### See also * * - [Floating UI documentation](https://floating-ui.com/docs/computePosition) * - [Floating UI middleware](https://floating-ui.com/docs/middleware) - * - [Popover migration plan](https://github.com/adobe/spectrum-web-components/blob/main/CONTRIBUTOR-DOCS/03_project-planning/03_components/popover/migration-plan.md) */ export const Appendix: Story = { tags: ['description-only', 'appendix'], From 2aa2f989df59334378f163d635226d227821a94d Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Tue, 26 May 2026 13:32:00 +0200 Subject: [PATCH 05/27] refactor(placement-controller): use inline JSDoc API table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the controllerApi infrastructure added to ApiTable.tsx and DocumentTemplate.mdx. Document the placement-controller's methods, readonly properties, options, and types as inline markdown tables in JSDoc on a description-only API story — matching the focusgroup-navigation-controller pattern. - ApiTable.tsx: drop the ControllerApiReference type, five controller-specific table components, and the tag-based branching added for controllers. Component CEM rendering is unchanged. - DocumentTemplate.mdx: restore ConditionalAPISection (Primary + Controls + optional stories tagged 'api') and drop the ConditionalAPIReference / ApiTable wiring. - placement-controller stories: remove parameters.controllerApi, add an API story with inline JSDoc tables tagged ['api', 'description-only']. --- .../stories/placement-controller.stories.ts | 100 +++--- .../swc/.storybook/DocumentTemplate.mdx | 44 ++- .../swc/.storybook/blocks/ApiTable.tsx | 333 +----------------- 3 files changed, 85 insertions(+), 392 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts index b60ebeb6578..d5a2cb8ae78 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts @@ -96,60 +96,6 @@ const argTypes = { }, } satisfies Meta['argTypes']; -const controllerApi = { - methods: [ - { - member: 'start(trigger, floating, options?)', - description: - 'Begin positioning; tear down any prior session and subscribe to Floating UI `autoUpdate`. Skips compute when the floating element has zero dimensions until size is available.', - }, - { - member: 'stop()', - description: - 'Tear down positioning, unsubscribe from `autoUpdate`, and clear `actualPlacement` and `isConstrained`. Called from `hostDisconnected` when the Lit host disconnects.', - }, - { - member: 'recompute()', - description: - 'Force one `computePosition` pass outside `autoUpdate` — for example after floating content reflows or a virtual trigger moves without layout events. No-op if not started.', - }, - ], - readonlyProperties: [ - { - name: 'actualPlacement', - type: 'Placement or null', - description: - 'Computed placement after `flip` (hyphenated). `null` when stopped.', - }, - { - name: 'isConstrained', - type: 'boolean', - description: - 'Whether `constrainSize` clamped height on the last compute.', - }, - ], - additionalOptions: [ - { - name: 'onPlacementChange', - type: 'callback', - default: '—', - description: - 'Called when computed placement changes. Receives the hyphenated `Placement`.', - }, - ], - types: [ - { - name: 'Placement', - description: 'Hyphenated placement union (22 values).', - }, - { - name: 'VirtualTrigger', - description: - '{ getBoundingClientRect(): DOMRect; contextElement?: Element }', - }, - ], -}; - /** * `PlacementController` positions a floating element relative to a trigger using * [Floating UI](https://floating-ui.com/) (`computePosition` + `autoUpdate`). Use it inside @@ -175,7 +121,6 @@ const meta: Meta = { > `, parameters: { - controllerApi, docs: { subtitle: 'Floating UI-backed positioning for popover, picker, menu, and other anchored patterns.', @@ -451,6 +396,51 @@ export const VirtualTrigger: Story = { parameters: { 'section-order': 7 }, }; +// ────────────────────────── +// API STORY +// ────────────────────────── + +/** + * ### Methods + * + * | Member | Description | + * |---|---| + * | `start(trigger, floating, options?)` | Begin positioning; tear down any prior session and subscribe to Floating UI `autoUpdate`. Skips compute when the floating element has zero dimensions until size is available. | + * | `stop()` | Tear down positioning, unsubscribe from `autoUpdate`, and clear `actualPlacement` and `isConstrained`. Called from `hostDisconnected` when the Lit host disconnects. | + * | `recompute()` | Force one `computePosition` pass outside `autoUpdate` — for example after floating content reflows or a virtual trigger moves without layout events. No-op if not started. | + * + * ### Readonly properties + * + * | Property | Type | Description | + * |---|---|---| + * | `actualPlacement` | `Placement \| null` | Computed placement after `flip` (hyphenated). `null` when stopped. | + * | `isConstrained` | `boolean` | Whether `constrainSize` clamped height on the last compute. | + * + * ### Options + * + * | Option | Type | Default | Description | + * |---|---|---|---| + * | `placement` | `Placement` | `'bottom'` | Preferred side and alignment relative to the trigger (hyphenated; 22 values). | + * | `offset` | `number` | `0` | Gap along the placement direction between trigger and floating element (px). | + * | `crossOffset` | `number` | `0` | Slide along the trigger edge (px), perpendicular to the placement direction. | + * | `containerPadding` | `number` | `8` | Minimum inset from the overflow boundary used by `flip`, `shift`, and `size`. | + * | `shouldFlip` | `boolean` | `true` | Whether `flip` may reorient when the requested placement does not fit. | + * | `constrainSize` | `boolean` | `false` | When `true`, applies `size` middleware (max-height/max-width). | + * | `onPlacementChange` | `(placement: Placement) => void` | — | Called when the computed placement changes. | + * + * ### Types + * + * | Type | Description | + * |---|---| + * | `Placement` | Hyphenated placement union (22 values). | + * | `VirtualTrigger` | `{ getBoundingClientRect(): DOMRect; contextElement?: Element }` | + * + * See the Controls table above for interactive demos of the configurable options. + */ +export const API: Story = { + tags: ['api', 'description-only'], +}; + // ──────────────────────────────── // ACCESSIBILITY STORIES // ──────────────────────────────── diff --git a/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx b/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx index dd7f1302e50..274cda1adb8 100644 --- a/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx +++ b/2nd-gen/packages/swc/.storybook/DocumentTemplate.mdx @@ -1,7 +1,10 @@ import { Meta, Title, + Primary, + Controls, Stories, + ArgTypes, Description, Subtitle, HeaderMdx, @@ -62,16 +65,37 @@ export const ConditionalSection = ({ tag, title, hideTitle = false }) => { ); }; -export const ConditionalAPIReference = () => ( - -<> - - API - - - +export const ConditionalAPISection = () => { +const resolvedOf = useOf('meta', ['meta']); +const tags = resolvedOf?.csfFile?.meta?.tags ?? []; +const hasCustomAPIDocs = tags.includes('api') || Object.values(resolvedOf.csfFile.stories).some( +(story) => story.tags?.includes('api') ); + if (!hasCustomAPIDocs) { + return ( + <> + API + + + + + ); + } + + return ( + <> + API + + + +
+ + + ); + +}; + export const ConditionalGettingStarted = () => { const resolvedOf = useOf('meta', ['meta']); @@ -100,7 +124,9 @@ export const ConditionalGettingStarted = () => { - +## API + + diff --git a/2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx b/2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx index 1d334bb8676..0b7e53d27c7 100644 --- a/2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx +++ b/2nd-gen/packages/swc/.storybook/blocks/ApiTable.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import { HeaderMdx, Markdown, useOf } from '@storybook/addon-docs/blocks'; +import { HeaderMdx, useOf } from '@storybook/addon-docs/blocks'; import type { Attribute, ClassField, @@ -78,313 +78,11 @@ const tableStyle: React.CSSProperties = { /** Storybook argType shape (subset we care about). */ interface ArgType { options?: string[]; - description?: string; - control?: string | { type?: string }; table?: { - category?: string; type?: { summary?: string }; - defaultValue?: { summary?: string }; }; } -interface ControllerApiMethod { - member: string; - description: string; -} - -interface ControllerApiOption { - name: string; - type?: string; - default?: string; - description: string; -} - -interface ControllerApiType { - name: string; - description: string; -} - -interface ControllerApiEvent { - name: string; - description: string; -} - -export interface ControllerApiReference { - methods?: ControllerApiMethod[]; - readonlyProperties?: ControllerApiOption[]; - additionalOptions?: ControllerApiOption[]; - types?: ControllerApiType[]; - events?: ControllerApiEvent[]; -} - -function inferArgTypeSummary(name: string, argType: ArgType): string { - if (argType.table?.type?.summary) { - return argType.table.type.summary; - } - - if (argType.options?.length) { - return argType.options.map((option) => `'${option}'`).join(' | '); - } - - const control = argType.control; - const controlType = typeof control === 'string' ? control : control?.type; - - if (controlType === 'boolean') { - return 'boolean'; - } - - if (controlType === 'number') { - return 'number'; - } - - return name; -} - -function DescriptionCell({ children }: { children?: string }) { - if (!children) { - return null; - } - - return {children}; -} - -function ControllerMethodsTable({ - methods, -}: { - methods: ControllerApiMethod[]; -}) { - if (methods.length === 0) { - return null; - } - - return ( - <> - - Methods - -
- - - - - - - - - {methods.map((method) => ( - - - - - ))} - -
MemberDescription
- {method.member} - - {method.description} -
-
- - ); -} - -function ControllerReadonlyPropertiesTable({ - properties, -}: { - properties: ControllerApiOption[]; -}) { - if (properties.length === 0) { - return null; - } - - return ( - <> - - Readonly properties - -
- - - - - - - - - - {properties.map((property) => ( - - - - - - ))} - -
PropertyTypeDescription
- {property.name} - {property.type ? {property.type} : '-'} - {property.description} -
-
- - ); -} - -function ControllerOptionsTable({ - argTypes, - additionalOptions = [], -}: { - argTypes: Record; - additionalOptions?: ControllerApiOption[]; -}) { - const rows: ControllerApiOption[] = [ - ...Object.entries(argTypes) - .filter(([, argType]) => argType.table?.category === 'Options') - .map(([name, argType]) => ({ - name, - type: inferArgTypeSummary(name, argType), - default: argType.table?.defaultValue?.summary ?? '—', - description: argType.description ?? '', - })), - ...additionalOptions, - ]; - - if (rows.length === 0) { - return null; - } - - return ( - <> - - Options - -
- - - - - - - - - - - {rows.map((option) => ( - - - - - - - ))} - -
OptionTypeDefaultDescription
- {option.name} - {option.type ? {option.type} : '-'} - {option.default != null ? {option.default} : '-'} - - {option.description} -
-
- - ); -} - -function ControllerTypesTable({ types }: { types: ControllerApiType[] }) { - if (types.length === 0) { - return null; - } - - return ( - <> - - Types - -
- - - - - - - - - {types.map((type) => ( - - - - - ))} - -
TypeDescription
- {type.name} - - {type.description} -
-
- - ); -} - -function ControllerEventsTable({ events }: { events: ControllerApiEvent[] }) { - if (events.length === 0) { - return null; - } - - return ( - <> - - Events - -
- - - - - - - - - {events.map((event) => ( - - - - - ))} - -
NameDescription
- {event.name} - - {event.description} -
-
- - ); -} - -function ControllerApiTables({ - controllerApi, - argTypes, -}: { - controllerApi: ControllerApiReference; - argTypes: Record; -}) { - return ( - <> - - - - - - - ); -} - function PropertiesTable({ members, attributes, @@ -605,40 +303,19 @@ function CssPartsTable({ cssParts }: { cssParts: CssPart[] }) { // ──────────────────────────── /** - * Custom API reference tables sourced from the Custom Elements Manifest for - * components, or from `parameters.controllerApi` plus playground `argTypes` for - * controllers. + * Custom API reference tables sourced directly from the Custom Elements + * Manifest. Renders categorized, read-only tables for Properties, Slots, + * Events, CSS Custom Properties, and CSS Parts. */ export function ApiTable() { const resolvedOf = useOf('meta', ['meta']); const meta = resolvedOf.csfFile?.meta as { component?: string; argTypes?: Record; - tags?: string[]; - parameters?: { - controllerApi?: ControllerApiReference; - }; }; - const tags = resolvedOf.preparedMeta?.tags ?? meta?.tags ?? []; + const tagName = meta?.component; const argTypes = resolvedOf.preparedMeta?.argTypes ?? meta?.argTypes ?? {}; - const controllerApi = - resolvedOf.preparedMeta?.parameters?.controllerApi ?? - meta?.parameters?.controllerApi; - - if (tags.includes('controller')) { - if (!controllerApi) { - return

No controller API data available.

; - } - - return ( - } - /> - ); - } - const tagName = meta?.component; const cem = window.__STORYBOOK_CUSTOM_ELEMENTS_MANIFEST__; if (!cem || !tagName) { return

No API data available.

; From 731110e0960cb3e51f0c70f6a049bbede3ecc3a2 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Tue, 26 May 2026 13:44:55 +0200 Subject: [PATCH 06/27] chore: bump floatingui version --- 2nd-gen/packages/core/package.json | 2 +- yarn.lock | 28 +++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/2nd-gen/packages/core/package.json b/2nd-gen/packages/core/package.json index d3177cac51c..2cd18a76e6b 100644 --- a/2nd-gen/packages/core/package.json +++ b/2nd-gen/packages/core/package.json @@ -249,7 +249,7 @@ } }, "dependencies": { - "@floating-ui/dom": "1.7.4", + "@floating-ui/dom": "1.7.6", "@lit-labs/observers": "2.0.2", "lit": "^2.5.0 || ^3.1.3" }, diff --git a/yarn.lock b/yarn.lock index 1d5ec403e77..334b77d142b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3936,6 +3936,15 @@ __metadata: languageName: node linkType: hard +"@floating-ui/core@npm:^1.7.5": + version: 1.7.5 + resolution: "@floating-ui/core@npm:1.7.5" + dependencies: + "@floating-ui/utils": "npm:^0.2.11" + checksum: 10c0/f9c52205e198b231d63a387b09c659aab08c46a1899e0b0bbe147b8b4f048b546f15ba17cb5d2a471da9534f1883d979425e13e5c4ceee67be63e4b0abd4db5d + languageName: node + linkType: hard + "@floating-ui/dom@npm:1.7.4": version: 1.7.4 resolution: "@floating-ui/dom@npm:1.7.4" @@ -3946,6 +3955,16 @@ __metadata: languageName: node linkType: hard +"@floating-ui/dom@npm:1.7.6": + version: 1.7.6 + resolution: "@floating-ui/dom@npm:1.7.6" + dependencies: + "@floating-ui/core": "npm:^1.7.5" + "@floating-ui/utils": "npm:^0.2.11" + checksum: 10c0/5c098e0d7b58c9bc769f276cca1766994c2c9c70c92d091a61bba8b3e9be53c011e0a79a8457fc2fb2f3d91697a26eb52e0a4962ef936dc963b45f58613c212f + languageName: node + linkType: hard + "@floating-ui/utils@npm:0.2.10, @floating-ui/utils@npm:^0.2.10": version: 0.2.10 resolution: "@floating-ui/utils@npm:0.2.10" @@ -3953,6 +3972,13 @@ __metadata: languageName: node linkType: hard +"@floating-ui/utils@npm:^0.2.11": + version: 0.2.11 + resolution: "@floating-ui/utils@npm:0.2.11" + checksum: 10c0/f4bcea1559bdbb721ecc8e8ead423ac58d6a5b6e70b602cf0810ba6ad4ed1c77211b207faa88b278a9042f0c743133de08a203ed6741c1b6443423332884d5b3 + languageName: node + linkType: hard + "@formatjs/ecma402-abstract@npm:2.3.4": version: 2.3.4 resolution: "@formatjs/ecma402-abstract@npm:2.3.4" @@ -6656,7 +6682,7 @@ __metadata: version: 0.0.0-use.local resolution: "@spectrum-web-components/core@workspace:2nd-gen/packages/core" dependencies: - "@floating-ui/dom": "npm:1.7.4" + "@floating-ui/dom": "npm:1.7.6" "@lit-labs/observers": "npm:2.0.2" glob: "npm:11.0.3" lit: "npm:^2.5.0 || ^3.1.3" From fe69752a7117b2569d633dfd125c629d47a4631c Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Tue, 26 May 2026 13:51:11 +0200 Subject: [PATCH 07/27] refactor(placement-controller): unify ShouldFlip demo with toggle Replace the two side-by-side flip demos (demo-placement-flip and demo-placement-no-flip) with a single demo-placement-should-flip that has a shouldFlip checkbox. One trigger + one floating panel + one toggle isolates the feature so readers can verify the placement only moves because of the flip middleware. - demo-hosts.ts: delete DemoPlacementFlip and DemoPlacementNoFlip; add DemoPlacementShouldFlip with a should-flip attribute, a checkbox in the demo template, and an updated() hook that rebinds the controller when the toggle flips. - placement-controller.stories.ts: ShouldFlip story now renders a single demo-placement-should-flip element. Story description gained a sentence explaining the toggle. - placement-controller.test.ts: FlipReorients reads from the new element (default shouldFlip=true). NoFlipKeepsRequestedSide reuses the same element, sets shouldFlip=false, then asserts actualPlacement stays at 'bottom'. --- .../stories/demo-hosts.ts | 114 +++++++----------- .../stories/placement-controller.stories.ts | 8 +- .../test/placement-controller.test.ts | 25 ++-- 3 files changed, 62 insertions(+), 85 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts index 199bb10b3db..98c0c11d4a5 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts @@ -30,8 +30,7 @@ import { declare global { interface HTMLElementTagNameMap { 'demo-placement-playground': DemoPlacementPlayground; - 'demo-placement-flip': DemoPlacementFlip; - 'demo-placement-no-flip': DemoPlacementNoFlip; + 'demo-placement-should-flip': DemoPlacementShouldFlip; 'demo-placement-offset': DemoPlacementOffset; 'demo-placement-constrain-size': DemoPlacementConstrainSize; 'demo-placement-virtual-trigger': DemoPlacementVirtualTrigger; @@ -555,76 +554,24 @@ const flipDemoStyles = css` } `; -@customElement('demo-placement-flip') -export class DemoPlacementFlip extends LitElement { - static override styles = [sharedStyles, flipDemoStyles]; - - @property({ type: String, attribute: 'actual-placement', reflect: true }) - actualPlacement: Placement | null = null; - - @query('button') triggerEl!: HTMLButtonElement; - @query('.floating') floatingEl!: HTMLDivElement; - - private controller = new PlacementController(this); - - protected override firstUpdated(): void { - bindController( - this.controller, - this.triggerEl, - this.floatingEl, - { shouldFlip: true }, - (next) => { - this.actualPlacement = next; - } - ); - } - - override disconnectedCallback(): void { - super.disconnectedCallback?.(); - this.controller.stop(); - } - - protected override render(): TemplateResult { - return html` -
-

- shouldFlip: true -

-
- -
-
- computed: ${this.actualPlacement} -
- ${FLIP_DEMO_ITEMS.map( - (label) => html` -
- ${label} -
- ` - )} -
-
-
- `; - } -} - -@customElement('demo-placement-no-flip') -export class DemoPlacementNoFlip extends LitElement { +@customElement('demo-placement-should-flip') +export class DemoPlacementShouldFlip extends LitElement { static override styles = [ sharedStyles, flipDemoStyles, css` - :host { - display: block; - margin-block-start: 32px; + .toggle { + display: flex; + align-items: center; + gap: 6px; + margin-block-end: 12px; } `, ]; + @property({ type: Boolean, attribute: 'should-flip', reflect: true }) + shouldFlip = true; + @property({ type: String, attribute: 'actual-placement', reflect: true }) actualPlacement: Placement | null = null; @@ -634,28 +581,53 @@ export class DemoPlacementNoFlip extends LitElement { private controller = new PlacementController(this); protected override firstUpdated(): void { + this.bind(); + } + + protected override updated(changed: PropertyValues): void { + if (changed.has('shouldFlip')) { + this.bind(); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback?.(); + this.controller.stop(); + } + + private bind(): void { + if (!this.triggerEl || !this.floatingEl) { + return; + } bindController( this.controller, this.triggerEl, this.floatingEl, - { shouldFlip: false }, + { shouldFlip: this.shouldFlip }, (next) => { this.actualPlacement = next; } ); } - override disconnectedCallback(): void { - super.disconnectedCallback?.(); - this.controller.stop(); + private onShouldFlipChange(event: Event): void { + this.shouldFlip = (event.target as HTMLInputElement).checked; } protected override render(): TemplateResult { return html`
-

- shouldFlip: false -

+
-
-
- computed: ${this.actualPlacement} -
- ${FLIP_DEMO_ITEMS.map( - (label) => html` -
- ${label} -
- ` - )} -
-
-
- `; - } -} - @customElement('demo-placement-offset') export class DemoPlacementOffset extends LitElement { static override styles = [ diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts index 2fec4932806..2aa06be1877 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts @@ -302,11 +302,6 @@ export const ContainerPadding: Story = { * overflows. Disabling flip does **not** disable **`shift`**; the panel may still slide * along an axis to reduce clipping. * - * Use the **shouldFlip** toggle below to compare both modes against the same trigger and - * surface — the trigger sits at the bottom of a constrained container so the requested - * `'bottom'` placement does not fit; you'll see the computed placement reorient when flip is - * enabled and stay put when it is disabled. - * * ```typescript * this.placement.start(this.trigger, this.panel, { * shouldFlip: true, @@ -318,10 +313,7 @@ export const ContainerPadding: Story = { * ``` */ export const ShouldFlip: Story = { - tags: ['behaviors'], - render: () => html` - - `, + tags: ['behaviors', 'description-only'], parameters: { 'section-order': 4 }, }; diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts index 3d4ef82cc23..94b6c0cc15a 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -19,13 +19,11 @@ import { fromFloatingPlacement, toFloatingPlacement } from '../index.js'; import type { DemoPlacementConstrainSize, DemoPlacementPlayground, - DemoPlacementShouldFlip, DemoPlacementVirtualTrigger, } from '../stories/demo-hosts.js'; import meta, { ConstrainSize, Playground, - ShouldFlip, VirtualTrigger, } from '../stories/placement-controller.stories.js'; @@ -106,24 +104,6 @@ export const PositionsBelowTrigger: Story = { }, }; -export const FlipReorients: Story = { - ...ShouldFlip, - play: async ({ canvasElement, step }) => { - const host = await getComponent( - canvasElement, - 'demo-placement-should-flip' - ); - // Default `shouldFlip: true` — the trigger sits in a constrained - // container, so the requested `'bottom'` placement reorients. - await nextFrames(); - - await step('flips away from bottom when no room', async () => { - expect(host.actualPlacement).toBeTruthy(); - expect(host.actualPlacement).not.toBe('bottom'); - }); - }, -}; - export const VirtualTriggerMoves: Story = { ...VirtualTrigger, play: async ({ canvasElement, step }) => { @@ -296,33 +276,6 @@ export const ConstrainSizeAppliesMaxHeight: Story = { }, }; -/** - * With `shouldFlip: false`, the controller must keep the requested side - * even when the floating element would overflow the boundary. Toggling - * the shouldFlip checkbox off on the same demo that flips by default - * proves the flip path is what does the reorienting. - */ -export const NoFlipKeepsRequestedSide: Story = { - ...ShouldFlip, - play: async ({ canvasElement, step }) => { - const host = await getComponent( - canvasElement, - 'demo-placement-should-flip' - ); - host.shouldFlip = false; - await nextFrames(); - - await step( - 'requested placement is preserved without flip middleware', - () => { - // With `shouldFlip: false`, `actualPlacement` should resolve to - // `'bottom'` even though the surface is constrained. - expect(host.actualPlacement).toBe('bottom'); - } - ); - }, -}; - /** * Calling `start()` again replaces the active session. The prior * `autoUpdate` cleanup must have run so its callback no longer drives From 134d2c0d22a1aa203775b6ccd7260a28a9a4c3a5 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Tue, 26 May 2026 14:46:28 +0200 Subject: [PATCH 12/27] docs(placement-controller): clarify that shift is always installed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs referenced `shift` in seven places as if it were an opt-in option. It isn't — `shift` runs on every compute and there's no opt-out (same as 1st-gen). Spell that out in the middleware stack section so the existing prose references read correctly. --- .../stories/placement-controller.stories.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts index 2aa06be1877..98188ecf8a8 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts @@ -168,7 +168,7 @@ export const Overview: Story = { * * 1. **`offset`** — trigger-relative gap along the placement direction (`offset` option) and along the trigger edge (`crossOffset` option). * 2. **`flip`** (when `shouldFlip: true`) — reorients when there is not enough room, respecting `containerPadding` inset from the overflow boundary. - * 3. **`shift`** — slides the floating element along the axis to stay inside the boundary, using `containerPadding` as inset. + * 3. **`shift`** — **always installed**; slides the floating element along the placement axis to keep it inside the boundary using `containerPadding` as inset. There's no opt-out — disabling it would let the panel clip off the viewport edge. * 4. **`size`** (when `constrainSize: true`) — clamps `maxHeight` / `maxWidth` for scrollable lists. * * ### What the caller owns From bdbb6b5f8ce718baf9592e80b3836bdcf35caa9a Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Tue, 26 May 2026 14:54:29 +0200 Subject: [PATCH 13/27] docs(placement-controller): stop name-dropping shift everywhere shift is not a configurable option, so listing it alongside flip and size in option JSDoc and story prose just makes those references read like undocumented options. Replace the lists with neutral phrasing ("used for collision detection" / "inset from the overflow boundary") and keep a single shift mention in the middleware-stack section, where the always-on note belongs. --- .../placement-controller/src/types.ts | 18 +++++++++--------- .../stories/placement-controller.stories.ts | 13 ++++++------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts index 8dc9771adac..b4870a80ce0 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts @@ -107,8 +107,8 @@ export interface PlacementOptions { * element, in pixels (Floating UI `offset` main axis). * * For `'bottom'`, this is the space below the trigger. This is **not** - * viewport padding — see `containerPadding` for edge inset when - * `flip` / `shift` run. + * viewport padding — see `containerPadding` for inset from the overflow + * boundary. * * Defaults to `0` so the controller-host contract stays neutral; each * consuming component sets its own pattern-specific default. @@ -129,14 +129,14 @@ export interface PlacementOptions { crossOffset?: number; /** - * Minimum inset from the **overflow boundary**, in pixels, used by Floating UI - * `flip`, `shift`, and (when enabled) `size` middleware. + * Minimum inset from the **overflow boundary**, in pixels, used for + * collision detection. * - * Floating UI applies this as padding around the boundary it uses for collision - * detection. By default that is the floating element's clipping ancestors, capped - * by the visual viewport — so fixed or top-layer popovers usually behave like - * screen-edge inset, while surfaces inside a scrollable clipping parent use that - * container's edges instead. + * Floating UI applies this as padding around the boundary it uses for + * collision detection. By default that is the floating element's clipping + * ancestors, capped by the visual viewport — so fixed or top-layer popovers + * usually behave like screen-edge inset, while surfaces inside a scrollable + * clipping parent use that container's edges instead. * * This is **not** the gap from the trigger — use `offset` and * `crossOffset` for trigger-relative spacing. diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts index 98188ecf8a8..c89e9dc866e 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts @@ -67,7 +67,7 @@ const argTypes = { containerPadding: { control: 'number', description: - 'Minimum inset (px) from the overflow boundary for `flip`, `shift`, and `size`. Defaults to clipping ancestors capped by the visual viewport; not trigger gap.', + 'Minimum inset (px) from the overflow boundary used for collision detection. Defaults to clipping ancestors capped by the visual viewport; not trigger gap.', table: { category: 'Options', type: { summary: 'number' }, @@ -254,7 +254,7 @@ export const RequestedPlacement: Story = { * (for example nudge a `'bottom-start'` panel further toward the trigger's start edge). * * Neither option sets boundary inset — use **`containerPadding`** for inset from the - * overflow boundary when **`flip`** or **`shift`** runs. + * overflow boundary. * * ```typescript * this.placement.start(this.trigger, this.panel, { @@ -275,11 +275,11 @@ export const Offset: Story = { }; /** - * Use **`containerPadding`** for minimum inset from the **overflow boundary** (px) — **not** the gap from the trigger. Passed as `padding` to Floating UI **`flip`**, **`shift`**, and (when **`constrainSize`** is enabled) **`size`** middleware. + * Use **`containerPadding`** for minimum inset from the **overflow boundary** (px) — **not** the gap from the trigger. Used internally for collision detection. * * By default, Floating UI uses clipping ancestors capped by the **visual viewport** as that boundary. For typical fixed or top-layer surfaces, this behaves like screen-edge inset. Inside a scrollable clipping parent, inset is measured from that container's edges instead. * - * Use the playground **Container padding** control with the trigger near an edge to see **`flip`** and **`shift`** keep the panel further inside the boundary. + * Use the playground **Container padding** control with the trigger near an edge to see the panel kept further inside the boundary. * * ```typescript * this.placement.start(this.trigger, this.panel, { @@ -299,8 +299,7 @@ export const ContainerPadding: Story = { * side after flip. * * With **`shouldFlip: false`**, the floating element stays on the requested side even if it - * overflows. Disabling flip does **not** disable **`shift`**; the panel may still slide - * along an axis to reduce clipping. + * overflows. * * ```typescript * this.placement.start(this.trigger, this.panel, { @@ -413,7 +412,7 @@ export const VirtualTrigger: Story = { * | `placement` | `Placement` | `'bottom'` | Preferred side and alignment relative to the trigger (hyphenated; 22 values). | * | `offset` | `number` | `0` | Gap along the placement direction between trigger and floating element (px). | * | `crossOffset` | `number` | `0` | Slide along the trigger edge (px), perpendicular to the placement direction. | - * | `containerPadding` | `number` | `8` | Minimum inset from the overflow boundary used by `flip`, `shift`, and `size`. | + * | `containerPadding` | `number` | `8` | Minimum inset from the overflow boundary used for collision detection. | * | `shouldFlip` | `boolean` | `true` | Whether `flip` may reorient when the requested placement does not fit. | * | `constrainSize` | `boolean` | `false` | When `true`, applies `size` middleware (max-height/max-width). | * | `onPlacementChange` | `(placement: Placement) => void` | — | Called when the computed placement changes. | From b4fc052961f988434b2e715cde207d7703095e3e Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Tue, 26 May 2026 15:45:00 +0200 Subject: [PATCH 14/27] test(placement-controller): cover the remaining option contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds six tests against a new DemoPlacementTestFixture host, which pins the trigger to a configurable viewport edge / center and can optionally swap in a 600px-tall floating panel. The fixture isn't referenced from any docs story; the test file is the only consumer. New tests: - FlipReorients — bottom placement reorients when the panel can't fit below a trigger anchored to the viewport bottom. - NoFlipKeepsRequestedSide — same setup with shouldFlip: false keeps the requested side, overflow and all. - OffsetMovesAlongPlacementAxis — offset: 40 shifts translateY by ~40 px for a 'bottom' placement. - CrossOffsetMovesAlongTriggerEdge — crossOffset: 40 shifts translateX without materially affecting translateY. - ContainerPaddingMovesPanelInward — with the trigger near the right edge, a larger containerPadding pulls the panel further inside the boundary. - OnPlacementChangeFiresOnChangeOnly — callback is silent when the computed placement matches the requested one and fires once when flip reorients. DemoPlacementTestFixture also exposes placementChanges (records each onPlacementChange invocation since the last rebind) and the controller field directly, so tests can read firing semantics or call recompute() without going through indirection. --- .../stories/demo-hosts.ts | 193 ++++++++++++++++++ .../test/placement-controller.test.ts | 184 +++++++++++++++++ 2 files changed, 377 insertions(+) diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts index 2c452664825..ab44c472d1d 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts @@ -35,6 +35,7 @@ declare global { 'demo-placement-virtual-trigger': DemoPlacementVirtualTrigger; 'demo-placement-placements': DemoPlacementPlacements; 'demo-placement-cell': DemoPlacementCell; + 'demo-placement-test-fixture': DemoPlacementTestFixture; } } @@ -1050,3 +1051,195 @@ class DemoPlacementCell extends LitElement { `; } } + +/** + * Test-only fixture. Renders a configurable trigger + floating element so + * test play functions can drive the controller through real DOM at a known + * geometry. Not surfaced from any docs story — only the tests reference it. + * + * Geometry controls: + * - `triggerPosition` pins the trigger to a corner / edge / center of the + * surface (which fills the viewport), so flip and shift behaviour can be + * set up deterministically. + * - `tallFloating` swaps in a 600px-tall floating panel, useful for forcing + * the bottom placement to overflow. + * + * Observables: + * - `actualPlacement` reflects the controller's computed placement. + * - `placementChanges` records each `onPlacementChange` invocation (reset + * on every rebind), so tests can assert firing semantics. + * - `controller` is exposed so tests can call `recompute()` directly. + */ +type TriggerPosition = + | 'center' + | 'top-left' + | 'top-center' + | 'top-right' + | 'bottom-center' + | 'bottom-right'; + +@customElement('demo-placement-test-fixture') +export class DemoPlacementTestFixture extends LitElement { + static override styles = css` + :host { + display: block; + position: relative; + inline-size: 100%; + block-size: 100vh; + } + + .surface { + position: relative; + inline-size: 100%; + block-size: 100%; + } + + button.trigger { + position: absolute; + inline-size: 40px; + block-size: 40px; + margin: 0; + padding: 0; + } + + button.trigger[data-position='center'] { + inset-block-start: 50%; + inset-inline-start: 50%; + translate: -50% -50%; + } + + button.trigger[data-position='top-left'] { + inset-block-start: 8px; + inset-inline-start: 8px; + } + + button.trigger[data-position='top-center'] { + inset-block-start: 8px; + inset-inline-start: 50%; + translate: -50% 0; + } + + button.trigger[data-position='top-right'] { + inset-block-start: 8px; + inset-inline-end: 8px; + } + + button.trigger[data-position='bottom-center'] { + inset-block-end: 8px; + inset-inline-start: 50%; + translate: -50% 0; + } + + button.trigger[data-position='bottom-right'] { + inset-block-end: 8px; + inset-inline-end: 8px; + } + + .floating { + position: fixed; + inset: 0 auto auto 0; + inline-size: 80px; + block-size: 40px; + padding: 4px; + background: Canvas; + color: CanvasText; + border: 1px solid currentcolor; + pointer-events: none; + } + + .floating.tall { + block-size: 600px; + } + `; + + @property({ type: String, reflect: true }) + placement: Placement = 'bottom'; + + @property({ type: Number, reflect: true }) + offset = 0; + + @property({ type: Number, attribute: 'cross-offset', reflect: true }) + crossOffset = 0; + + @property({ type: Number, attribute: 'container-padding', reflect: true }) + containerPadding = 8; + + @property({ type: Boolean, attribute: 'should-flip', reflect: true }) + shouldFlip = true; + + @property({ type: Boolean, attribute: 'tall-floating', reflect: true }) + tallFloating = false; + + @property({ type: String, attribute: 'trigger-position', reflect: true }) + triggerPosition: TriggerPosition = 'center'; + + @property({ type: String, attribute: 'actual-placement', reflect: true }) + actualPlacement: Placement | null = null; + + /** Records every `onPlacementChange` invocation since the last rebind. */ + placementChanges: Placement[] = []; + + @query('button.trigger') triggerEl!: HTMLButtonElement; + + @query('.floating') floatingEl!: HTMLDivElement; + + /** Exposed so tests can call `recompute()` and similar directly. */ + controller = new PlacementController(this); + + protected override firstUpdated(): void { + this.bind(); + } + + protected override updated(changed: PropertyValues): void { + if ( + changed.has('placement') || + changed.has('offset') || + changed.has('crossOffset') || + changed.has('containerPadding') || + changed.has('shouldFlip') || + changed.has('triggerPosition') + ) { + this.bind(); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback?.(); + this.controller.stop(); + } + + private bind(): void { + if (!this.triggerEl || !this.floatingEl) { + return; + } + this.placementChanges = []; + this.controller.start(this.triggerEl, this.floatingEl, { + placement: this.placement, + offset: this.offset, + crossOffset: this.crossOffset, + containerPadding: this.containerPadding, + shouldFlip: this.shouldFlip, + onPlacementChange: (next) => { + this.actualPlacement = next; + this.placementChanges.push(next); + }, + }); + } + + protected override render(): TemplateResult { + return html` +
+ +
+ ${this.placement} +
+
+ `; + } +} diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts index 94b6c0cc15a..db280fe4c05 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -9,6 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +import { html } from 'lit'; import { expect } from '@storybook/test'; import type { Meta, StoryObj as Story } from '@storybook/web-components'; @@ -19,6 +20,7 @@ import { fromFloatingPlacement, toFloatingPlacement } from '../index.js'; import type { DemoPlacementConstrainSize, DemoPlacementPlayground, + DemoPlacementTestFixture, DemoPlacementVirtualTrigger, } from '../stories/demo-hosts.js'; import meta, { @@ -27,6 +29,12 @@ import meta, { VirtualTrigger, } from '../stories/placement-controller.stories.js'; +const testFixtureStory: Story = { + render: () => html` + + `, +}; + function readTranslate(el: HTMLElement): [number, number] { const match = el.style.translate.match(/^(-?\d*\.?\d+)px\s+(-?\d*\.?\d+)px$/); if (!match) { @@ -303,3 +311,179 @@ export const RapidStartReplacesPriorSession: Story = { }); }, }; + +/** + * With `shouldFlip: true` and a floating panel that can't fit below a + * trigger pinned to the viewport bottom, `flip` middleware reorients to + * the opposite side. + */ +export const FlipReorients: Story = { + ...testFixtureStory, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-test-fixture' + ); + host.placement = 'bottom'; + host.triggerPosition = 'bottom-center'; + host.tallFloating = true; + host.shouldFlip = true; + await nextFrames(); + + await step('actualPlacement reorients away from bottom', () => { + expect(host.actualPlacement).toBeTruthy(); + expect(host.actualPlacement).not.toBe('bottom'); + }); + }, +}; + +/** + * With `shouldFlip: false`, the controller keeps the requested side even + * when the floating panel overflows the boundary. + */ +export const NoFlipKeepsRequestedSide: Story = { + ...testFixtureStory, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-test-fixture' + ); + host.placement = 'bottom'; + host.triggerPosition = 'bottom-center'; + host.tallFloating = true; + host.shouldFlip = false; + await nextFrames(); + + await step('actualPlacement stays at the requested side', () => { + expect(host.actualPlacement).toBe('bottom'); + }); + }, +}; + +/** + * The `offset` option adds pixels along the placement direction. With + * placement `'bottom'`, increasing `offset` should push the floating + * element further down (larger `translateY`). + */ +export const OffsetMovesAlongPlacementAxis: Story = { + ...testFixtureStory, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-test-fixture' + ); + host.placement = 'bottom'; + host.triggerPosition = 'top-center'; + host.offset = 0; + await nextFrames(); + const [, y0] = readTranslate(host.floatingEl); + + await step('offset: 40 shifts translateY by ~40px', async () => { + host.offset = 40; + await nextFrames(); + const [, y40] = readTranslate(host.floatingEl); + expect(y40 - y0).toBeGreaterThanOrEqual(30); + expect(y40 - y0).toBeLessThanOrEqual(50); + }); + }, +}; + +/** + * The `crossOffset` option slides along the trigger edge. With placement + * `'bottom'`, increasing `crossOffset` should shift the floating element + * sideways (`translateX`) without changing `translateY` materially. + */ +export const CrossOffsetMovesAlongTriggerEdge: Story = { + ...testFixtureStory, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-test-fixture' + ); + host.placement = 'bottom'; + host.triggerPosition = 'center'; + host.crossOffset = 0; + await nextFrames(); + const [x0, y0] = readTranslate(host.floatingEl); + + await step( + 'crossOffset: 40 shifts translateX, not translateY', + async () => { + host.crossOffset = 40; + await nextFrames(); + const [x40, y40] = readTranslate(host.floatingEl); + expect(Math.abs(x40 - x0)).toBeGreaterThan(20); + expect(Math.abs(y40 - y0)).toBeLessThan(5); + } + ); + }, +}; + +/** + * The `containerPadding` option controls the inset enforced by `shift` + * when the floating element would overflow the boundary. With the + * trigger near the right edge, raising `containerPadding` should push + * the floating element further inward (smaller `translateX`). + */ +export const ContainerPaddingMovesPanelInward: Story = { + ...testFixtureStory, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-test-fixture' + ); + host.placement = 'bottom'; + host.triggerPosition = 'top-right'; + host.containerPadding = 8; + await nextFrames(); + const [xSmall] = readTranslate(host.floatingEl); + + await step( + 'larger containerPadding pulls the panel further inside the boundary', + async () => { + host.containerPadding = 64; + await nextFrames(); + const [xLarge] = readTranslate(host.floatingEl); + // With trigger near the right edge, shift keeps the panel inside + // the viewport. A larger padding means the panel sits further + // from the right edge — i.e. a smaller translateX. + expect(xLarge).toBeLessThan(xSmall); + } + ); + }, +}; + +/** + * `onPlacementChange` is documented to fire only when the computed + * placement differs from the synchronous initial value. When the panel + * fits on the requested side, the callback must not fire. When `flip` + * reorients, it fires once with the new placement. + */ +export const OnPlacementChangeFiresOnChangeOnly: Story = { + ...testFixtureStory, + play: async ({ canvasElement, step }) => { + const host = await getComponent( + canvasElement, + 'demo-placement-test-fixture' + ); + host.placement = 'bottom'; + host.triggerPosition = 'top-center'; + host.tallFloating = false; + host.shouldFlip = true; + await nextFrames(); + + await step('no callback when computed placement matches requested', () => { + expect(host.placementChanges).toEqual([]); + }); + + await step('callback fires when flip reorients', async () => { + host.triggerPosition = 'bottom-center'; + host.tallFloating = true; + await nextFrames(); + expect(host.placementChanges.length).toBeGreaterThan(0); + expect(host.placementChanges[host.placementChanges.length - 1]).not.toBe( + 'bottom' + ); + }); + }, +}; From c3d22f440640a68f032006d876a063a439b6e4f7 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Thu, 28 May 2026 09:29:27 +0200 Subject: [PATCH 15/27] chore: simplify api --- 2nd-gen/packages/core/controllers/index.ts | 1 - .../controllers/placement-controller/index.ts | 1 - .../placement-controller/src/index.ts | 25 ----------- .../src/placement-controller.ts | 43 ++++--------------- .../placement-controller/src/types.ts | 22 +++------- .../stories/placement-controller.stories.ts | 15 ++++--- 2nd-gen/packages/swc/.storybook/main.ts | 2 +- 7 files changed, 26 insertions(+), 83 deletions(-) delete mode 100644 2nd-gen/packages/core/controllers/placement-controller/src/index.ts diff --git a/2nd-gen/packages/core/controllers/index.ts b/2nd-gen/packages/core/controllers/index.ts index 0b60b000101..895a959d0ed 100644 --- a/2nd-gen/packages/core/controllers/index.ts +++ b/2nd-gen/packages/core/controllers/index.ts @@ -31,7 +31,6 @@ export { PlacementController, toFloatingPlacement, type Placement, - type PlacementHostConfig, type PlacementOptions, type VirtualTrigger, } from './placement-controller/index.js'; diff --git a/2nd-gen/packages/core/controllers/placement-controller/index.ts b/2nd-gen/packages/core/controllers/placement-controller/index.ts index 7e973f2eccd..e97cd52fead 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/index.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/index.ts @@ -16,7 +16,6 @@ export { PlacementController, toFloatingPlacement, type Placement, - type PlacementHostConfig, type PlacementOptions, type VirtualTrigger, } from './src/placement-controller.js'; diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/index.ts b/2nd-gen/packages/core/controllers/placement-controller/src/index.ts deleted file mode 100644 index 43ae57d79ed..00000000000 --- a/2nd-gen/packages/core/controllers/placement-controller/src/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright 2026 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -export { - ALL_PLACEMENTS, - fromFloatingPlacement, - PlacementController, - toFloatingPlacement, - type Placement, - type PlacementHostConfig, - type PlacementOptions, - type VirtualTrigger, -} from './placement-controller.js'; -// Note: `getFallbackPlacements` is intentionally not re-exported. It is an -// implementation detail of the `flip` middleware composition and is not part -// of the public surface. diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts index 201af95302a..9be6852bf10 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts @@ -25,12 +25,7 @@ import { fromFloatingPlacement, toFloatingPlacement, } from './placement-conversion.js'; -import type { - Placement, - PlacementHostConfig, - PlacementOptions, - VirtualTrigger, -} from './types.js'; +import type { Placement, PlacementOptions, VirtualTrigger } from './types.js'; /** Minimum height for the floating content when `constrainSize` is enabled. */ const MIN_FLOATING_HEIGHT = 100; @@ -112,12 +107,10 @@ export class PlacementController implements ReactiveController { * `stop` has been called. * * Set synchronously to the requested `PlacementOptions.placement` - * (or `DEFAULT_PLACEMENT`) when `start` is called, then - * updated to the value returned by `computePosition` once measurement - * resolves. `PlacementOptions.onPlacementChange` fires only when the - * computed value differs from the synchronous initial value — consumers - * that need a "first compute resolved" signal should read this property - * after their own open transition completes. + * (or `DEFAULT_PLACEMENT`) when `start` is called, then refreshed on every + * successful `computePlacement` pass. `PlacementOptions.onPlacementChange` + * fires alongside each refresh, so consumers can mirror this property in + * a single callback without also reading it synchronously after `start()`. */ public actualPlacement: Placement | null = null; @@ -127,23 +120,12 @@ export class PlacementController implements ReactiveController { */ public isConstrained = false; - /** - * Reserved configuration object. Currently unused; declared for forward - * compatibility so consuming components can pass host-level integration - * hooks (e.g. a tip-element resolver for future `arrow` middleware - * integration) without a constructor signature change. - */ - private readonly config: PlacementHostConfig; - /** * Registers this controller on `host` via `addController`. * * @param host - Reactive element that owns the floating surface lifecycle. - * @param config - Optional host configuration. Reserved for forward - * compatibility (currently has no required fields). */ - constructor(host: ReactiveControllerHost, config: PlacementHostConfig = {}) { - this.config = config; + constructor(host: ReactiveControllerHost) { host.addController(this); } @@ -370,19 +352,12 @@ export class PlacementController implements ReactiveController { }); const nextPlacement = fromFloatingPlacement(placement); - if (nextPlacement !== this.actualPlacement) { - this.actualPlacement = nextPlacement; - options.onPlacementChange?.(nextPlacement); - } + this.actualPlacement = nextPlacement; + options.onPlacementChange?.(nextPlacement); } } -export type { - Placement, - PlacementHostConfig, - PlacementOptions, - VirtualTrigger, -} from './types.js'; +export type { Placement, PlacementOptions, VirtualTrigger } from './types.js'; export { ALL_PLACEMENTS } from './types.js'; export { fromFloatingPlacement, diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts index b4870a80ce0..69bce90a647 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts @@ -67,20 +67,6 @@ export interface VirtualTrigger { contextElement?: Element; } -// ───────────────────────── -// HOST CONFIG -// ───────────────────────── - -/** - * Host-level configuration passed once to the `PlacementController` - * constructor. Reserved for forward compatibility — currently has no - * required fields. - */ -export interface PlacementHostConfig { - /** Reserved for future use. */ - readonly _reserved?: never; -} - // ───────────────────────── // OPTIONS // ───────────────────────── @@ -166,8 +152,12 @@ export interface PlacementOptions { constrainSize?: boolean; /** - * Called whenever the computed placement changes. Receives the hyphenated - * placement value. + * Called after every successful `computePlacement` pass with the + * computed hyphenated placement. Fires once after first compute and on + * every subsequent `autoUpdate` tick — even if the value is unchanged — + * so callers can mirror `actualPlacement` in a single callback. If you + * only care about transitions, compare the incoming value against the + * previous one in the handler. */ onPlacementChange?: (placement: Placement) => void; } diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts index c89e9dc866e..755d466fbee 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts @@ -339,10 +339,15 @@ export const ConstrainSize: Story = { }; /** - * Use **`onPlacementChange`** when styling or host state must track the **computed** placement - * after middleware runs — on first open and whenever **`flip`** reorients after scroll or - * resize. The callback receives the same hyphenated value as **`actualPlacement`**; the - * controller does **not** write placement classes or attributes on the floating element. + * Use **`onPlacementChange`** when styling or host state must track the **computed** + * placement after middleware runs. The callback fires once after first compute and again + * on every subsequent `autoUpdate` tick — even when the value is unchanged — so a single + * handler is enough to mirror **`actualPlacement`** into your component's state. If you + * only care about transitions, compare the incoming value against the previous one in + * the handler. + * + * The callback receives the same hyphenated value as **`actualPlacement`**; the controller + * does **not** write placement classes or attributes on the floating element. * * ```typescript * this.placement.start(this.trigger, this.panel, { @@ -415,7 +420,7 @@ export const VirtualTrigger: Story = { * | `containerPadding` | `number` | `8` | Minimum inset from the overflow boundary used for collision detection. | * | `shouldFlip` | `boolean` | `true` | Whether `flip` may reorient when the requested placement does not fit. | * | `constrainSize` | `boolean` | `false` | When `true`, applies `size` middleware (max-height/max-width). | - * | `onPlacementChange` | `(placement: Placement) => void` | — | Called when the computed placement changes. | + * | `onPlacementChange` | `(placement: Placement) => void` | — | Fires after every successful compute with the resolved hyphenated placement. | * * ### Types * diff --git a/2nd-gen/packages/swc/.storybook/main.ts b/2nd-gen/packages/swc/.storybook/main.ts index 328b1a81214..c6d5d770678 100644 --- a/2nd-gen/packages/swc/.storybook/main.ts +++ b/2nd-gen/packages/swc/.storybook/main.ts @@ -138,7 +138,7 @@ if (storybookMode === 'dev') { }); stories.push({ ...CORE_STORY_ROOT, - files: '**/stories/**/*.test.ts', + files: '**/test/**/*.test.ts', }); } From 13085964c22a3da3f15fb21f5f4f89cfb1708ed8 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Thu, 28 May 2026 14:30:16 +0200 Subject: [PATCH 16/27] chore: update tests --- .../test/placement-controller.test.ts | 200 ++++++++---------- 1 file changed, 94 insertions(+), 106 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts index db280fe4c05..2176c2ec90e 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ import { html } from 'lit'; -import { expect } from '@storybook/test'; +import { expect, waitFor } from '@storybook/test'; import type { Meta, StoryObj as Story } from '@storybook/web-components'; import '../stories/demo-hosts.js'; @@ -43,21 +43,6 @@ function readTranslate(el: HTMLElement): [number, number] { return [Number(match[1]), Number(match[2])]; } -function nextFrames(count = 2): Promise { - return new Promise((resolve) => { - let remaining = count; - const tick = (): void => { - remaining -= 1; - if (remaining <= 0) { - resolve(); - } else { - requestAnimationFrame(tick); - } - }; - requestAnimationFrame(tick); - }); -} - export default { ...meta, title: 'Controllers/Placement controller/Tests', @@ -72,17 +57,17 @@ export const AlignsStartAndEnd: Story = { canvasElement, 'demo-placement-playground' ); - await nextFrames(); + await waitFor(() => expect(host.actualPlacement).toBe('bottom')); await step( 'bottom-start and bottom-end differ on the cross axis', async () => { host.placement = 'bottom-start'; - await nextFrames(); + await waitFor(() => expect(host.actualPlacement).toBe('bottom-start')); const [startX] = readTranslate(host.floatingEl); host.placement = 'bottom-end'; - await nextFrames(); + await waitFor(() => expect(host.actualPlacement).toBe('bottom-end')); const [endX] = readTranslate(host.floatingEl); expect(startX).not.toBe(endX); @@ -98,13 +83,9 @@ export const PositionsBelowTrigger: Story = { canvasElement, 'demo-placement-playground' ); - await nextFrames(); + await waitFor(() => expect(host.actualPlacement).toBe('bottom')); - await step('reports computed placement', async () => { - expect(host.actualPlacement).toBe('bottom'); - }); - - await step('floating element sits below trigger', async () => { + await step('floating element sits below trigger', () => { const triggerRect = host.triggerEl.getBoundingClientRect(); const [, y] = readTranslate(host.floatingEl); expect(y).toBeGreaterThanOrEqual(Math.floor(triggerRect.bottom)); @@ -119,8 +100,7 @@ export const VirtualTriggerMoves: Story = { canvasElement, 'demo-placement-virtual-trigger' ); - await nextFrames(); - + await waitFor(() => expect(host.floatingEl.style.translate).not.toBe('')); const [, initialY] = readTranslate(host.floatingEl); await step('click moves the floating element', async () => { @@ -133,9 +113,10 @@ export const VirtualTriggerMoves: Story = { clientY: rect.top + 200, }) ); - await nextFrames(); - const [, nextY] = readTranslate(host.floatingEl); - expect(nextY).toBeGreaterThan(initialY); + await waitFor(() => { + const [, y] = readTranslate(host.floatingEl); + expect(y).toBeGreaterThan(initialY); + }); }); }, }; @@ -147,13 +128,15 @@ export const StopOnDisconnect: Story = { canvasElement, 'demo-placement-playground' ); - await nextFrames(); + await waitFor(() => expect(host.floatingEl.style.translate).not.toBe('')); const before = host.floatingEl.style.translate; await step('disconnect freezes translate', async () => { host.remove(); window.dispatchEvent(new Event('resize')); - await nextFrames(); + // Asserting absence — give the (now-disconnected) controller a moment + // to confirm it doesn't write further translate updates. + await new Promise((resolve) => setTimeout(resolve, 50)); expect(host.floatingEl.style.translate).toBe(before); }); }, @@ -214,7 +197,7 @@ export const ConversionFunctionsRoundTrip: Story = { /** * Verifies the four previously-broken logical-side placements compute a - * non-zero translate (i.e. Floating UI received a valid placement). Before + * valid translate (i.e. Floating UI received a valid placement). Before * the fix, `toFloatingPlacement('start-top')` returned the invalid * `'left-top'` and `computePosition` produced nonsensical coordinates. */ @@ -226,7 +209,7 @@ export const LogicalSidePlacementsCompute: Story = { 'demo-placement-playground' ); host.shouldFlip = false; - await nextFrames(); + await waitFor(() => expect(host.actualPlacement).toBe('bottom')); const cases: Array<{ requested: Parameters[0]; @@ -241,14 +224,12 @@ export const LogicalSidePlacementsCompute: Story = { for (const { requested, expected } of cases) { await step(`${requested} → ${expected}`, async () => { host.placement = requested; - await nextFrames(); + await waitFor(() => expect(host.actualPlacement).toBe(expected)); const [tx, ty] = readTranslate(host.floatingEl); // Translate must be a real number (not NaN, which is what invalid // Floating UI placements produced before the fix). expect(Number.isFinite(tx)).toBe(true); expect(Number.isFinite(ty)).toBe(true); - // And the resolved placement comes back through the SWC union. - expect(host.actualPlacement).toBe(expected); }); } }, @@ -256,8 +237,7 @@ export const LogicalSidePlacementsCompute: Story = { /** * `constrainSize: true` installs Floating UI's `size` middleware, which - * writes `max-height` (and `max-width`) on the floating element when the - * available space is smaller than the floating content. + * writes `max-height` and `max-width` on the floating element. */ export const ConstrainSizeAppliesMaxHeight: Story = { ...ConstrainSize, @@ -266,30 +246,25 @@ export const ConstrainSizeAppliesMaxHeight: Story = { canvasElement, 'demo-placement-constrain-size' ); - await nextFrames(); - - await step('floating element receives a numeric max-height', () => { - const maxHeight = host.floatingEl.style.maxHeight; - expect(maxHeight).toMatch(/^\d+px$/); - }); + await waitFor(() => + expect(host.floatingEl.style.maxHeight).toMatch(/^\d+px$/) + ); await step( - 'isConstrained reflects when content exceeds the surface', + 'floating element receives numeric max-height and max-width', () => { - // The demo lists 24 items in a 180px surface, so size middleware - // clamps height and reports `isConstrained`. - expect(host.isConstrained).toBe(true); + expect(host.floatingEl.style.maxHeight).toMatch(/^\d+px$/); + expect(host.floatingEl.style.maxWidth).toMatch(/^\d+px$/); } ); }, }; /** - * Calling `start()` again replaces the active session. The prior - * `autoUpdate` cleanup must have run so its callback no longer drives - * compute. We verify this indirectly: cycle through several placements - * rapidly and observe that the final placement is reflected (i.e. no - * stale `start-*` session writes coordinates after `end-*` is requested). + * Calling `start()` again replaces the active session. Cycling through + * several placements rapidly and waiting for the final one's compute to + * land verifies that prior sessions don't write stale coordinates after + * the latest `start()` call. */ export const RapidStartReplacesPriorSession: Story = { ...Playground, @@ -299,15 +274,14 @@ export const RapidStartReplacesPriorSession: Story = { 'demo-placement-playground' ); host.shouldFlip = false; - await nextFrames(); + await waitFor(() => expect(host.actualPlacement).toBe('bottom')); await step('final placement wins after a burst of changes', async () => { host.placement = 'top'; host.placement = 'left'; host.placement = 'right'; host.placement = 'bottom-end'; - await nextFrames(); - expect(host.actualPlacement).toBe('bottom-end'); + await waitFor(() => expect(host.actualPlacement).toBe('bottom-end')); }); }, }; @@ -328,11 +302,12 @@ export const FlipReorients: Story = { host.triggerPosition = 'bottom-center'; host.tallFloating = true; host.shouldFlip = true; - await nextFrames(); - await step('actualPlacement reorients away from bottom', () => { - expect(host.actualPlacement).toBeTruthy(); - expect(host.actualPlacement).not.toBe('bottom'); + await step('actualPlacement reorients away from bottom', async () => { + await waitFor(() => { + expect(host.actualPlacement).toBeTruthy(); + expect(host.actualPlacement).not.toBe('bottom'); + }); }); }, }; @@ -352,18 +327,17 @@ export const NoFlipKeepsRequestedSide: Story = { host.triggerPosition = 'bottom-center'; host.tallFloating = true; host.shouldFlip = false; - await nextFrames(); - await step('actualPlacement stays at the requested side', () => { - expect(host.actualPlacement).toBe('bottom'); + await step('actualPlacement stays at the requested side', async () => { + await waitFor(() => expect(host.actualPlacement).toBe('bottom')); }); }, }; /** * The `offset` option adds pixels along the placement direction. With - * placement `'bottom'`, increasing `offset` should push the floating - * element further down (larger `translateY`). + * placement `'bottom'`, increasing `offset` pushes the floating element + * further down (larger `translateY`). */ export const OffsetMovesAlongPlacementAxis: Story = { ...testFixtureStory, @@ -375,22 +349,23 @@ export const OffsetMovesAlongPlacementAxis: Story = { host.placement = 'bottom'; host.triggerPosition = 'top-center'; host.offset = 0; - await nextFrames(); + await waitFor(() => expect(host.floatingEl.style.translate).not.toBe('')); const [, y0] = readTranslate(host.floatingEl); await step('offset: 40 shifts translateY by ~40px', async () => { host.offset = 40; - await nextFrames(); - const [, y40] = readTranslate(host.floatingEl); - expect(y40 - y0).toBeGreaterThanOrEqual(30); - expect(y40 - y0).toBeLessThanOrEqual(50); + await waitFor(() => { + const [, y40] = readTranslate(host.floatingEl); + expect(y40 - y0).toBeGreaterThanOrEqual(30); + expect(y40 - y0).toBeLessThanOrEqual(50); + }); }); }, }; /** * The `crossOffset` option slides along the trigger edge. With placement - * `'bottom'`, increasing `crossOffset` should shift the floating element + * `'bottom'`, increasing `crossOffset` shifts the floating element * sideways (`translateX`) without changing `translateY` materially. */ export const CrossOffsetMovesAlongTriggerEdge: Story = { @@ -403,27 +378,28 @@ export const CrossOffsetMovesAlongTriggerEdge: Story = { host.placement = 'bottom'; host.triggerPosition = 'center'; host.crossOffset = 0; - await nextFrames(); + await waitFor(() => expect(host.floatingEl.style.translate).not.toBe('')); const [x0, y0] = readTranslate(host.floatingEl); await step( 'crossOffset: 40 shifts translateX, not translateY', async () => { host.crossOffset = 40; - await nextFrames(); - const [x40, y40] = readTranslate(host.floatingEl); - expect(Math.abs(x40 - x0)).toBeGreaterThan(20); - expect(Math.abs(y40 - y0)).toBeLessThan(5); + await waitFor(() => { + const [x40, y40] = readTranslate(host.floatingEl); + expect(Math.abs(x40 - x0)).toBeGreaterThan(20); + expect(Math.abs(y40 - y0)).toBeLessThan(5); + }); } ); }, }; /** - * The `containerPadding` option controls the inset enforced by `shift` - * when the floating element would overflow the boundary. With the - * trigger near the right edge, raising `containerPadding` should push - * the floating element further inward (smaller `translateX`). + * The `containerPadding` option controls the inset used by `shift` when + * the floating element would overflow the boundary. With the trigger near + * the right edge, raising `containerPadding` pulls the floating element + * further inward (smaller `translateX`). */ export const ContainerPaddingMovesPanelInward: Story = { ...testFixtureStory, @@ -435,31 +411,33 @@ export const ContainerPaddingMovesPanelInward: Story = { host.placement = 'bottom'; host.triggerPosition = 'top-right'; host.containerPadding = 8; - await nextFrames(); + await waitFor(() => expect(host.floatingEl.style.translate).not.toBe('')); const [xSmall] = readTranslate(host.floatingEl); await step( 'larger containerPadding pulls the panel further inside the boundary', async () => { host.containerPadding = 64; - await nextFrames(); - const [xLarge] = readTranslate(host.floatingEl); - // With trigger near the right edge, shift keeps the panel inside - // the viewport. A larger padding means the panel sits further - // from the right edge — i.e. a smaller translateX. - expect(xLarge).toBeLessThan(xSmall); + await waitFor(() => { + const [xLarge] = readTranslate(host.floatingEl); + // With trigger near the right edge, shift keeps the panel inside + // the viewport. A larger padding means the panel sits further + // from the right edge — i.e. a smaller translateX. + expect(xLarge).toBeLessThan(xSmall); + }); } ); }, }; /** - * `onPlacementChange` is documented to fire only when the computed - * placement differs from the synchronous initial value. When the panel - * fits on the requested side, the callback must not fire. When `flip` - * reorients, it fires once with the new placement. + * `onPlacementChange` fires after every successful `computePlacement` pass + * with the computed hyphenated placement — once after first compute and + * again whenever an `autoUpdate` tick produces a new value. The no-flip + * case still hands the callback the requested placement; the flip case + * hands it the flipped value. */ -export const OnPlacementChangeFiresOnChangeOnly: Story = { +export const OnPlacementChangeFiresWithComputedPlacement: Story = { ...testFixtureStory, play: async ({ canvasElement, step }) => { const host = await getComponent( @@ -470,20 +448,30 @@ export const OnPlacementChangeFiresOnChangeOnly: Story = { host.triggerPosition = 'top-center'; host.tallFloating = false; host.shouldFlip = true; - await nextFrames(); - await step('no callback when computed placement matches requested', () => { - expect(host.placementChanges).toEqual([]); - }); + await step( + 'callback fires with the requested placement when nothing flips', + async () => { + await waitFor(() => { + expect(host.placementChanges.length).toBeGreaterThan(0); + expect(host.placementChanges[host.placementChanges.length - 1]).toBe( + 'bottom' + ); + }); + } + ); - await step('callback fires when flip reorients', async () => { - host.triggerPosition = 'bottom-center'; - host.tallFloating = true; - await nextFrames(); - expect(host.placementChanges.length).toBeGreaterThan(0); - expect(host.placementChanges[host.placementChanges.length - 1]).not.toBe( - 'bottom' - ); - }); + await step( + 'callback fires with the new placement when flip reorients', + async () => { + host.triggerPosition = 'bottom-center'; + host.tallFloating = true; + await waitFor(() => { + expect( + host.placementChanges.some((placement) => placement !== 'bottom') + ).toBe(true); + }); + } + ); }, }; From f7d82613e23d9cb95eec792f6050d719c0eb7a89 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Thu, 28 May 2026 16:52:54 +0200 Subject: [PATCH 17/27] chore: simplify some tests --- .../test/placement-controller.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts index 2176c2ec90e..192091cdc0c 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -131,12 +131,15 @@ export const StopOnDisconnect: Story = { await waitFor(() => expect(host.floatingEl.style.translate).not.toBe('')); const before = host.floatingEl.style.translate; - await step('disconnect freezes translate', async () => { + await step('disconnect freezes translate', () => { host.remove(); + // `disconnectedCallback` fires synchronously, so by the time this + // line returns the controller has called `stop()` (autoUpdate + // listeners removed, session nulled). A subsequent in-flight + // `computePlacement` would still bail at the session check before + // writing — so dispatching a resize here is a no-op, kept only as + // documentation of intent. window.dispatchEvent(new Event('resize')); - // Asserting absence — give the (now-disconnected) controller a moment - // to confirm it doesn't write further translate updates. - await new Promise((resolve) => setTimeout(resolve, 50)); expect(host.floatingEl.style.translate).toBe(before); }); }, From 0737d4598bde71bec37bde686add80e5a81f0ce1 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Thu, 28 May 2026 17:53:04 +0200 Subject: [PATCH 18/27] refactor(placement-controller): match 1st-gen middleware order and always-on size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile two behavioural deviations from 1st-gen that the migration plan didn't justify against the cost of breaking consumer parity. Middleware order — restored to 1st-gen: offset → shift → flip → size. The 2nd-gen had switched to offset → flip → shift → size on the rationale that it was 'Floating UI's canonical recommended order'; Floating UI doesn't actually prescribe one, and the two orders produce different positions in edge cases (trigger near a corner, panel close to viewport edge). The 1st-gen pattern is what downstream consumers have been seeing for years — no upside to changing it. size middleware — now always installed. Dropped the constrainSize option. The apply callback uses 1st-gen semantics: it tracks initialHeight from the first un-constrained compute as the baseline, writes max-width on every compute, and writes max-height only when isConstrained is true (content overflows the available space). stop() now unconditionally clears max-height / max-width and resets initialHeight. The ConstrainSize story was renamed to SizeAlwaysClamps and reframed as documentation of the always-on behaviour rather than an opt-in toggle. PlacementOptions.constrainSize is removed; the Playground demo's constrainSize property + attribute + bind entry are gone; the API table no longer lists constrainSize; the test that previously asserted 'constrainSize applies max-height' was rewritten to use the test fixture with tallFloating + triggerPosition='bottom-center' + shouldFlip=false so the overflow scenario is deterministic. --- .../src/placement-controller.ts | 76 ++++++++++++------- .../placement-controller/src/types.ts | 9 --- .../stories/demo-hosts.ts | 20 +---- .../stories/placement-controller.stories.ts | 49 +++++------- .../test/placement-controller.test.ts | 45 ++++++----- 5 files changed, 96 insertions(+), 103 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts index 9be6852bf10..ebf73819965 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts @@ -27,7 +27,7 @@ import { } from './placement-conversion.js'; import type { Placement, PlacementOptions, VirtualTrigger } from './types.js'; -/** Minimum height for the floating content when `constrainSize` is enabled. */ +/** Minimum height the `size` middleware will allow the floating content to clamp to. */ const MIN_FLOATING_HEIGHT = 100; const DEFAULT_PLACEMENT: Placement = 'bottom'; @@ -90,8 +90,6 @@ type ActiveSession = { * this.placement.start(this.trigger, this.dialog, { * placement: 'bottom-start', * offset: 0, - * crossOffset: 0, - * shouldFlip: true, * onPlacementChange: (p) => { * this.actualPlacement = p; * }, @@ -115,11 +113,20 @@ export class PlacementController implements ReactiveController { public actualPlacement: Placement | null = null; /** - * Whether `PlacementOptions.constrainSize` clamped the floating - * element's height on the last compute. + * Whether the `size` middleware clamped the floating element's height + * on the last compute. `true` when the content would otherwise overflow + * the available space below/above the trigger, in which case the + * controller writes a `max-height` style on the floating element. */ public isConstrained = false; + /** + * Natural floating-element height from the first un-constrained compute, + * used as the baseline that determines whether subsequent computes are + * clamping content. Cleared on `stop()`. + */ + private initialHeight?: number; + /** * Registers this controller on `host` via `addController`. * @@ -213,14 +220,22 @@ export class PlacementController implements ReactiveController { /** * Stop positioning: tear down `autoUpdate`, clear `actualPlacement`, - * and reset `isConstrained`. Safe to call multiple times. + * reset `isConstrained`, and remove the `max-height` / `max-width` + * inline styles the `size` middleware wrote. Safe to call multiple + * times. */ public stop(): void { + const floating = this.session?.floating; + if (floating) { + floating.style.removeProperty('max-height'); + floating.style.removeProperty('max-width'); + } this.cleanup?.(); this.cleanup = undefined; this.session = null; this.actualPlacement = null; this.isConstrained = false; + this.initialHeight = undefined; } /** @@ -301,31 +316,36 @@ export class PlacementController implements ReactiveController { fallbackStrategy: 'bestFit', })); + // Middleware order matches 1st-gen: offset → shift → flip → size. + // `shift` runs before `flip` so the panel slides along the current + // side before any decision to flip; `size` runs last so it sees the + // final placement when clamping max-height / max-width. const middleware = [ offset({ mainAxis, crossAxis }), - ...(flipMiddleware ? [flipMiddleware] : []), shift({ padding: containerPadding }), - ...(options.constrainSize - ? [ - size({ - padding: containerPadding, - apply: ({ availableHeight, availableWidth, rects }) => { - const maxHeight = Math.max( - MIN_FLOATING_HEIGHT, - Math.floor(availableHeight) - ); - const actualHeight = rects.floating.height; - this.isConstrained = - actualHeight >= maxHeight || - Math.floor(availableHeight) <= MIN_FLOATING_HEIGHT; - Object.assign(floating.style, { - maxHeight: `${maxHeight}px`, - maxWidth: `${Math.floor(availableWidth)}px`, - }); - }, - }), - ] - : []), + flipMiddleware, + size({ + padding: containerPadding, + apply: ({ availableHeight, availableWidth, rects }) => { + const maxHeight = Math.max( + MIN_FLOATING_HEIGHT, + Math.floor(availableHeight) + ); + const actualHeight = rects.floating.height; + // Track the natural (un-constrained) height on the first compute + // so subsequent computes can detect whether content is currently + // being clamped. + this.initialHeight = !this.isConstrained + ? actualHeight + : (this.initialHeight ?? actualHeight); + this.isConstrained = + actualHeight < this.initialHeight || maxHeight <= actualHeight; + Object.assign(floating.style, { + maxWidth: `${Math.floor(availableWidth)}px`, + maxHeight: this.isConstrained ? `${maxHeight}px` : '', + }); + }, + }), ]; const { x, y, placement } = await computePosition(trigger, floating, { diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts index 69bce90a647..bf272b60f2a 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts @@ -142,15 +142,6 @@ export interface PlacementOptions { */ shouldFlip?: boolean; - /** - * When `true`, applies Floating UI's `size` middleware so long content - * scrolls inside the viewport. Opt-in — consumers that need it (list - * surfaces such as menus, pickers, comboboxes) enable it explicitly. - * - * @default false - */ - constrainSize?: boolean; - /** * Called after every successful `computePlacement` pass with the * computed hyphenated placement. Fires once after first compute and on diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts index ab44c472d1d..28666e99592 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts @@ -84,7 +84,6 @@ const PLAYGROUND_DEFAULTS = { crossOffset: 0, containerPadding: 8, shouldFlip: true, - constrainSize: false, }; /** Spectrum typography utility classes (requires `typography.css` in Storybook). */ @@ -249,9 +248,6 @@ export class DemoPlacementPlayground extends LitElement { @property({ type: Boolean, attribute: 'should-flip', reflect: true }) shouldFlip = PLAYGROUND_DEFAULTS.shouldFlip; - @property({ type: Boolean, attribute: 'constrain-size', reflect: true }) - constrainSize = PLAYGROUND_DEFAULTS.constrainSize; - @property({ type: String, attribute: 'actual-placement', reflect: true }) actualPlacement: Placement | null = null; @@ -270,8 +266,7 @@ export class DemoPlacementPlayground extends LitElement { changed.has('offset') || changed.has('crossOffset') || changed.has('containerPadding') || - changed.has('shouldFlip') || - changed.has('constrainSize') + changed.has('shouldFlip') ) { this.bind(); } @@ -296,7 +291,6 @@ export class DemoPlacementPlayground extends LitElement { crossOffset: this.crossOffset, containerPadding: this.containerPadding, shouldFlip: this.shouldFlip, - constrainSize: this.constrainSize, }, (next) => { this.actualPlacement = next; @@ -781,15 +775,9 @@ export class DemoPlacementConstrainSize extends LitElement { private controller = new PlacementController(this); protected override firstUpdated(): void { - bindController( - this.controller, - this.triggerEl, - this.floatingEl, - { constrainSize: true }, - () => { - this.isConstrained = this.controller.isConstrained; - } - ); + bindController(this.controller, this.triggerEl, this.floatingEl, {}, () => { + this.isConstrained = this.controller.isConstrained; + }); } override disconnectedCallback(): void { diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts index 755d466fbee..717b9a9f629 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts @@ -29,7 +29,6 @@ const args = { crossOffset: 0, containerPadding: 8, shouldFlip: true, - constrainSize: false, }; const argTypes = { @@ -84,16 +83,6 @@ const argTypes = { defaultValue: { summary: 'true' }, }, }, - constrainSize: { - control: 'boolean', - description: - 'When `true`, sets `maxHeight` / `maxWidth` on the floating element so long content scrolls inside the viewport.', - table: { - category: 'Options', - type: { summary: 'boolean' }, - defaultValue: { summary: 'false' }, - }, - }, } satisfies Meta['argTypes']; /** @@ -115,7 +104,6 @@ const meta: Meta = { cross-offset=${a.crossOffset} container-padding=${a.containerPadding} ?should-flip=${a.shouldFlip} - ?constrain-size=${a.constrainSize} > `, parameters: { @@ -166,10 +154,12 @@ export const Overview: Story = { * * ### Middleware stack * + * Same order as 1st-gen: `offset → shift → flip → size`. + * * 1. **`offset`** — trigger-relative gap along the placement direction (`offset` option) and along the trigger edge (`crossOffset` option). - * 2. **`flip`** (when `shouldFlip: true`) — reorients when there is not enough room, respecting `containerPadding` inset from the overflow boundary. - * 3. **`shift`** — **always installed**; slides the floating element along the placement axis to keep it inside the boundary using `containerPadding` as inset. There's no opt-out — disabling it would let the panel clip off the viewport edge. - * 4. **`size`** (when `constrainSize: true`) — clamps `maxHeight` / `maxWidth` for scrollable lists. + * 2. **`shift`** — slides the floating element along the placement axis to keep it inside the boundary using `containerPadding` as inset. Always installed. + * 3. **`flip`** (when `shouldFlip: true`) — reorients to the opposite side when there is not enough room, respecting `containerPadding`. + * 4. **`size`** — always installed. Writes `max-width` on every compute and `max-height` when content overflows the available space. Read `isConstrained` to detect when clamping is active. * * ### What the caller owns * @@ -317,20 +307,15 @@ export const ShouldFlip: Story = { }; /** - * With **`constrainSize: true`**, Floating UI **`size`** middleware sets inline - * **`maxHeight`** and **`maxWidth`** on the floating element so long content scrolls - * inside the viewport instead of overflowing off-screen. Readonly **`isConstrained`** is - * `true` when height was clamped on the last compute. - * - * Opt-in — leave disabled when content is guaranteed to fit. + * Floating UI's **`size`** middleware is always installed. On every compute it writes + * **`max-width`** on the floating element and writes **`max-height`** when the natural + * content would otherwise overflow the available space below or above the trigger. The + * readonly **`isConstrained`** property is `true` while `max-height` is being applied, + * so consumers can react to "content is currently clamped" — e.g. show a scrollbar hint. * - * ```typescript - * this.placement.start(this.trigger, this.listbox, { - * constrainSize: true, - * }); - * ``` + * No option to opt out. Matches 1st-gen behaviour. */ -export const ConstrainSize: Story = { +export const SizeAlwaysClamps: Story = { tags: ['behaviors'], render: () => html` @@ -408,7 +393,7 @@ export const VirtualTrigger: Story = { * | Property | Type | Description | * |---|---|---| * | `actualPlacement` | `Placement \| null` | Computed placement after `flip` (hyphenated). `null` when stopped. | - * | `isConstrained` | `boolean` | Whether `constrainSize` clamped height on the last compute. | + * | `isConstrained` | `boolean` | `true` while `size` middleware is applying `max-height` because content would otherwise overflow. | * * ### Options * @@ -419,7 +404,6 @@ export const VirtualTrigger: Story = { * | `crossOffset` | `number` | `0` | Slide along the trigger edge (px), perpendicular to the placement direction. | * | `containerPadding` | `number` | `8` | Minimum inset from the overflow boundary used for collision detection. | * | `shouldFlip` | `boolean` | `true` | Whether `flip` may reorient when the requested placement does not fit. | - * | `constrainSize` | `boolean` | `false` | When `true`, applies `size` middleware (max-height/max-width). | * | `onPlacementChange` | `(placement: Placement) => void` | — | Fires after every successful compute with the resolved hyphenated placement. | * * ### Types @@ -487,9 +471,10 @@ export const Accessibility: Story = { * * The 2nd-gen controller is a **focused subset** of the 1st-gen * `PlacementController`: single `autoUpdate` channel, hyphenated placements, - * callback-based placement surfacing, and opt-in `constrainSize`. It owns - * geometry only — open/close lifecycle, ARIA, focus, and dismissal remain - * the caller's responsibility. + * and callback-based placement surfacing. The middleware stack and the + * always-on `size` clamp behaviour match 1st-gen. It owns geometry only — + * open/close lifecycle, ARIA, focus, and dismissal remain the caller's + * responsibility. * * ### See also * diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts index 192091cdc0c..6f3830b5a77 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -18,13 +18,11 @@ import '../stories/demo-hosts.js'; import { getComponent } from '../../../../swc/utils/test-utils.js'; import { fromFloatingPlacement, toFloatingPlacement } from '../index.js'; import type { - DemoPlacementConstrainSize, DemoPlacementPlayground, DemoPlacementTestFixture, DemoPlacementVirtualTrigger, } from '../stories/demo-hosts.js'; import meta, { - ConstrainSize, Playground, VirtualTrigger, } from '../stories/placement-controller.stories.js'; @@ -239,27 +237,38 @@ export const LogicalSidePlacementsCompute: Story = { }; /** - * `constrainSize: true` installs Floating UI's `size` middleware, which - * writes `max-height` and `max-width` on the floating element. + * `size` middleware is always installed. It writes `max-width` on every + * compute, and `max-height` only when the floating content would otherwise + * overflow the available space. + * + * To assert max-height deterministically we set up the test fixture with a + * 600 px tall floating element and pin the trigger to the bottom of the + * viewport with `shouldFlip: false` — the panel can't fit below, so size + * clamps `max-height` and `isConstrained` flips on. */ -export const ConstrainSizeAppliesMaxHeight: Story = { - ...ConstrainSize, +export const SizeMiddlewareWritesMaxDimensions: Story = { + ...testFixtureStory, play: async ({ canvasElement, step }) => { - const host = await getComponent( + const host = await getComponent( canvasElement, - 'demo-placement-constrain-size' - ); - await waitFor(() => - expect(host.floatingEl.style.maxHeight).toMatch(/^\d+px$/) + 'demo-placement-test-fixture' ); + host.triggerPosition = 'bottom-center'; + host.tallFloating = true; + host.shouldFlip = false; - await step( - 'floating element receives numeric max-height and max-width', - () => { - expect(host.floatingEl.style.maxHeight).toMatch(/^\d+px$/); - expect(host.floatingEl.style.maxWidth).toMatch(/^\d+px$/); - } - ); + await waitFor(() => { + expect(host.floatingEl.style.maxWidth).toMatch(/^\d+px$/); + expect(host.floatingEl.style.maxHeight).toMatch(/^\d+px$/); + }); + + await step('max-width is set on every compute', () => { + expect(host.floatingEl.style.maxWidth).toMatch(/^\d+px$/); + }); + + await step('max-height is set when content overflows', () => { + expect(host.floatingEl.style.maxHeight).toMatch(/^\d+px$/); + }); }, }; From 8759cbf1093cda919bfe846b3f6dc0f3f02b83b6 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Thu, 28 May 2026 23:16:42 +0200 Subject: [PATCH 19/27] feat(placement-controller): wire up arrow middleware (parity with 1st-gen) The controller now installs Floating UI's arrow middleware when a tipElement is passed in PlacementOptions, matching 1st-gen behaviour. After every compute the controller writes inline translate on the tip element using the same pattern as 1st-gen: - top:0 reset for left/right placements (arrow on a vertical edge) - left:0 reset for top/bottom placements (arrow on a horizontal edge) - translate carries the arrow.x / arrow.y from middlewareData CSS positions the tip element relative to the floating element's edge (typically with a negative offset for the half-size of the arrow); the controller only slides it along that edge so it stays pointing at the trigger's center as shift moves the floating panel. API additions: - PlacementOptions.tipElement?: HTMLElement - PlacementOptions.tipPadding?: number (default 8) - stop() now clears the tip element's inline translate/top/left Demo + tests: - New demo-placement-arrow: trigger + floating panel with a CSS triangle tip. The host passes the tip to the controller. - New Arrow story in the docs page (section-order: 8) under Behaviors. - New ArrowMiddlewarePositionsTip test asserts the tip receives a numeric inline translate after the first compute. - Middleware-stack docs and API options table updated. --- .../src/placement-controller.ts | 61 ++++++-- .../placement-controller/src/types.ts | 21 +++ .../stories/demo-hosts.ts | 132 ++++++++++++++++++ .../stories/placement-controller.stories.ts | 31 ++++ .../test/placement-controller.test.ts | 33 +++++ 5 files changed, 267 insertions(+), 11 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts index ebf73819965..d4f970cd8c3 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts @@ -12,6 +12,7 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit'; import { + arrow, autoUpdate, computePosition, flip, @@ -37,6 +38,7 @@ const DEFAULT_OFFSET = 0; const DEFAULT_CROSS_OFFSET = 0; const DEFAULT_CONTAINER_PADDING = 8; const DEFAULT_SHOULD_FLIP = true; +const DEFAULT_TIP_PADDING = 8; /** * Round a pixel value to the nearest device-pixel boundary so translated @@ -220,9 +222,10 @@ export class PlacementController implements ReactiveController { /** * Stop positioning: tear down `autoUpdate`, clear `actualPlacement`, - * reset `isConstrained`, and remove the `max-height` / `max-width` - * inline styles the `size` middleware wrote. Safe to call multiple - * times. + * reset `isConstrained`, and remove inline styles the controller wrote + * (the `size` middleware's `max-height` / `max-width` on the floating + * element, plus the `arrow` middleware's `translate` / `top` / `left` + * on the tip element). Safe to call multiple times. */ public stop(): void { const floating = this.session?.floating; @@ -230,6 +233,12 @@ export class PlacementController implements ReactiveController { floating.style.removeProperty('max-height'); floating.style.removeProperty('max-width'); } + const tipElement = this.session?.options.tipElement; + if (tipElement) { + tipElement.style.removeProperty('translate'); + tipElement.style.removeProperty('top'); + tipElement.style.removeProperty('left'); + } this.cleanup?.(); this.cleanup = undefined; this.session = null; @@ -316,10 +325,15 @@ export class PlacementController implements ReactiveController { fallbackStrategy: 'bestFit', })); - // Middleware order matches 1st-gen: offset → shift → flip → size. + // Middleware order matches 1st-gen: offset → shift → flip → size → arrow. // `shift` runs before `flip` so the panel slides along the current - // side before any decision to flip; `size` runs last so it sees the - // final placement when clamping max-height / max-width. + // side before any decision to flip; `size` runs after the final + // placement is known so it clamps max-height / max-width accurately; + // `arrow` (when a tip element is provided) runs last so it positions + // the tip relative to the trigger after every other middleware has + // settled. + const tipElement = options.tipElement; + const tipPadding = options.tipPadding ?? DEFAULT_TIP_PADDING; const middleware = [ offset({ mainAxis, crossAxis }), shift({ padding: containerPadding }), @@ -346,13 +360,18 @@ export class PlacementController implements ReactiveController { }); }, }), + tipElement ? arrow({ element: tipElement, padding: tipPadding }) : null, ]; - const { x, y, placement } = await computePosition(trigger, floating, { - placement: floatingPlacement, - middleware, - strategy: 'fixed', - }); + const { x, y, placement, middlewareData } = await computePosition( + trigger, + floating, + { + placement: floatingPlacement, + middleware, + strategy: 'fixed', + } + ); if (this.session !== session) { return; } @@ -371,6 +390,26 @@ export class PlacementController implements ReactiveController { translate: `${roundByDPR(translateX)}px ${roundByDPR(translateY)}px`, }); + // Position the tip element (1st-gen pattern). Floating UI exposes + // either `arrow.x` (top/bottom placements — arrow on a horizontal + // edge) or `arrow.y` (left/right placements — arrow on a vertical + // edge). Reset whichever inline offset would conflict with the + // edge we're on, then write the computed translate. + if (tipElement && middlewareData.arrow) { + const { x: arrowX, y: arrowY } = middlewareData.arrow; + Object.assign(tipElement.style, { + top: + placement.startsWith('right') || placement.startsWith('left') + ? '0px' + : '', + left: + placement.startsWith('bottom') || placement.startsWith('top') + ? '0px' + : '', + translate: `${roundByDPR(arrowX ?? 0)}px ${roundByDPR(arrowY ?? 0)}px`, + }); + } + const nextPlacement = fromFloatingPlacement(placement); this.actualPlacement = nextPlacement; options.onPlacementChange?.(nextPlacement); diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts index bf272b60f2a..727022bb94c 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/types.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/types.ts @@ -142,6 +142,27 @@ export interface PlacementOptions { */ shouldFlip?: boolean; + /** + * Optional tip element ("arrow") that should point at the trigger from + * the floating element's edge. When provided, the controller installs + * Floating UI's `arrow` middleware and writes an inline `translate` on + * this element after every compute so it stays aligned with the trigger + * even when `shift` slides the floating panel sideways. CSS positions + * the tip relative to the floating element's edge (typically with a + * negative offset for the half-size of the arrow); the controller only + * moves it along that edge. + */ + tipElement?: HTMLElement; + + /** + * Minimum inset of the tip element from the floating element's + * corners, in pixels. Passed straight to the `arrow` middleware as + * its `padding`. Only meaningful when `tipElement` is provided. + * + * @default 8 + */ + tipPadding?: number; + /** * Called after every successful `computePlacement` pass with the * computed hyphenated placement. Fires once after first compute and on diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts index 28666e99592..042cb393788 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts @@ -36,6 +36,7 @@ declare global { 'demo-placement-placements': DemoPlacementPlacements; 'demo-placement-cell': DemoPlacementCell; 'demo-placement-test-fixture': DemoPlacementTestFixture; + 'demo-placement-arrow': DemoPlacementArrow; } } @@ -1231,3 +1232,134 @@ export class DemoPlacementTestFixture extends LitElement { `; } } + +@customElement('demo-placement-arrow') +export class DemoPlacementArrow extends LitElement { + static override styles = [ + sharedStyles, + css` + .demo { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + } + + .surface { + display: grid; + place-items: center; + block-size: 180px; + } + + button.trigger { + inline-size: 48px; + block-size: 48px; + } + + .floating { + inline-size: 200px; + block-size: auto; + padding: 12px 14px; + border-radius: 6px; + } + + .tip { + position: absolute; + width: 12px; + height: 12px; + rotate: 45deg; + background: Canvas; + border-top: 1px solid currentcolor; + border-left: 1px solid currentcolor; + pointer-events: none; + } + + /* CSS pins the tip to the relevant edge of the floating panel based + on the computed placement; the controller then writes inline + translate to slide it along that edge so it points at the + trigger's center. */ + :host([actual-placement^='bottom']) .tip { + inset-block-start: -7px; + } + + :host([actual-placement^='top']) .tip { + inset-block-end: -7px; + rotate: 225deg; + } + + :host([actual-placement^='right']), + :host([actual-placement^='end']) { + } + + :host([actual-placement^='right']) .tip, + :host([actual-placement^='end']) .tip { + inset-inline-start: -7px; + rotate: -45deg; + } + + :host([actual-placement^='left']) .tip, + :host([actual-placement^='start']) .tip { + inset-inline-end: -7px; + rotate: 135deg; + } + `, + ]; + + @property({ type: String, reflect: true }) + placement: Placement = 'bottom'; + + @property({ type: String, attribute: 'actual-placement', reflect: true }) + actualPlacement: Placement | null = null; + + @query('button.trigger') triggerEl!: HTMLButtonElement; + + @query('.floating') floatingEl!: HTMLDivElement; + + @query('.tip') tipEl!: HTMLDivElement; + + private controller = new PlacementController(this); + + protected override firstUpdated(): void { + this.controller.start(this.triggerEl, this.floatingEl, { + placement: this.placement, + offset: 10, + tipElement: this.tipEl, + tipPadding: 8, + onPlacementChange: (next) => { + this.actualPlacement = next; + }, + }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback?.(); + this.controller.stop(); + } + + protected override render(): TemplateResult { + return html` +
+

+ The triangular tip is positioned by Floating UI's + arrow + middleware so it always points at the trigger's center — even when the + panel is shifted to stay inside the viewport. +

+
+ +
+
+ + + placement: ${this.actualPlacement ?? this.placement} + +
+
+ `; + } +} diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts index 717b9a9f629..95ea085651c 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts @@ -160,6 +160,7 @@ export const Overview: Story = { * 2. **`shift`** — slides the floating element along the placement axis to keep it inside the boundary using `containerPadding` as inset. Always installed. * 3. **`flip`** (when `shouldFlip: true`) — reorients to the opposite side when there is not enough room, respecting `containerPadding`. * 4. **`size`** — always installed. Writes `max-width` on every compute and `max-height` when content overflows the available space. Read `isConstrained` to detect when clamping is active. + * 5. **`arrow`** (when a `tipElement` is provided) — positions a tip element so it stays pointing at the trigger's center as the floating panel shifts. * * ### What the caller owns * @@ -375,6 +376,34 @@ export const VirtualTrigger: Story = { parameters: { 'section-order': 7 }, }; +/** + * Pass a **`tipElement`** when the floating surface has a small visual "arrow" + * (a triangular tip, notch, or speech-bubble pointer) that should keep pointing + * at the trigger. The controller installs Floating UI's **`arrow`** middleware + * and writes an inline **`translate`** on the tip element after every compute, + * so the tip stays aligned with the trigger even when **`shift`** slides the + * floating panel sideways to avoid clipping. + * + * CSS positions the tip element against the relevant edge of the floating + * panel (typically with a negative offset for the half-size of the arrow); + * the controller only slides it along that edge. **`tipPadding`** keeps the + * tip from getting too close to the floating element's corners. + * + * ```typescript + * this.placement.start(this.trigger, this.panel, { + * tipElement: this.tip, + * tipPadding: 8, + * }); + * ``` + */ +export const Arrow: Story = { + tags: ['behaviors'], + render: () => html` + + `, + parameters: { 'section-order': 8 }, +}; + // ────────────────────────── // API STORY // ────────────────────────── @@ -404,6 +433,8 @@ export const VirtualTrigger: Story = { * | `crossOffset` | `number` | `0` | Slide along the trigger edge (px), perpendicular to the placement direction. | * | `containerPadding` | `number` | `8` | Minimum inset from the overflow boundary used for collision detection. | * | `shouldFlip` | `boolean` | `true` | Whether `flip` may reorient when the requested placement does not fit. | + * | `tipElement` | `HTMLElement` | — | Tip ("arrow") element to keep pointing at the trigger. When set, the controller installs `arrow` middleware and writes an inline `translate` on this element. | + * | `tipPadding` | `number` | `8` | Minimum inset of the tip element from the floating element's corners. Only meaningful when `tipElement` is set. | * | `onPlacementChange` | `(placement: Placement) => void` | — | Fires after every successful compute with the resolved hyphenated placement. | * * ### Types diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts index 6f3830b5a77..cd61dc133aa 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -487,3 +487,36 @@ export const OnPlacementChangeFiresWithComputedPlacement: Story = { ); }, }; + +/** + * When a `tipElement` is passed in options, the controller installs + * `arrow` middleware and writes inline `translate` on the tip after every + * compute. The test asserts the tip ends up with a numeric translate + * value (not empty, not `NaN`), which only happens when the middleware + * actually ran. + */ +export const ArrowMiddlewarePositionsTip: Story = { + render: () => html` + + `, + play: async ({ canvasElement, step }) => { + const host = await getComponent< + HTMLElement & { + tipEl: HTMLElement; + floatingEl: HTMLElement; + } + >(canvasElement, 'demo-placement-arrow'); + + await waitFor(() => expect(host.floatingEl.style.translate).not.toBe('')); + + await step('tip element receives a numeric translate', async () => { + // CSS `translate` may normalize `Xpx 0px` to just `Xpx` when read + // back, so accept either form: a single value or two values. + await waitFor(() => + expect(host.tipEl.style.translate).toMatch( + /^(-?\d*\.?\d+)px(\s+(-?\d*\.?\d+)px)?$/ + ) + ); + }); + }, +}; From ae6d86256a98f250d7694bacd24d03584894e1f0 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Fri, 29 May 2026 11:35:54 +0200 Subject: [PATCH 20/27] refactor(placement-controller): arrow demo uses bottom-end + text-sized trigger - Trigger button: drop the fixed 48x48 sizing and use min-block-size + inline padding so 'Trigger' fits naturally without overflowing. - Default placement: switch from 'bottom' to 'bottom-end' so the tip's computed offset from the floating panel's center is clearly visible (with 'bottom', the tip would sit at the center and there'd be nothing visually distinct from a CSS-centered tip). --- .../controllers/placement-controller/stories/demo-hosts.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts index 042cb393788..16c097b9dba 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts @@ -1252,8 +1252,8 @@ export class DemoPlacementArrow extends LitElement { } button.trigger { - inline-size: 48px; - block-size: 48px; + min-block-size: 32px; + padding-inline: 14px; } .floating { @@ -1306,7 +1306,7 @@ export class DemoPlacementArrow extends LitElement { ]; @property({ type: String, reflect: true }) - placement: Placement = 'bottom'; + placement: Placement = 'bottom-end'; @property({ type: String, attribute: 'actual-placement', reflect: true }) actualPlacement: Placement | null = null; From ae11079b23ed3844fd9d06da611b01af88f8c119 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Fri, 29 May 2026 11:52:51 +0200 Subject: [PATCH 21/27] docs(placement-controller): rename 1st-gen/2nd-gen to gen1/gen2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the references in code comments and story prose only — no behaviour change. Affects the middleware-stack JSDoc, the controller's inline comments about the order match and tip-positioning pattern, the SizeAlwaysClamps story's 'matches gen1 behaviour' note, and the appendix 'Relationship to gen1 PlacementController' section. --- .../placement-controller/src/placement-controller.ts | 4 ++-- .../stories/placement-controller.stories.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts index d4f970cd8c3..f8c0b07d8fd 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts @@ -325,7 +325,7 @@ export class PlacementController implements ReactiveController { fallbackStrategy: 'bestFit', })); - // Middleware order matches 1st-gen: offset → shift → flip → size → arrow. + // Middleware order matches gen1: offset → shift → flip → size → arrow. // `shift` runs before `flip` so the panel slides along the current // side before any decision to flip; `size` runs after the final // placement is known so it clamps max-height / max-width accurately; @@ -390,7 +390,7 @@ export class PlacementController implements ReactiveController { translate: `${roundByDPR(translateX)}px ${roundByDPR(translateY)}px`, }); - // Position the tip element (1st-gen pattern). Floating UI exposes + // Position the tip element (gen1 pattern). Floating UI exposes // either `arrow.x` (top/bottom placements — arrow on a horizontal // edge) or `arrow.y` (left/right placements — arrow on a vertical // edge). Reset whichever inline offset would conflict with the diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts index 95ea085651c..777f3fb3503 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts @@ -154,7 +154,7 @@ export const Overview: Story = { * * ### Middleware stack * - * Same order as 1st-gen: `offset → shift → flip → size`. + * Same order as gen1: `offset → shift → flip → size`. * * 1. **`offset`** — trigger-relative gap along the placement direction (`offset` option) and along the trigger edge (`crossOffset` option). * 2. **`shift`** — slides the floating element along the placement axis to keep it inside the boundary using `containerPadding` as inset. Always installed. @@ -314,7 +314,7 @@ export const ShouldFlip: Story = { * readonly **`isConstrained`** property is `true` while `max-height` is being applied, * so consumers can react to "content is currently clamped" — e.g. show a scrollbar hint. * - * No option to opt out. Matches 1st-gen behaviour. + * No option to opt out. Matches gen1 behaviour. */ export const SizeAlwaysClamps: Story = { tags: ['behaviors'], @@ -498,12 +498,12 @@ export const Accessibility: Story = { // ────────────────────────── /** - * ### Relationship to 1st-gen `PlacementController` + * ### Relationship to gen1 `PlacementController` * - * The 2nd-gen controller is a **focused subset** of the 1st-gen + * The gen2 controller is a **focused subset** of the gen1 * `PlacementController`: single `autoUpdate` channel, hyphenated placements, * and callback-based placement surfacing. The middleware stack and the - * always-on `size` clamp behaviour match 1st-gen. It owns geometry only — + * always-on `size` clamp behaviour match gen1. It owns geometry only — * open/close lifecycle, ARIA, focus, and dismissal remain the caller's * responsibility. * From fc04c9da44377c1fcac3e5df972b4041feeadbbc Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Fri, 29 May 2026 13:10:34 +0200 Subject: [PATCH 22/27] test(placement-controller): isolate tests from playground demo and tighten assertions Move the behavioral tests off the interactive demo-placement-playground host onto the lean, property-driven demo-placement-test-fixture so they no longer depend on the demo's controls, placement picker, or layout. Tighten loose assertions to the actual expected geometry: start/end alignment gap, below-trigger position, offset and cross-offset deltas, container-padding inward shift, and the flip-reorients placement value. --- .../test/placement-controller.test.ts | 91 +++++++++++-------- 1 file changed, 52 insertions(+), 39 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts index cd61dc133aa..1ae13a6431d 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -18,12 +18,10 @@ import '../stories/demo-hosts.js'; import { getComponent } from '../../../../swc/utils/test-utils.js'; import { fromFloatingPlacement, toFloatingPlacement } from '../index.js'; import type { - DemoPlacementPlayground, DemoPlacementTestFixture, DemoPlacementVirtualTrigger, } from '../stories/demo-hosts.js'; import meta, { - Playground, VirtualTrigger, } from '../stories/placement-controller.stories.js'; @@ -49,12 +47,13 @@ export default { } as Meta; export const AlignsStartAndEnd: Story = { - ...Playground, + ...testFixtureStory, play: async ({ canvasElement, step }) => { - const host = await getComponent( + const host = await getComponent( canvasElement, - 'demo-placement-playground' + 'demo-placement-test-fixture' ); + host.triggerPosition = 'top-center'; await waitFor(() => expect(host.actualPlacement).toBe('bottom')); await step( @@ -68,25 +67,35 @@ export const AlignsStartAndEnd: Story = { await waitFor(() => expect(host.actualPlacement).toBe('bottom-end')); const [endX] = readTranslate(host.floatingEl); - expect(startX).not.toBe(endX); + // `bottom-start` pins the floating start edge to the trigger start + // edge; `bottom-end` pins the end edges. The gap between the two + // positions is exactly the width difference between the two boxes. + const triggerWidth = host.triggerEl.getBoundingClientRect().width; + const floatingWidth = host.floatingEl.getBoundingClientRect().width; + const expectedGap = Math.abs(floatingWidth - triggerWidth); + expect(Math.abs(startX - endX)).toBeGreaterThanOrEqual(expectedGap - 2); + expect(Math.abs(startX - endX)).toBeLessThanOrEqual(expectedGap + 2); } ); }, }; export const PositionsBelowTrigger: Story = { - ...Playground, + ...testFixtureStory, play: async ({ canvasElement, step }) => { - const host = await getComponent( + const host = await getComponent( canvasElement, - 'demo-placement-playground' + 'demo-placement-test-fixture' ); + host.triggerPosition = 'top-center'; await waitFor(() => expect(host.actualPlacement).toBe('bottom')); - await step('floating element sits below trigger', () => { + await step('floating element sits directly below the trigger', () => { const triggerRect = host.triggerEl.getBoundingClientRect(); const [, y] = readTranslate(host.floatingEl); - expect(y).toBeGreaterThanOrEqual(Math.floor(triggerRect.bottom)); + // With placement `bottom` and `offset: 0`, the floating top edge sits + // flush against the trigger bottom edge. + expect(Math.abs(y - triggerRect.bottom)).toBeLessThanOrEqual(2); }); }, }; @@ -120,11 +129,11 @@ export const VirtualTriggerMoves: Story = { }; export const StopOnDisconnect: Story = { - ...Playground, + ...testFixtureStory, play: async ({ canvasElement, step }) => { - const host = await getComponent( + const host = await getComponent( canvasElement, - 'demo-placement-playground' + 'demo-placement-test-fixture' ); await waitFor(() => expect(host.floatingEl.style.translate).not.toBe('')); const before = host.floatingEl.style.translate; @@ -151,7 +160,7 @@ export const StopOnDisconnect: Story = { * `left-start`/`left-end`/`right-start`/`right-end` back into the SWC union. */ export const ConversionFunctionsRoundTrip: Story = { - ...Playground, + ...testFixtureStory, play: async ({ step }) => { await step( 'logical-side placements produce valid Floating UI placements', @@ -203,11 +212,11 @@ export const ConversionFunctionsRoundTrip: Story = { * `'left-top'` and `computePosition` produced nonsensical coordinates. */ export const LogicalSidePlacementsCompute: Story = { - ...Playground, + ...testFixtureStory, play: async ({ canvasElement, step }) => { - const host = await getComponent( + const host = await getComponent( canvasElement, - 'demo-placement-playground' + 'demo-placement-test-fixture' ); host.shouldFlip = false; await waitFor(() => expect(host.actualPlacement).toBe('bottom')); @@ -279,11 +288,11 @@ export const SizeMiddlewareWritesMaxDimensions: Story = { * the latest `start()` call. */ export const RapidStartReplacesPriorSession: Story = { - ...Playground, + ...testFixtureStory, play: async ({ canvasElement, step }) => { - const host = await getComponent( + const host = await getComponent( canvasElement, - 'demo-placement-playground' + 'demo-placement-test-fixture' ); host.shouldFlip = false; await waitFor(() => expect(host.actualPlacement).toBe('bottom')); @@ -315,12 +324,14 @@ export const FlipReorients: Story = { host.tallFloating = true; host.shouldFlip = true; - await step('actualPlacement reorients away from bottom', async () => { - await waitFor(() => { - expect(host.actualPlacement).toBeTruthy(); - expect(host.actualPlacement).not.toBe('bottom'); - }); - }); + await step( + 'actualPlacement reorients away from bottom (flips to top)', + async () => { + await waitFor(() => { + expect(host.actualPlacement).toBe('top'); + }); + } + ); }, }; @@ -364,12 +375,12 @@ export const OffsetMovesAlongPlacementAxis: Story = { await waitFor(() => expect(host.floatingEl.style.translate).not.toBe('')); const [, y0] = readTranslate(host.floatingEl); - await step('offset: 40 shifts translateY by ~40px', async () => { + await step('offset: 40 shifts translateY down by 40px', async () => { host.offset = 40; await waitFor(() => { const [, y40] = readTranslate(host.floatingEl); - expect(y40 - y0).toBeGreaterThanOrEqual(30); - expect(y40 - y0).toBeLessThanOrEqual(50); + expect(y40 - y0).toBeGreaterThanOrEqual(38); + expect(y40 - y0).toBeLessThanOrEqual(42); }); }); }, @@ -399,8 +410,11 @@ export const CrossOffsetMovesAlongTriggerEdge: Story = { host.crossOffset = 40; await waitFor(() => { const [x40, y40] = readTranslate(host.floatingEl); - expect(Math.abs(x40 - x0)).toBeGreaterThan(20); - expect(Math.abs(y40 - y0)).toBeLessThan(5); + // crossOffset: 40 slides the panel 40px along the trigger edge… + expect(Math.abs(x40 - x0)).toBeGreaterThanOrEqual(38); + expect(Math.abs(x40 - x0)).toBeLessThanOrEqual(42); + // …without moving it along the placement axis. + expect(Math.abs(y40 - y0)).toBeLessThanOrEqual(2); }); } ); @@ -432,10 +446,11 @@ export const ContainerPaddingMovesPanelInward: Story = { host.containerPadding = 64; await waitFor(() => { const [xLarge] = readTranslate(host.floatingEl); - // With trigger near the right edge, shift keeps the panel inside - // the viewport. A larger padding means the panel sits further - // from the right edge — i.e. a smaller translateX. - expect(xLarge).toBeLessThan(xSmall); + // With the trigger near the right edge, shift clamps the panel a + // fixed inset from the boundary in both cases, so raising padding + // from 8 to 64 moves it inward by exactly the 56px delta. + expect(xSmall - xLarge).toBeGreaterThanOrEqual(54); + expect(xSmall - xLarge).toBeLessThanOrEqual(58); }); } ); @@ -479,9 +494,7 @@ export const OnPlacementChangeFiresWithComputedPlacement: Story = { host.triggerPosition = 'bottom-center'; host.tallFloating = true; await waitFor(() => { - expect( - host.placementChanges.some((placement) => placement !== 'bottom') - ).toBe(true); + expect(host.placementChanges.at(-1)).toBe('top'); }); } ); From 6fa115958c61d7cff5d9da9cfa2f13050bd90354 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Fri, 29 May 2026 13:14:46 +0200 Subject: [PATCH 23/27] test(placement-controller): assert arrow tip points at trigger center Replace the loose regex check on the tip's serialized translate with direct style and geometry assertions: the controller pins the tip to the floating edge (left: 0) and the arrow middleware centers it on the trigger. Read the tip and trigger rects directly and assert their centers align. --- .../test/placement-controller.test.ts | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts index 1ae13a6431d..e330b23a674 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -503,10 +503,10 @@ export const OnPlacementChangeFiresWithComputedPlacement: Story = { /** * When a `tipElement` is passed in options, the controller installs - * `arrow` middleware and writes inline `translate` on the tip after every - * compute. The test asserts the tip ends up with a numeric translate - * value (not empty, not `NaN`), which only happens when the middleware - * actually ran. + * `arrow` middleware. For a bottom/top placement it pins the tip to the + * floating's horizontal edge (`left: 0`) and slides it along that edge with + * inline `translate` so the tip points at the trigger's center. The test + * reads the tip's style and geometry directly to assert that contract. */ export const ArrowMiddlewarePositionsTip: Story = { render: () => html` @@ -516,20 +516,29 @@ export const ArrowMiddlewarePositionsTip: Story = { const host = await getComponent< HTMLElement & { tipEl: HTMLElement; + triggerEl: HTMLElement; floatingEl: HTMLElement; } >(canvasElement, 'demo-placement-arrow'); await waitFor(() => expect(host.floatingEl.style.translate).not.toBe('')); - await step('tip element receives a numeric translate', async () => { - // CSS `translate` may normalize `Xpx 0px` to just `Xpx` when read - // back, so accept either form: a single value or two values. - await waitFor(() => - expect(host.tipEl.style.translate).toMatch( - /^(-?\d*\.?\d+)px(\s+(-?\d*\.?\d+)px)?$/ - ) - ); + await step('controller pins the tip to the floating edge', async () => { + // Default demo placement is `bottom-end`, so the tip rides the top + // edge of the floating panel: the controller writes `left: 0` and + // leaves `top` cleared. + await waitFor(() => { + expect(host.tipEl.style.left).toBe('0px'); + expect(host.tipEl.style.translate).not.toBe(''); + }); + }); + + await step('tip points at the trigger center', () => { + const triggerRect = host.triggerEl.getBoundingClientRect(); + const tipRect = host.tipEl.getBoundingClientRect(); + const triggerCenterX = triggerRect.left + triggerRect.width / 2; + const tipCenterX = tipRect.left + tipRect.width / 2; + expect(Math.abs(tipCenterX - triggerCenterX)).toBeLessThanOrEqual(2); }); }, }; From 9a3cca8b1e2d169b2d745fb57452c2971b33f239 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Sat, 30 May 2026 21:26:08 +0200 Subject: [PATCH 24/27] fix(placement-controller): ignore superseded compute in size apply The size middleware's apply callback writes baseline state (isConstrained, initialHeight) during computePosition. A rapid start()/stop() replacement could let a stale in-flight compute bleed that state into the new session, so bail when the session has been replaced. --- .../placement-controller/src/placement-controller.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts index f8c0b07d8fd..e47c7e360c3 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts @@ -341,6 +341,12 @@ export class PlacementController implements ReactiveController { size({ padding: containerPadding, apply: ({ availableHeight, availableWidth, rects }) => { + // Ignore a superseded compute: a new `start()` (or `stop()`) may + // have replaced the session while this `computePosition` was in + // flight, and writing baseline state here would bleed into it. + if (this.session !== session) { + return; + } const maxHeight = Math.max( MIN_FLOATING_HEIGHT, Math.floor(availableHeight) From aa164da156d689737209884330ee8d7a35252800 Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Tue, 2 Jun 2026 12:48:46 +0200 Subject: [PATCH 25/27] fix(placement-controller): expose available space as custom properties Addresses PR review: the size middleware no longer writes max-width / max-height inline (which overrode a component's intended CSS max-size, e.g. Tooltip wouldn't wrap until viewport width). Instead it exposes --swc-placement-available-width / --swc-placement-available-height on the floating element; components opt in via min() so their intended size wins and overflow stays their concern (resolving the box-sizing overflow too). - stop() removes the custom properties; isConstrained kept as an informational flag (no longer drives an inline max-height) - document the contract + consumer pattern on the controller and in stories - demo-placement-constrain-size consumes the props so it clamps and scrolls - tighten the size test to assert real bounded values; no inline max-* written --- .../src/placement-controller.ts | 64 +++++++++++++++---- .../stories/demo-hosts.ts | 4 ++ .../stories/placement-controller.stories.ts | 26 +++++--- .../test/placement-controller.test.ts | 46 +++++++++---- 4 files changed, 104 insertions(+), 36 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts index e47c7e360c3..d8d73844483 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts @@ -87,6 +87,34 @@ type ActiveSession = { * (`start`, `end`) normalize to physical sides for positioning math; RTL is * handled in CSS at the consumer layer. * + * ### Available-space custom properties + * + * The `size` middleware does **not** write `max-width` / `max-height` on the + * floating element — an inline max-size would override the consuming + * component's intended CSS max-size. Instead it exposes the space available to + * the trigger as two custom properties on the floating element, refreshed on + * every compute and removed on `stop`: + * + * - `--swc-placement-available-width` — usable inline space, in px. + * - `--swc-placement-available-height` — usable block space, in px (floored to + * a minimum so a cramped trigger still yields a usable panel). + * + * Only components positioned by this controller receive these properties. + * Such a component opts into viewport-bounded sizing by combining them with + * its own intended size via `min()`, with a fallback for when the controller + * is not active: + * + * ```css + * .floating { + * max-inline-size: min(var(--intended-width), var(--swc-placement-available-width, 100vw)); + * max-block-size: min(var(--intended-height), var(--swc-placement-available-height, 100vh)); + * overflow: auto; + * } + * ``` + * + * Pair `max-block-size` with `overflow: auto` so the panel scrolls when the + * block space is constrained (read `isConstrained` to detect that state). + * * @example * ```typescript * this.placement.start(this.trigger, this.dialog, { @@ -115,10 +143,12 @@ export class PlacementController implements ReactiveController { public actualPlacement: Placement | null = null; /** - * Whether the `size` middleware clamped the floating element's height - * on the last compute. `true` when the content would otherwise overflow - * the available space below/above the trigger, in which case the - * controller writes a `max-height` style on the floating element. + * Whether the floating content would overflow the available space + * below/above the trigger on the last compute. `true` when the content is + * taller than the available height, so a consumer applying + * `max-block-size: min(…, var(--swc-placement-available-height))` will be + * clamping (and should enable scrolling). Informational only — the + * controller no longer writes `max-height` itself. */ public isConstrained = false; @@ -223,15 +253,16 @@ export class PlacementController implements ReactiveController { /** * Stop positioning: tear down `autoUpdate`, clear `actualPlacement`, * reset `isConstrained`, and remove inline styles the controller wrote - * (the `size` middleware's `max-height` / `max-width` on the floating + * (the `size` middleware's `--swc-placement-available-width` / + * `--swc-placement-available-height` custom properties on the floating * element, plus the `arrow` middleware's `translate` / `top` / `left` * on the tip element). Safe to call multiple times. */ public stop(): void { const floating = this.session?.floating; if (floating) { - floating.style.removeProperty('max-height'); - floating.style.removeProperty('max-width'); + floating.style.removeProperty('--swc-placement-available-width'); + floating.style.removeProperty('--swc-placement-available-height'); } const tipElement = this.session?.options.tipElement; if (tipElement) { @@ -328,7 +359,7 @@ export class PlacementController implements ReactiveController { // Middleware order matches gen1: offset → shift → flip → size → arrow. // `shift` runs before `flip` so the panel slides along the current // side before any decision to flip; `size` runs after the final - // placement is known so it clamps max-height / max-width accurately; + // placement is known so it measures the available space accurately; // `arrow` (when a tip element is provided) runs last so it positions // the tip relative to the trigger after every other middleware has // settled. @@ -360,10 +391,19 @@ export class PlacementController implements ReactiveController { : (this.initialHeight ?? actualHeight); this.isConstrained = actualHeight < this.initialHeight || maxHeight <= actualHeight; - Object.assign(floating.style, { - maxWidth: `${Math.floor(availableWidth)}px`, - maxHeight: this.isConstrained ? `${maxHeight}px` : '', - }); + // Expose the available space as custom properties rather than writing + // `max-width` / `max-height` directly: an inline max-size would + // override a component's intended CSS max-size, so instead each + // component opts in by combining these with its own value, e.g. + // `max-inline-size: min(, var(--swc-placement-available-width))`. + floating.style.setProperty( + '--swc-placement-available-width', + `${Math.floor(availableWidth)}px` + ); + floating.style.setProperty( + '--swc-placement-available-height', + `${maxHeight}px` + ); }, }), tipElement ? arrow({ element: tipElement, padding: tipPadding }) : null, diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts index 16c097b9dba..69f44e1d8a8 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/demo-hosts.ts @@ -755,6 +755,10 @@ export class DemoPlacementConstrainSize extends LitElement { } .floating { + /* Consume the controller's available-space props so the list clamps + * and scrolls instead of overflowing. */ + max-inline-size: var(--swc-placement-available-width); + max-block-size: var(--swc-placement-available-height); overflow: auto; pointer-events: auto; } diff --git a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts index 777f3fb3503..c6498baddc3 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/stories/placement-controller.stories.ts @@ -159,7 +159,7 @@ export const Overview: Story = { * 1. **`offset`** — trigger-relative gap along the placement direction (`offset` option) and along the trigger edge (`crossOffset` option). * 2. **`shift`** — slides the floating element along the placement axis to keep it inside the boundary using `containerPadding` as inset. Always installed. * 3. **`flip`** (when `shouldFlip: true`) — reorients to the opposite side when there is not enough room, respecting `containerPadding`. - * 4. **`size`** — always installed. Writes `max-width` on every compute and `max-height` when content overflows the available space. Read `isConstrained` to detect when clamping is active. + * 4. **`size`** — always installed. Exposes the available space as the `--swc-placement-available-width` / `--swc-placement-available-height` custom properties (components opt in via `min()`). Read `isConstrained` to detect when content overflows the available space. * 5. **`arrow`** (when a `tipElement` is provided) — positions a tip element so it stays pointing at the trigger's center as the floating panel shifts. * * ### What the caller owns @@ -308,15 +308,21 @@ export const ShouldFlip: Story = { }; /** - * Floating UI's **`size`** middleware is always installed. On every compute it writes - * **`max-width`** on the floating element and writes **`max-height`** when the natural - * content would otherwise overflow the available space below or above the trigger. The - * readonly **`isConstrained`** property is `true` while `max-height` is being applied, - * so consumers can react to "content is currently clamped" — e.g. show a scrollbar hint. - * - * No option to opt out. Matches gen1 behaviour. + * Floating UI's **`size`** middleware is always installed. On every compute it + * exposes the available space as the custom properties + * **`--swc-placement-available-width`** and **`--swc-placement-available-height`** + * on the floating element, rather than writing `max-width` / `max-height` + * directly — an inline max-size would override the consuming component's + * intended CSS max-size. Components opt in with `min()`, e.g. + * `max-inline-size: min(, var(--swc-placement-available-width))`. The + * readonly **`isConstrained`** property is `true` when the content would + * overflow the available block space, so consumers can react to "content is + * currently clamped" — e.g. enable scrolling or show a scrollbar hint. + * + * No option to opt out of the measurement. Only components positioned by the + * controller receive these properties. */ -export const SizeAlwaysClamps: Story = { +export const SizeExposesAvailableSpace: Story = { tags: ['behaviors'], render: () => html` @@ -422,7 +428,7 @@ export const Arrow: Story = { * | Property | Type | Description | * |---|---|---| * | `actualPlacement` | `Placement \| null` | Computed placement after `flip` (hyphenated). `null` when stopped. | - * | `isConstrained` | `boolean` | `true` while `size` middleware is applying `max-height` because content would otherwise overflow. | + * | `isConstrained` | `boolean` | `true` when content would overflow the available block space — i.e. a component clamping with `var(--swc-placement-available-height)` is scrolling. | * * ### Options * diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts index e330b23a674..cd6dee795b2 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -246,16 +246,17 @@ export const LogicalSidePlacementsCompute: Story = { }; /** - * `size` middleware is always installed. It writes `max-width` on every - * compute, and `max-height` only when the floating content would otherwise - * overflow the available space. + * `size` middleware is always installed. Rather than writing `max-width` / + * `max-height` directly (which would override a component's intended CSS + * max-size), it exposes the available space as the custom properties + * `--swc-placement-available-width` and `--swc-placement-available-height` + * on the floating element; components opt in via `min()`. * - * To assert max-height deterministically we set up the test fixture with a - * 600 px tall floating element and pin the trigger to the bottom of the - * viewport with `shouldFlip: false` — the panel can't fit below, so size - * clamps `max-height` and `isConstrained` flips on. + * We pin the trigger to the bottom of the viewport with a 600 px tall floating + * element and `shouldFlip: false` so the available height is meaningfully + * constrained. */ -export const SizeMiddlewareWritesMaxDimensions: Story = { +export const SizeMiddlewareExposesAvailableSpace: Story = { ...testFixtureStory, play: async ({ canvasElement, step }) => { const host = await getComponent( @@ -266,17 +267,34 @@ export const SizeMiddlewareWritesMaxDimensions: Story = { host.tallFloating = true; host.shouldFlip = false; + const availableWidth = (): string => + host.floatingEl.style.getPropertyValue('--swc-placement-available-width'); + const availableHeight = (): string => + host.floatingEl.style.getPropertyValue( + '--swc-placement-available-height' + ); + await waitFor(() => { - expect(host.floatingEl.style.maxWidth).toMatch(/^\d+px$/); - expect(host.floatingEl.style.maxHeight).toMatch(/^\d+px$/); + expect(availableWidth()).toMatch(/^\d+px$/); + expect(availableHeight()).toMatch(/^\d+px$/); }); - await step('max-width is set on every compute', () => { - expect(host.floatingEl.style.maxWidth).toMatch(/^\d+px$/); + await step('available space is exposed as concrete px values', () => { + const width = parseFloat(availableWidth()); + const height = parseFloat(availableHeight()); + // A real, positive width bounded by the viewport. + expect(width).toBeGreaterThan(0); + expect(width).toBeLessThanOrEqual(window.innerWidth); + // Trigger pinned to the viewport bottom with `shouldFlip: false` leaves + // little room below, so the height is floored to MIN_FLOATING_HEIGHT + // (100) and stays well under the 600px natural content height. + expect(height).toBeGreaterThanOrEqual(100); + expect(height).toBeLessThan(600); }); - await step('max-height is set when content overflows', () => { - expect(host.floatingEl.style.maxHeight).toMatch(/^\d+px$/); + await step('no inline max-width / max-height is written', () => { + expect(host.floatingEl.style.maxWidth).toBe(''); + expect(host.floatingEl.style.maxHeight).toBe(''); }); }, }; From 5c76a44f3a180ee01575dbac820f5bf8384375bb Mon Sep 17 00:00:00 2001 From: Ruben Carvalho Date: Tue, 2 Jun 2026 13:49:44 +0200 Subject: [PATCH 26/27] fix(placement-controller): resolve logical start/end sides for RTL Addresses PR review: logical primary sides must resolve to the correct physical side here, not in CSS, or the panel lands on the wrong side in RTL. - toFloatingPlacement takes a direction arg; start->right / end->left in RTL (and start-top -> right-start, etc.) - the controller reads getComputedStyle(trigger).direction (falling back to the floating element for a VirtualTrigger), honoring a scoped dir, not document.dir - logical alignment suffixes (bottom-start, top-end) pass through unchanged so Floating UI's own RTL handling flips them without double-flipping - actualPlacement stays physical; add RTL conversion tests --- .../src/placement-controller.ts | 19 +++++++-- .../src/placement-conversion.ts | 40 ++++++++++++++++--- .../test/placement-controller.test.ts | 26 ++++++++++++ 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts index d8d73844483..3c92f673490 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts @@ -84,8 +84,11 @@ type ActiveSession = { * (`computePosition` + `autoUpdate`). * * Hyphenated placements are accepted on the public API. Logical sides - * (`start`, `end`) normalize to physical sides for positioning math; RTL is - * handled in CSS at the consumer layer. + * (`start`, `end`) resolve to physical sides against the trigger's computed + * writing direction (`start` is the left in LTR, the right in RTL), so the + * panel lands on the correct side. Logical alignment suffixes (`bottom-start`) + * are left for Floating UI's own RTL handling; the consumer's CSS still owns + * direction-aware styling such as tip orientation. * * ### Available-space custom properties * @@ -336,7 +339,17 @@ export class PlacementController implements ReactiveController { } const requestedPlacement = options.placement ?? DEFAULT_PLACEMENT; - const floatingPlacement = toFloatingPlacement(requestedPlacement); + // Resolve logical sides (`start` / `end`) against the trigger's writing + // direction so RTL positions the panel on the correct physical side. Read + // from the trigger (it lives in the consumer's possibly scoped-RTL subtree); + // fall back to the floating element for a `VirtualTrigger`. + const directionSource = trigger instanceof HTMLElement ? trigger : floating; + const direction = + getComputedStyle(directionSource).direction === 'rtl' ? 'rtl' : 'ltr'; + const floatingPlacement = toFloatingPlacement( + requestedPlacement, + direction + ); const containerPadding = options.containerPadding ?? DEFAULT_CONTAINER_PADDING; const shouldFlip = options.shouldFlip ?? DEFAULT_SHOULD_FLIP; diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts index c0c322eec0e..0a8ce614e91 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-conversion.ts @@ -14,8 +14,28 @@ import type { Placement as FloatingPlacement } from '@floating-ui/dom'; import type { Placement } from './types.js'; +/** Writing direction used to resolve logical sides to physical sides. */ +export type Direction = 'ltr' | 'rtl'; + const LOGICAL_SIDES = new Set(['start', 'end']); +/** + * Logical inline **sides** resolve to physical sides per the writing direction: + * `start` is the left in LTR and the right in RTL (and vice versa for `end`). + * Floating UI has no logical primary side, so this resolution must happen here + * — the physical side it receives is what determines which side it positions on. + */ +const LOGICAL_SIDE_TO_PHYSICAL: Record> = { + ltr: { start: 'left', end: 'right' }, + rtl: { start: 'right', end: 'left' }, +}; + +/** + * Logical **alignment** fallback for a logical side paired with a logical + * alignment. Cross-axis alignment for left/right sides is vertical, so it is + * direction-independent; this is only a guard for inputs outside the standard + * placement set. + */ const LOGICAL_TO_PHYSICAL: Record = { start: 'left', end: 'right', @@ -55,20 +75,28 @@ const FLOATING_TO_SWC_PLACEMENT: Partial> = /** * Normalize a hyphenated `Placement` for Floating UI's `computePosition`. * - * - Logical **sides** (`start`, `end`) map to `left` / `right`. - * - Logical **alignments** (`bottom-start`, `top-end`) pass through unchanged. + * - Logical **sides** (`start`, `end`) resolve to `left` / `right` per the + * `direction` argument (`start` is left in LTR, right in RTL). Floating UI + * has no logical primary side, so this must be resolved here for the panel + * to land on the correct side. + * - Logical **alignments** (`bottom-start`, `top-end`) pass through unchanged — + * Floating UI's own RTL handling flips those, so they must not be flipped here. * - Physical alignments (`bottom-left`, `left-top`, `start-top`, etc.) map to - * the nearest Floating UI placement (LTR positioning math; RTL styling - * stays in CSS). + * the nearest Floating UI placement. * * @param placement - Customer-facing hyphenated placement. + * @param direction - Writing direction of the trigger; defaults to `'ltr'`. * @returns Placement value understood by Floating UI. */ -export function toFloatingPlacement(placement: Placement): FloatingPlacement { +export function toFloatingPlacement( + placement: Placement, + direction: Direction = 'ltr' +): FloatingPlacement { const [primary, alignment] = placement.split('-') as [string, string?]; if (LOGICAL_SIDES.has(primary)) { - const physicalPrimary = LOGICAL_TO_PHYSICAL[primary] ?? primary; + const physicalPrimary = + LOGICAL_SIDE_TO_PHYSICAL[direction][primary] ?? primary; if (!alignment) { return physicalPrimary as FloatingPlacement; } diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts index cd6dee795b2..d295da93baf 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -177,6 +177,32 @@ export const ConversionFunctionsRoundTrip: Story = { expect(toFloatingPlacement('end')).toBe('right'); }); + await step( + 'logical sides flip to the opposite physical side in RTL', + () => { + // Default and explicit LTR are equivalent. + expect(toFloatingPlacement('start', 'ltr')).toBe('left'); + expect(toFloatingPlacement('end', 'ltr')).toBe('right'); + // RTL: inline-start is the right, inline-end is the left. + expect(toFloatingPlacement('start', 'rtl')).toBe('right'); + expect(toFloatingPlacement('end', 'rtl')).toBe('left'); + // The vertical sub-alignment is preserved across the flip. + expect(toFloatingPlacement('start-top', 'rtl')).toBe('right-start'); + expect(toFloatingPlacement('start-bottom', 'rtl')).toBe('right-end'); + expect(toFloatingPlacement('end-top', 'rtl')).toBe('left-start'); + expect(toFloatingPlacement('end-bottom', 'rtl')).toBe('left-end'); + } + ); + + await step('physical sides and logical alignments are not flipped', () => { + // Physical sides ignore direction. + expect(toFloatingPlacement('left', 'rtl')).toBe('left'); + expect(toFloatingPlacement('right', 'rtl')).toBe('right'); + // Logical alignment suffixes pass through for Floating UI to RTL-flip. + expect(toFloatingPlacement('bottom-start', 'rtl')).toBe('bottom-start'); + expect(toFloatingPlacement('top-end', 'rtl')).toBe('top-end'); + }); + await step('physical alignments map to Floating UI start/end', () => { expect(toFloatingPlacement('bottom-left')).toBe('bottom-start'); expect(toFloatingPlacement('bottom-right')).toBe('bottom-end'); From bcf04d8deb03fa86d749a643835297f32cd91701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=BAben=20Carvalho?= Date: Wed, 17 Jun 2026 10:09:03 +0100 Subject: [PATCH 27/27] feat(popover): scaffold + API contract (phases 2+3) (#6354) --- .../core/components/popover/Popover.base.ts | 194 +++++++++++ .../core/components/popover/Popover.types.ts | 70 ++++ .../packages/core/components/popover/index.ts | 13 + .../src/placement-controller.ts | 28 +- .../test/placement-controller.test.ts | 3 +- 2nd-gen/packages/core/package.json | 16 + .../packages/core/utils/dismissible-stack.ts | 58 ++++ 2nd-gen/packages/core/utils/index.ts | 10 + .../packages/core/utils/resolve-trigger.ts | 48 +++ .../swc/components/popover/Popover.ts | 73 +++++ .../packages/swc/components/popover/index.ts | 12 + .../swc/components/popover/popover.css | 27 ++ .../popover/stories/popover.stories.ts | 73 +++++ .../swc/components/popover/swc-popover.ts | 22 ++ .../popover/test/popover.a11y.spec.ts | 40 +++ .../components/popover/test/popover.test.ts | 301 ++++++++++++++++++ .../01_status.md | 2 +- .../03_components/popover/migration-plan.md | 132 ++++---- eslint.config.js | 11 + 19 files changed, 1052 insertions(+), 81 deletions(-) create mode 100644 2nd-gen/packages/core/components/popover/Popover.base.ts create mode 100644 2nd-gen/packages/core/components/popover/Popover.types.ts create mode 100644 2nd-gen/packages/core/components/popover/index.ts create mode 100644 2nd-gen/packages/core/utils/dismissible-stack.ts create mode 100644 2nd-gen/packages/core/utils/resolve-trigger.ts create mode 100644 2nd-gen/packages/swc/components/popover/Popover.ts create mode 100644 2nd-gen/packages/swc/components/popover/index.ts create mode 100644 2nd-gen/packages/swc/components/popover/popover.css create mode 100644 2nd-gen/packages/swc/components/popover/stories/popover.stories.ts create mode 100644 2nd-gen/packages/swc/components/popover/swc-popover.ts create mode 100644 2nd-gen/packages/swc/components/popover/test/popover.a11y.spec.ts create mode 100644 2nd-gen/packages/swc/components/popover/test/popover.test.ts diff --git a/2nd-gen/packages/core/components/popover/Popover.base.ts b/2nd-gen/packages/core/components/popover/Popover.base.ts new file mode 100644 index 00000000000..3497f7b181e --- /dev/null +++ b/2nd-gen/packages/core/components/popover/Popover.base.ts @@ -0,0 +1,194 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { VirtualTrigger } from '@spectrum-web-components/core/controllers/index.js'; +import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; + +import { + type Placement, + POPOVER_VALID_PLACEMENTS, + POPOVER_VALID_SIZES, + type PopoverSize, +} from './Popover.types.js'; + +/** + * Abstract base for the popover component. Declares the public property surface + * shared by all popover implementations; the concrete SWC subclass owns + * rendering, the dialog lifecycle, event dispatch, and ARIA wiring. + * + * @todo Phase 4/5: dialog lifecycle (`showPopover()` / `showModal()`), event + * dispatch, trigger and ARIA wiring, and `PlacementController` integration. + * + * @slot - Popover content. Free-form; consumers slot whatever pattern they build. + */ +export abstract class PopoverBase extends SpectrumElement { + /** + * @internal + * + * The valid placement values for the popover. Narrowed in downstream + * first-party subclasses per the proxy pattern. + */ + static readonly VALID_PLACEMENTS: readonly Placement[] = + POPOVER_VALID_PLACEMENTS; + + /** + * @internal + * + * The valid fixed sizes for the popover. + */ + static readonly VALID_SIZES: readonly PopoverSize[] = POPOVER_VALID_SIZES; + + /** + * Whether the popover is open. + * + * @default false + */ + @property({ type: Boolean, reflect: true }) + public open = false; + + /** + * Opt in to blocking modal behavior (`.showModal()`): focus trap, + * background inert, native `role="dialog"`. When unset, the popover uses + * `popover="auto"` light-dismiss behavior. + * + * @default false + */ + @property({ type: Boolean, reflect: true }) + public modal = false; + + /** + * The placement of the popover relative to its trigger. + * + * @default 'bottom' + */ + @property({ type: String, reflect: true }) + public placement: Placement = 'bottom'; + + /** + * Optional fixed size. When set, the popover uses a fixed inline size + * (`s` → 336px, `m` → 416px, `l` → 576px); when unset, it fits its contents. + */ + @property({ type: String, reflect: true }) + public size?: PopoverSize; + + // The computed placement after the `flip` middleware reorients the popover is + // intentionally kept off the public property surface: a readonly property would + // still be writable at runtime and could desync the component from the + // controller. The concrete component applies the computed placement only for + // styling, via the `.swc-Popover--` modifier classes on the internal + // surface element. (Tooltip reaches the same "no writable property" result a + // different way, by reflecting an `actual-placement` host attribute.) Consumers + // read the requested side via `placement`. + + /** + * Hide the popover's arrow (tip). The arrow is shown by default. + * + * @default false + */ + @property({ type: Boolean, reflect: true, attribute: 'hide-arrow' }) + public hideArrow = false; + + /** + * Main-axis offset in pixels from the trigger. + * + * @default 8 + */ + @property({ type: Number }) + public offset = 8; + + /** + * Cross-axis offset in pixels from the trigger. + * + * @default 0 + */ + @property({ type: Number, attribute: 'cross-offset' }) + public crossOffset = 0; + + /** + * Distance from the viewport edge for the `flip` and `shift` middleware. + * + * Positioning implementation detail. Set by first-party components; excluded + * from the public API. Users are not expected to set it. + * + * @internal + * @default 8 + */ + @property({ type: Number, attribute: 'container-padding' }) + public containerPadding = 8; + + /** + * Allow the popover to flip to the opposite side when constrained. When + * `false`, the popover stays in the requested placement. + * + * @default true + */ + @property({ type: Boolean, reflect: true, attribute: 'should-flip' }) + public shouldFlip = true; + + /** + * Minimum inset of the tip from the popover's corners, passed to the + * `PlacementController`'s `arrow` middleware as its `padding`. + * + * Positioning implementation detail. Set by first-party components; excluded + * from the public API. Users are not expected to set it. + * + * @internal + * @default 8 + */ + @property({ type: Number, attribute: 'tip-padding' }) + public tipPadding = 8; + + /** + * ID of the trigger element in the same document tree root. + */ + @property({ type: String }) + public for?: string; + + /** + * Direct trigger reference. Overrides `for` when both are set. Use for + * cross-shadow-root triggers or programmatic wiring. + */ + @property({ attribute: false }) + public triggerElement: HTMLElement | VirtualTrigger | null = null; + + /** + * Suppress the automatic click-to-toggle wiring on the resolved trigger. When + * set, control visibility through the `open` property instead. ARIA + * relationship wiring still applies. + * + * @default false + */ + @property({ type: Boolean, reflect: true }) + public manual = false; + + protected override update(changedProperties: PropertyValues): void { + if (window.__swc?.DEBUG) { + // Validate against the static so subclasses that narrow the placement set + // (the proxy pattern) get their own valid values checked at runtime. + const constructor = this.constructor as typeof PopoverBase; + if (!constructor.VALID_PLACEMENTS.includes(this.placement)) { + window.__swc.warn( + this, + `<${this.localName}> element expects the "placement" attribute to be one of the following:`, + 'https://spectrum-web-components.adobe.com/?path=/docs/components-popover--docs', + { + issues: [...constructor.VALID_PLACEMENTS], + } + ); + } + } + super.update(changedProperties); + } +} diff --git a/2nd-gen/packages/core/components/popover/Popover.types.ts b/2nd-gen/packages/core/components/popover/Popover.types.ts new file mode 100644 index 00000000000..b5177f98764 --- /dev/null +++ b/2nd-gen/packages/core/components/popover/Popover.types.ts @@ -0,0 +1,70 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + ALL_PLACEMENTS, + type Placement, +} from '@spectrum-web-components/core/controllers/index.js'; + +// ───────────────────────── +// PLACEMENT +// ───────────────────────── + +/** + * The placement of the popover relative to its trigger, re-exported from the + * `PlacementController` so the popover and the controller share a single source + * of truth for the 22 supported values. + */ +export type { Placement }; +export { ALL_PLACEMENTS }; + +/** + * The full set of placement values accepted by ``. Downstream + * first-party components narrow this set per the proxy pattern. + */ +export const POPOVER_VALID_PLACEMENTS = + ALL_PLACEMENTS satisfies readonly Placement[]; + +// ───────────────────────── +// SIZE +// ───────────────────────── + +/** + * Optional fixed sizes for the popover. When unset, the popover fits its + * contents. Each size pins a fixed inline size (`s` → 336px, `m` → 416px, + * `l` → 576px). + */ +export const POPOVER_VALID_SIZES = ['s', 'm', 'l'] as const; + +export type PopoverSize = (typeof POPOVER_VALID_SIZES)[number]; + +// ───────────────────────── +// EVENTS +// ───────────────────────── + +/** + * The cause of a popover close, carried on `swc-close.detail.source`. + * + * - `'escape'`: the Escape key dismissed the popover. + * - `'outside'`: a click outside the popover (light-dismiss in default mode, + * wired backdrop-click in modal mode) dismissed it. + * - `'programmatic'`: `open` was set to `false` in code. + */ +export type PopoverCloseSource = 'escape' | 'outside' | 'programmatic'; + +/** + * Detail payload carried on the `swc-close` event. + */ +export interface PopoverCloseEventDetail { + /** What triggered the close. */ + source: PopoverCloseSource; +} diff --git a/2nd-gen/packages/core/components/popover/index.ts b/2nd-gen/packages/core/components/popover/index.ts new file mode 100644 index 00000000000..5c48da3deaf --- /dev/null +++ b/2nd-gen/packages/core/components/popover/index.ts @@ -0,0 +1,13 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +export * from './Popover.base.js'; +export * from './Popover.types.js'; diff --git a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts index 1bbde6baba2..68ff256b270 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/src/placement-controller.ts @@ -98,7 +98,9 @@ type ActiveSession = { * floating element — an inline max-size would override the consuming * component's intended CSS max-size. Instead it exposes the space available to * the trigger as two custom properties on the floating element, refreshed on - * every compute and removed on `stop`: + * every compute. The caller removes them after any exit transition completes + * (clearing them in `stop()` would snap the floating element's size + * mid-animation): * * - `--swc-placement-available-width` — usable inline space, in px. * - `--swc-placement-available-height` — usable block space, in px (floored to @@ -292,25 +294,13 @@ export class PlacementController implements ReactiveController { } /** - * Stop positioning: tear down `autoUpdate`, clear `actualPlacement`, - * reset `isConstrained`, and remove inline styles the controller wrote - * (the `size` middleware's `--swc-placement-available-width` / - * `--swc-placement-available-height` custom properties on the floating - * element, plus the `arrow` middleware's `translate` / `top` / `left` - * on the tip element). Safe to call multiple times. + * Stop positioning: tear down `autoUpdate`, clear `actualPlacement` and + * `isConstrained`. Inline style cleanup — including `translate`, `top`, + * `left`, and `--swc-placement-available-*` — is left to the caller so + * exit transitions can complete before those properties are removed. + * Safe to call multiple times. */ public stop() { - const floating = this.session?.floating; - if (floating) { - floating.style.removeProperty('--swc-placement-available-width'); - floating.style.removeProperty('--swc-placement-available-height'); - } - const tipElement = this.session?.options.tipElement; - if (tipElement) { - tipElement.style.removeProperty('translate'); - tipElement.style.removeProperty('top'); - tipElement.style.removeProperty('left'); - } this.cleanup?.(); this.cleanup = undefined; this.session = null; @@ -467,7 +457,7 @@ export class PlacementController implements ReactiveController { { placement: floatingPlacement, middleware, - strategy: 'fixed', + strategy: 'absolute', // Required for correct top-layer element placement } ); if (this.session !== session) { diff --git a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts index 97ca0bfc0c4..8edd0e3c563 100644 --- a/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts +++ b/2nd-gen/packages/core/controllers/placement-controller/test/placement-controller.test.ts @@ -692,8 +692,7 @@ export const TwoControllersDoNotInterfere: Story = { const bTranslateBefore = host.floatingB.style.translate; host.controllerA.stop(); - // A is torn down: custom props removed, placement cleared. - expect(availableWidth(host.floatingA)).toBe(''); + // A is torn down: actualPlacement cleared. expect(host.controllerA.actualPlacement).toBeNull(); // B is untouched: same position, props, and placement. diff --git a/2nd-gen/packages/core/package.json b/2nd-gen/packages/core/package.json index 3e0ab550e6b..ff31e06f1de 100644 --- a/2nd-gen/packages/core/package.json +++ b/2nd-gen/packages/core/package.json @@ -63,6 +63,14 @@ "types": "./dist/components/meter/index.d.ts", "import": "./dist/components/meter/index.js" }, + "./components/popover": { + "types": "./dist/components/popover/index.d.ts", + "import": "./dist/components/popover/index.js" + }, + "./components/popover/index.js": { + "types": "./dist/components/popover/index.d.ts", + "import": "./dist/components/popover/index.js" + }, "./components/progress-circle": { "types": "./dist/components/progress-circle/index.d.ts", "import": "./dist/components/progress-circle/index.js" @@ -159,6 +167,10 @@ "types": "./dist/utils/capitalize.d.ts", "import": "./dist/utils/capitalize.js" }, + "./utils/dismissible-stack.js": { + "types": "./dist/utils/dismissible-stack.d.ts", + "import": "./dist/utils/dismissible-stack.js" + }, "./utils/get-label-from-slot.js": { "types": "./dist/utils/get-label-from-slot.d.ts", "import": "./dist/utils/get-label-from-slot.js" @@ -166,6 +178,10 @@ "./utils/index.js": { "types": "./dist/utils/index.d.ts", "import": "./dist/utils/index.js" + }, + "./utils/resolve-trigger.js": { + "types": "./dist/utils/resolve-trigger.d.ts", + "import": "./dist/utils/resolve-trigger.js" } }, "main": "./dist/index.js", diff --git a/2nd-gen/packages/core/utils/dismissible-stack.ts b/2nd-gen/packages/core/utils/dismissible-stack.ts new file mode 100644 index 00000000000..539e6d4f999 --- /dev/null +++ b/2nd-gen/packages/core/utils/dismissible-stack.ts @@ -0,0 +1,58 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * A LIFO stack of currently-open dismissible top-layer components, used to + * coordinate Escape handling across mechanisms that don't natively order with + * each other (e.g. an `popover="auto"` element open while a `` is also + * open). + * + * Usage convention for consumers: + * + * - `registerDismissible(this)` on open. + * - `unregisterDismissible(this)` on close and in `disconnectedCallback`. + * - Check `isTopDismissible(this)` before processing any custom Escape handling. + * + * State is module-level and in-memory only; it resets on page reload, which is + * correct for this coordination use case. + * + */ +const dismissibleStack: object[] = []; + +/** + * Push a dismissible onto the stack when it opens. Duplicate registration + * (same key already on the stack) is a no-op to prevent double-push from + * re-entrant open paths. + */ +export function registerDismissible(key: object): void { + if (!dismissibleStack.includes(key)) { + dismissibleStack.push(key); + } +} + +/** + * Remove the most-recent entry matching the key when it closes. Idempotent: + * calling with a key that is not on the stack is a safe no-op. + */ +export function unregisterDismissible(key: object): void { + const index = dismissibleStack.lastIndexOf(key); + if (index !== -1) { + dismissibleStack.splice(index, 1); + } +} + +/** + * Whether the key is the topmost (most-recently-registered) dismissible. + */ +export function isTopDismissible(key: object): boolean { + return dismissibleStack.at(-1) === key; +} diff --git a/2nd-gen/packages/core/utils/index.ts b/2nd-gen/packages/core/utils/index.ts index 26867bea526..7260f3e1c02 100644 --- a/2nd-gen/packages/core/utils/index.ts +++ b/2nd-gen/packages/core/utils/index.ts @@ -11,6 +11,16 @@ */ export { capitalize } from './capitalize.js'; +export { + isTopDismissible, + registerDismissible, + unregisterDismissible, +} from './dismissible-stack.js'; export { getActiveElement } from './get-active-element.js'; export { focusableSelector, tabbableSelector } from './focusable-selectors.js'; export { getLabelFromSlot } from './get-label-from-slot.js'; +export { + resolveTrigger, + type ResolvedTrigger, + type ResolveTriggerOptions, +} from './resolve-trigger.js'; diff --git a/2nd-gen/packages/core/utils/resolve-trigger.ts b/2nd-gen/packages/core/utils/resolve-trigger.ts new file mode 100644 index 00000000000..b104ee1d21d --- /dev/null +++ b/2nd-gen/packages/core/utils/resolve-trigger.ts @@ -0,0 +1,48 @@ +/** + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Options for {@link resolveTrigger}. + */ +export interface ResolveTriggerOptions { + /** ID of the trigger element in the host's tree root. */ + for?: string; + /** Direct trigger reference; overrides `for` when set. */ + triggerElement?: HTMLElement | null; +} + +/** + * The resolved trigger and the element that should receive ARIA wiring. + * + * `trigger` is the element used for positioning. `interactiveElement` is the + * AT-facing focusable element — the inner `