Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs-app/app/templates/5-floaty-bits/floating-ui.gjs.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
# Floating UI

The `FloatingUI` component provides a wrapper for using [Floating UI](https://floating-ui.com/), for associating a floating element to an anchor element (such as for menus, popovers, etc).

<Callout>

The usage of a 3rd-party library will be removed when [CSS Anchor Positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning) lands and is widely supported (This component and modifier will still exist for the purpose of wiring up the ids between anchor and target).
**Higher-level components use CSS Anchor Positioning.** `<Popover>`, `<Menu>`, and other built-in components now use native [CSS Anchor Positioning](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning) instead of Floating UI. For most use cases, prefer [`<Popover>`](/5-floaty-bits/popover.md) or [`<Menu>`](/5-floaty-bits/menu.md) directly.

The `<FloatingUI>` component and `anchorTo` modifier remain available for:
- **Environments without CSS Anchor Positioning support** — if you need to support browsers that don't yet implement CSS Anchor Positioning, use `<FloatingUI>` directly for JS-based positioning
- **Advanced use cases** where you need direct access to [Floating UI](https://floating-ui.com/) middleware

Note that Floating UI is entirely JS-based and does not use CSS Anchor Positioning internally — these are two separate positioning approaches.

</Callout>

The `FloatingUI` component provides a wrapper for using [Floating UI](https://floating-ui.com/), for associating a floating element to an anchor element (such as for menus, popovers, etc).

Several of Floating UI's functions and [middleware](https://floating-ui.com/docs/middleware) are used to create an experience out of the box that is useful and expected.
See Floating UI's [documentation](https://floating-ui.com/docs/getting-started) for more information on any of the following included functionality.

Expand Down
31 changes: 27 additions & 4 deletions docs-app/app/templates/5-floaty-bits/popover.gjs.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Popover

Popovers are built with [Floating UI][docs-floating-ui], a set of utilities for making floating elements relate to each other with minimal configuration. The `<Popover>` component uses the [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API) for layering, which totally solves z-index and overflow clipping issues, no portals needed.
Popovers use [CSS Anchor Positioning][docs-anchor] for positioning floating elements relative to their anchor, with automatic flip fallbacks via `position-try-fallbacks` and viewport-aware visibility via `position-visibility`. No JavaScript positioning library needed.

The `<Popover>` component uses the [Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API) for layering, which totally solves z-index and overflow clipping issues — no portals needed.

One thing to note is that the position of the popover can _escape_ the boundary of a [ShadowDom][docs-shadow-dom] -- all demos on this docs site for `ember-primitives` use a `ShadowDom` to allow for isolated CSS usage within the demos.

[docs-floating-ui]: /5-floaty-bits/floating-ui.md
[docs-floating]: https://floating-ui.com/
[docs-popper]: https://popper.js.org/
[docs-anchor]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_anchor_positioning
[docs-shadow-dom]: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM

<div class="featured-demo">
Expand Down Expand Up @@ -376,6 +376,29 @@ The `@inline` argument has been removed. All popover content now renders inline
+ <p.Content>
```

### Remove Floating UI–specific arguments

Positioning is now handled by [CSS Anchor Positioning][docs-anchor] instead of Floating UI. The arguments that mapped onto Floating UI middleware no longer exist on `<Popover>` (or `<Menu>`):

- `@flipOptions` — flip behavior is now provided by `position-try-fallbacks: flip-block`.
- `@middleware` — there is no JS middleware pipeline anymore.
- `@shiftOptions` — the browser handles shifting via `position-area`.
- `@strategy` — the component sets `position: fixed` itself; there is no `absolute` strategy to opt into.

`@offsetOptions` and `@placement` continue to work the same way.

```diff
<Popover
- @flipOptions={{...}}
- @middleware={{...}}
- @shiftOptions={{...}}
- @strategy="absolute"
@offsetOptions={{8}}
@placement="bottom"
as |p|
>
```

### CSS considerations

The `popover` HTML attribute has a browser UA stylesheet that adds default `border`, `padding`, and `overflow` styles to elements with `[popover]`. The component resets `overflow: visible` automatically so arrows aren't clipped, but you may need to set `border: none` on your floating content if you don't want the browser's default `[popover]` border:
Expand Down
3 changes: 3 additions & 0 deletions ember-primitives/src/anchor-position.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { attachArrow } from './anchor-position/modifier.ts';
export type { OffsetOptions, Placement, PlacementConfig } from './anchor-position/placement.ts';
export { anchorPositionStyle, PLACEMENT_CONFIG } from './anchor-position/placement.ts';
43 changes: 43 additions & 0 deletions ember-primitives/src/anchor-position/modifier.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { modifier as eModifier } from 'ember-modifier';

import { PLACEMENT_CONFIG } from './placement.ts';

import type { Placement } from './placement.ts';

/**
* Positions an arrow element on the side of its anchor-positioned parent
* facing the reference element, centered along the cross-axis.
*
* Example:
* ```gjs
* import { attachArrow } from 'ember-primitives/anchor-position';
*
* <template>
* <div {{attachArrow placement="bottom"}}></div>
* </template>
* ```
*/
export const attachArrow = eModifier<{
Element: Element;
Args: {
Named: {
placement: Placement;
};
};
}>((el, _: [], { placement }) => {
if (!(el instanceof HTMLElement)) return;

const config = PLACEMENT_CONFIG[placement];

el.style.setProperty('position', 'absolute');

if (config.arrowIsVertical) {
el.style.setProperty('left', '50%');
el.style.setProperty('translate', '-50% 0');
} else {
el.style.setProperty('top', '50%');
el.style.setProperty('translate', '0 -50%');
}

el.style.setProperty(config.arrowSide, '-4px');
});
180 changes: 180 additions & 0 deletions ember-primitives/src/anchor-position/placement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { htmlSafe } from '@ember/template';

import type { SafeString } from '@ember/template';

export type Placement =
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'left'
| 'left-start'
| 'left-end'
| 'right'
| 'right-start'
| 'right-end';

export type OffsetOptions = number | { mainAxis?: number; crossAxis?: number };

export interface PlacementConfig {
positionArea: string;
selfProp: string;
selfValue: string;
offsetMargin: string;
crossOffsetMargin: string;
arrowSide: string;
arrowIsVertical: boolean;
}

/**
* Static lookup for CSS Anchor Positioning properties per placement.
*
* The position-area grid has the anchor element at center.
* For -start/-end variants, we use span-right/span-left (or span-bottom/span-top)
* to create an area starting at the anchor's edge, then align within that area.
*/
export const PLACEMENT_CONFIG: Record<Placement, PlacementConfig> = {
top: {
positionArea: 'top',
selfProp: 'justify-self',
selfValue: 'anchor-center',
offsetMargin: 'margin-bottom',
crossOffsetMargin: 'margin-left',
arrowSide: 'bottom',
arrowIsVertical: true,
},
'top-start': {
positionArea: 'top span-right',
selfProp: 'justify-self',
selfValue: 'start',
offsetMargin: 'margin-bottom',
crossOffsetMargin: 'margin-left',
arrowSide: 'bottom',
arrowIsVertical: true,
},
'top-end': {
positionArea: 'top span-left',
selfProp: 'justify-self',
selfValue: 'end',
offsetMargin: 'margin-bottom',
crossOffsetMargin: 'margin-left',
arrowSide: 'bottom',
arrowIsVertical: true,
},
bottom: {
positionArea: 'bottom',
selfProp: 'justify-self',
selfValue: 'anchor-center',
offsetMargin: 'margin-top',
crossOffsetMargin: 'margin-left',
arrowSide: 'top',
arrowIsVertical: true,
},
'bottom-start': {
positionArea: 'bottom span-right',
selfProp: 'justify-self',
selfValue: 'start',
offsetMargin: 'margin-top',
crossOffsetMargin: 'margin-left',
arrowSide: 'top',
arrowIsVertical: true,
},
'bottom-end': {
positionArea: 'bottom span-left',
selfProp: 'justify-self',
selfValue: 'end',
offsetMargin: 'margin-top',
crossOffsetMargin: 'margin-left',
arrowSide: 'top',
arrowIsVertical: true,
},
left: {
positionArea: 'left',
selfProp: 'align-self',
selfValue: 'anchor-center',
offsetMargin: 'margin-right',
crossOffsetMargin: 'margin-top',
arrowSide: 'right',
arrowIsVertical: false,
},
'left-start': {
positionArea: 'left span-bottom',
selfProp: 'align-self',
selfValue: 'start',
offsetMargin: 'margin-right',
crossOffsetMargin: 'margin-top',
arrowSide: 'right',
arrowIsVertical: false,
},
'left-end': {
positionArea: 'left span-top',
selfProp: 'align-self',
selfValue: 'end',
offsetMargin: 'margin-right',
crossOffsetMargin: 'margin-top',
arrowSide: 'right',
arrowIsVertical: false,
},
right: {
positionArea: 'right',
selfProp: 'align-self',
selfValue: 'anchor-center',
offsetMargin: 'margin-left',
crossOffsetMargin: 'margin-top',
arrowSide: 'left',
arrowIsVertical: false,
},
'right-start': {
positionArea: 'right span-bottom',
selfProp: 'align-self',
selfValue: 'start',
offsetMargin: 'margin-left',
crossOffsetMargin: 'margin-top',
arrowSide: 'left',
arrowIsVertical: false,
},
'right-end': {
positionArea: 'right span-top',
selfProp: 'align-self',
selfValue: 'end',
offsetMargin: 'margin-left',
crossOffsetMargin: 'margin-top',
arrowSide: 'left',
arrowIsVertical: false,
},
};

/**
* Build the inline CSS that positions a floating element relative to its
* anchor using CSS Anchor Positioning.
*
* The returned string covers placement (`position-area`), self-alignment,
* fallback flipping (`position-try-fallbacks`), and viewport-aware visibility
* (`position-visibility`). Optional `mainAxis` / `crossAxis` offsets are
* expressed as margins in the appropriate direction for the chosen placement.
*/
export function anchorPositionStyle(
placement: Placement,
anchorName: string,
offsetOptions?: OffsetOptions
): SafeString {
const config = PLACEMENT_CONFIG[placement];

const offsetOpts = offsetOptions ?? 0;
const mainAxis = typeof offsetOpts === 'number' ? offsetOpts : (offsetOpts?.mainAxis ?? 0);
const crossAxis = typeof offsetOpts === 'number' ? 0 : (offsetOpts?.crossAxis ?? 0);

let style = `position: fixed; inset: auto; overflow: visible; border: none; position-anchor: ${anchorName}; position-area: ${config.positionArea}; ${config.selfProp}: ${config.selfValue}; width: max-content; margin: 0; position-try-fallbacks: flip-block; position-visibility: anchors-visible`;

if (mainAxis) {
style += `; ${config.offsetMargin}: ${mainAxis}px`;
}

if (crossAxis) {
style += `; ${config.crossOffsetMargin}: ${crossAxis}px`;
}

return htmlSafe(style);
}
10 changes: 1 addition & 9 deletions ember-primitives/src/components/menu.gts
Original file line number Diff line number Diff line change
Expand Up @@ -338,15 +338,7 @@ export class Menu extends Component<Signature> {

<template>
{{#let (IsOpen) (TriggerElement) as |isOpen triggerEl|}}
<Popover
@flipOptions={{@flipOptions}}
@middleware={{@middleware}}
@offsetOptions={{@offsetOptions}}
@placement={{@placement}}
@shiftOptions={{@shiftOptions}}
@strategy={{@strategy}}
as |p|
>
<Popover @offsetOptions={{@offsetOptions}} @placement={{@placement}} as |p|>
{{#let
(modifier
trigger
Expand Down
Loading
Loading