diff --git a/examples/select-group/index.tsx b/examples/select-group/index.tsx
index 3e8f7c8fb9..76fb6d4c44 100644
--- a/examples/select-group/index.tsx
+++ b/examples/select-group/index.tsx
@@ -2,16 +2,17 @@ import * as Ariakit from "@ariakit/react";
import "./style.css";
export default function Example() {
- const select = Ariakit.useSelectStore({
- defaultValue: "Apple",
- sameWidth: true,
- gutter: 4,
- });
+ const select = Ariakit.useSelectStore({ defaultValue: "Apple" });
return (
Favorite food
-
+
Fruits & Vegetables
diff --git a/examples/select-item-custom/index.tsx b/examples/select-item-custom/index.tsx
index 447e8d461b..6863c28cd0 100644
--- a/examples/select-item-custom/index.tsx
+++ b/examples/select-item-custom/index.tsx
@@ -28,8 +28,6 @@ export default function Example() {
const select = Ariakit.useSelectStore({
defaultValue: "john.doe@example.com",
setValueOnMove: true,
- sameWidth: true,
- gutter: 4,
});
const value = select.useState("value");
return (
@@ -39,7 +37,12 @@ export default function Example() {
{renderValue(value)}
-
+
{accounts.map((email) => (
{renderValue(email)}
diff --git a/examples/select-multiple/index.tsx b/examples/select-multiple/index.tsx
index 469b3b8c9c..5cb4cfb4e1 100644
--- a/examples/select-multiple/index.tsx
+++ b/examples/select-multiple/index.tsx
@@ -9,11 +9,7 @@ function renderValue(value: string[]) {
}
export default function Example() {
- const select = Ariakit.useSelectStore({
- defaultValue: ["Apple", "Cake"],
- sameWidth: true,
- gutter: 4,
- });
+ const select = Ariakit.useSelectStore({ defaultValue: ["Apple", "Cake"] });
const value = select.useState("value");
const mounted = select.useState("mounted");
return (
@@ -24,7 +20,12 @@ export default function Example() {
{mounted && (
-
+
{list.map((value) => (
Favorite fruit
-
+
diff --git a/examples/toolbar-select/toolbar.tsx b/examples/toolbar-select/toolbar.tsx
index 64ed646c8f..d3555ddbe8 100644
--- a/examples/toolbar-select/toolbar.tsx
+++ b/examples/toolbar-select/toolbar.tsx
@@ -76,7 +76,6 @@ export const ToolbarSelect = React.forwardRef<
value,
setValue: onChange,
defaultValue,
- gutter: 4,
});
const selectValue = select.useState("value");
@@ -105,7 +104,7 @@ export const ToolbarSelect = React.forwardRef<
{displayValue}
-
+
{options.map((option) => (
Hover or focus on me
-
+
Tooltip
>
diff --git a/examples/tooltip-placement/style.css b/examples/tooltip-instant/style.css
similarity index 100%
rename from examples/tooltip-placement/style.css
rename to examples/tooltip-instant/style.css
diff --git a/examples/tooltip-instant/test.ts b/examples/tooltip-instant/test.ts
new file mode 100644
index 0000000000..d4a88fc60e
--- /dev/null
+++ b/examples/tooltip-instant/test.ts
@@ -0,0 +1,29 @@
+import { getByRole, hover, press, waitFor } from "@ariakit/test";
+
+const getTooltip = () => getByRole("tooltip", { hidden: true });
+
+const hoverOutside = async () => {
+ await hover(document.body);
+ await hover(document.body, { clientX: 10, clientY: 10 });
+ await hover(document.body, { clientX: 20, clientY: 20 });
+};
+
+test("show tooltip on hover", async () => {
+ expect(getTooltip()).not.toBeVisible();
+ await hover(getByRole("button"));
+ await waitFor(() => expect(getTooltip()).toBeVisible());
+ await hoverOutside();
+ expect(getTooltip()).not.toBeVisible();
+});
+
+test("show tooltip on focus", async () => {
+ const div = document.createElement("div");
+ div.tabIndex = 0;
+ document.body.append(div);
+ expect(getTooltip()).not.toBeVisible();
+ await press.Tab();
+ await waitFor(() => expect(getTooltip()).toBeVisible());
+ await press.Tab();
+ expect(getTooltip()).not.toBeVisible();
+ div.remove();
+});
diff --git a/examples/tooltip-label/index.tsx b/examples/tooltip-label/index.tsx
new file mode 100644
index 0000000000..2f9fc5a53f
--- /dev/null
+++ b/examples/tooltip-label/index.tsx
@@ -0,0 +1,33 @@
+import * as Ariakit from "@ariakit/react";
+import "./style.css";
+
+const icon = (
+
+
+
+);
+
+export default function Example() {
+ const tooltip = Ariakit.useTooltipStore({ type: "label" });
+ return (
+ <>
+
+ {icon}
+
+
+ Bold
+
+ >
+ );
+}
diff --git a/examples/tooltip-timeout/style.css b/examples/tooltip-label/style.css
similarity index 100%
rename from examples/tooltip-timeout/style.css
rename to examples/tooltip-label/style.css
diff --git a/examples/tooltip-label/test.ts b/examples/tooltip-label/test.ts
new file mode 100644
index 0000000000..f7b4921340
--- /dev/null
+++ b/examples/tooltip-label/test.ts
@@ -0,0 +1,18 @@
+import { getByRole, getByText, hover, waitFor } from "@ariakit/test";
+
+const getButton = () => getByRole("button", { name: "Bold" });
+const getTooltip = () => getByText("Bold");
+
+const hoverOutside = async () => {
+ await hover(document.body);
+ await hover(document.body, { clientX: 10, clientY: 10 });
+ await hover(document.body, { clientX: 20, clientY: 20 });
+};
+
+test("show tooltip on hover", async () => {
+ expect(getTooltip()).not.toBeVisible();
+ await hover(getButton());
+ await waitFor(() => expect(getTooltip()).toBeVisible());
+ await hoverOutside();
+ expect(getTooltip()).not.toBeVisible();
+});
diff --git a/examples/tooltip-placement/test.ts b/examples/tooltip-placement/test.ts
deleted file mode 100644
index 03e87ecb1e..0000000000
--- a/examples/tooltip-placement/test.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-import { blur, getByRole, hover, press } from "@ariakit/test";
-
-test("show tooltip on hover", async () => {
- expect(getByRole("tooltip", { hidden: true })).not.toBeVisible();
- await hover(getByRole("button"));
- expect(getByRole("tooltip", { hidden: true })).toBeVisible();
- await hover(document.body);
- expect(getByRole("tooltip", { hidden: true })).not.toBeVisible();
-});
-
-test("show tooltip on focus", async () => {
- expect(getByRole("tooltip", { hidden: true })).not.toBeVisible();
- await press.Tab();
- expect(getByRole("tooltip", { hidden: true })).toBeVisible();
- await blur();
- expect(getByRole("tooltip", { hidden: true })).not.toBeVisible();
-});
diff --git a/examples/tooltip-timeout/index.tsx b/examples/tooltip-timeout/index.tsx
deleted file mode 100644
index 6efee72c72..0000000000
--- a/examples/tooltip-timeout/index.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import * as Ariakit from "@ariakit/react";
-import "./style.css";
-
-export default function Example() {
- const tooltip = Ariakit.useTooltipStore({ timeout: 2000 });
- return (
- <>
-
- Hover or focus on me and wait for 2 seconds
-
-
- Tooltip
-
- >
- );
-}
diff --git a/examples/tooltip-timeout/test.ts b/examples/tooltip-timeout/test.ts
deleted file mode 100644
index 4a380f201b..0000000000
--- a/examples/tooltip-timeout/test.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { blur, getByRole, hover, press, waitFor } from "@ariakit/test";
-
-test("show tooltip on hover after timeout", async () => {
- expect(getByRole("tooltip", { hidden: true })).not.toBeVisible();
- await hover(getByRole("button"));
- await waitFor(
- () => expect(getByRole("tooltip", { hidden: true })).toBeVisible(),
- { timeout: 2100 }
- );
- await hover(document.body);
- expect(getByRole("tooltip", { hidden: true })).not.toBeVisible();
-});
-
-test("show tooltip on focus after timeout", async () => {
- expect(getByRole("tooltip", { hidden: true })).not.toBeVisible();
- await press.Tab();
- await waitFor(
- () => expect(getByRole("tooltip", { hidden: true })).toBeVisible(),
- { timeout: 2100 }
- );
- await blur();
- expect(getByRole("tooltip", { hidden: true })).not.toBeVisible();
-});
diff --git a/examples/tooltip/index.tsx b/examples/tooltip/index.tsx
index 37aa57b0bb..9510b97789 100644
--- a/examples/tooltip/index.tsx
+++ b/examples/tooltip/index.tsx
@@ -6,13 +6,13 @@ export default function Example() {
return (
<>
Hover or focus on me
-
+
Tooltip
>
diff --git a/examples/tooltip/style.css b/examples/tooltip/style.css
index 5ed78f8cb3..5e54418304 100644
--- a/examples/tooltip/style.css
+++ b/examples/tooltip/style.css
@@ -7,6 +7,7 @@
py-1
px-2
text-sm
+ cursor-default
text-black
dark:text-white
border-gray-300
diff --git a/examples/tooltip/test-mobile.ts b/examples/tooltip/test-mobile.ts
new file mode 100644
index 0000000000..c93eafc48a
--- /dev/null
+++ b/examples/tooltip/test-mobile.ts
@@ -0,0 +1,12 @@
+import { expect, test } from "@playwright/test";
+
+test.beforeEach(async ({ page }) => {
+ await page.goto("/previews/tooltip", { waitUntil: "networkidle" });
+});
+
+test("tooltip does not appear on mobile click", async ({ page }) => {
+ await expect(page.getByRole("tooltip")).not.toBeVisible();
+ await page.getByRole("button").click();
+ await page.waitForTimeout(600);
+ await expect(page.getByRole("tooltip")).not.toBeVisible();
+});
diff --git a/examples/tooltip/test.ts b/examples/tooltip/test.ts
index 416b9c5530..d369dc0478 100644
--- a/examples/tooltip/test.ts
+++ b/examples/tooltip/test.ts
@@ -1,17 +1,89 @@
-import { blur, getByRole, hover, press } from "@ariakit/test";
+import { click, getByRole, hover, press, waitFor } from "@ariakit/test";
+
+const getTooltip = () => getByRole("tooltip", { hidden: true });
+
+const waitForTooltipToShow = () =>
+ waitFor(() => expect(getTooltip()).toBeVisible());
+
+const hoverOutside = async () => {
+ await hover(document.body);
+ await hover(document.body, { clientX: 10, clientY: 10 });
+ await hover(document.body, { clientX: 20, clientY: 20 });
+};
test("show tooltip on hover", async () => {
- expect(getByRole("tooltip", { hidden: true })).not.toBeVisible();
+ expect(getTooltip()).not.toBeVisible();
await hover(getByRole("button"));
- expect(getByRole("tooltip")).toBeVisible();
- await hover(document.body);
- expect(getByRole("tooltip", { hidden: true })).not.toBeVisible();
+ await waitForTooltipToShow();
+ await hoverOutside();
+ expect(getTooltip()).not.toBeVisible();
+});
+
+test("do not hide tooltip on click", async () => {
+ await hover(getByRole("button"));
+ await waitForTooltipToShow();
+ await click(getByRole("button"));
+ expect(getTooltip()).toBeVisible();
+ await hoverOutside();
+ expect(getTooltip()).not.toBeVisible();
+});
+
+test("do not wait to show the tooltip if it was just hidden", async () => {
+ await hover(getByRole("button"));
+ await waitForTooltipToShow();
+ await hoverOutside();
+ expect(getTooltip()).not.toBeVisible();
+ await hover(getByRole("button"));
+ expect(getTooltip()).toBeVisible();
+});
+
+test("if tooltip was shown on hover, then the anchor received keyboard focus, do not hide on mouseleave", async () => {
+ await hover(getByRole("button"));
+ await waitForTooltipToShow();
+ await press.Tab();
+ expect(getTooltip()).toBeVisible();
+ await hoverOutside();
+ expect(getTooltip()).toBeVisible();
+});
+
+test("if tooltip was shown on focus visible, do not hide on mouseleave", async () => {
+ await press.Tab();
+ await waitForTooltipToShow();
+ await hoverOutside();
+ expect(getTooltip()).toBeVisible();
+ await hover(getByRole("button"));
+ expect(getTooltip()).toBeVisible();
+ await hoverOutside();
+ expect(getTooltip()).toBeVisible();
});
test("show tooltip on focus", async () => {
- expect(getByRole("tooltip", { hidden: true })).not.toBeVisible();
+ const div = document.createElement("div");
+ div.tabIndex = 0;
+ document.body.append(div);
+
+ expect(getTooltip()).not.toBeVisible();
await press.Tab();
expect(getByRole("tooltip")).toBeVisible();
- await blur();
- expect(getByRole("tooltip", { hidden: true })).not.toBeVisible();
+ await press.Tab();
+ expect(getTooltip()).not.toBeVisible();
+
+ div.remove();
+});
+
+test("do not show tooltip immediately if focus was lost", async () => {
+ const div = document.createElement("div");
+ div.tabIndex = 0;
+ document.body.append(div);
+
+ await hover(getByRole("button"));
+ await waitForTooltipToShow();
+ await press.Tab();
+ await press.Tab();
+ expect(getTooltip()).not.toBeVisible();
+ await hover(getByRole("button"));
+ expect(getTooltip()).not.toBeVisible();
+ await waitForTooltipToShow();
+
+ div.remove();
});
diff --git a/package-lock.json b/package-lock.json
index ae85306388..993b861498 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23530,9 +23530,7 @@
"version": "0.1.8",
"license": "MIT",
"dependencies": {
- "@ariakit/core": "0.1.5",
- "@ariakit/react-core": "0.1.8",
- "@floating-ui/dom": "^1.0.0"
+ "@ariakit/react-core": "0.1.8"
},
"funding": {
"type": "opencollective",
@@ -23549,6 +23547,7 @@
"license": "MIT",
"dependencies": {
"@ariakit/core": "0.1.5",
+ "@floating-ui/dom": "^1.0.0",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
diff --git a/package.json b/package.json
index a946d7c144..a8c8306313 100644
--- a/package.json
+++ b/package.json
@@ -27,6 +27,15 @@
"test-safari": "npm run playwright -- --project=safari",
"test-safari-headed": "npm run test-safari -- --headed",
"test-safari-debug": "npm run test-safari -- --ui",
+ "test-mobile": "npm run playwright -- --project=ios --project=android",
+ "test-mobile-headed": "npm run test-mobile -- --headed",
+ "test-mobile-debug": "npm run test-mobile -- --ui",
+ "test-ios": "npm run playwright -- --project=ios",
+ "test-ios-headed": "npm run test-ios -- --headed",
+ "test-ios-debug": "npm run test-ios -- --ui",
+ "test-android": "npm run playwright -- --project=android",
+ "test-android-headed": "npm run test-android -- --headed",
+ "test-android-debug": "npm run test-android -- --ui",
"coverage": "npm test -- --coverage",
"postcoverage": "open-cli coverage/index.html",
"lint": "eslint . --ext js,ts,tsx",
diff --git a/packages/ariakit-core/src/hovercard/hovercard-store.ts b/packages/ariakit-core/src/hovercard/hovercard-store.ts
index 06ae325320..6a313880ad 100644
--- a/packages/ariakit-core/src/hovercard/hovercard-store.ts
+++ b/packages/ariakit-core/src/hovercard/hovercard-store.ts
@@ -69,8 +69,9 @@ export interface HovercardStoreState extends PopoverStoreState {
*/
placement: PopoverStoreState["placement"];
/**
- * The amount of time in milliseconds to wait before showing or hiding the
- * popover.
+ * The amount of time in milliseconds to wait before showing and hiding the
+ * popover. To control the delay for showing and hiding separately, use
+ * `showTimeout` and `hideTimeout`.
* @default 500
*/
timeout: number;
diff --git a/packages/ariakit-core/src/popover/popover-store.ts b/packages/ariakit-core/src/popover/popover-store.ts
index f1ccf2d6cd..405acc1aab 100644
--- a/packages/ariakit-core/src/popover/popover-store.ts
+++ b/packages/ariakit-core/src/popover/popover-store.ts
@@ -1,13 +1,3 @@
-import type { Middleware } from "@floating-ui/dom";
-import {
- arrow,
- autoUpdate,
- computePosition,
- flip,
- offset,
- shift,
- size,
-} from "@floating-ui/dom";
import type {
DialogStoreFunctions,
DialogStoreOptions,
@@ -26,68 +16,10 @@ type Placement =
| `${BasePlacement}-start`
| `${BasePlacement}-end`;
-type AnchorRect = {
- x?: number;
- y?: number;
- width?: number;
- height?: number;
-};
-
-const middlewares = { arrow, flip, offset, shift, size };
-
-function createDOMRect(x = 0, y = 0, width = 0, height = 0) {
- if (typeof DOMRect === "function") {
- return new DOMRect(x, y, width, height);
- }
- // JSDOM doesn't support DOMRect constructor.
- const rect = {
- x,
- y,
- width,
- height,
- top: y,
- right: x + width,
- bottom: y + height,
- left: x,
- };
- return { ...rect, toJSON: () => rect };
-}
-
-function getDOMRect(anchorRect?: AnchorRect | null) {
- if (!anchorRect) return createDOMRect();
- const { x, y, width, height } = anchorRect;
- return createDOMRect(x, y, width, height);
-}
-
-function getAnchorElement(
- anchorElement: HTMLElement | null,
- getAnchorRect?: (anchor: HTMLElement | null) => AnchorRect | null
-) {
- // https://floating-ui.com/docs/virtual-elements
- const contextElement = anchorElement || undefined;
- return {
- contextElement,
- getBoundingClientRect: () => {
- const anchor = anchorElement;
- const anchorRect = getAnchorRect?.(anchor);
- if (anchorRect || !anchor) {
- return getDOMRect(anchorRect);
- }
- return anchor.getBoundingClientRect();
- },
- };
-}
-
-function isValidPlacement(flip: string): flip is Placement {
- return /^(?:top|bottom|left|right)(?:-(?:start|end))?$/.test(flip);
-}
-
/**
* Creates a popover store.
*/
export function createPopoverStore({
- getAnchorRect,
- renderCallback,
popover: otherPopover,
...props
}: PopoverStoreProps = {}): PopoverStore {
@@ -103,7 +35,6 @@ export function createPopoverStore({
);
const syncState = store?.getState();
- const rendered = createStore({ rendered: [] });
const dialog = createDialogStore({ ...props, store });
const placement = defaultValue(
@@ -116,258 +47,23 @@ export function createPopoverStore({
...dialog.getState(),
placement,
currentPlacement: placement,
- fixed: defaultValue(props.fixed, syncState?.fixed, false),
- gutter: defaultValue(props.gutter, syncState?.gutter),
- flip: defaultValue(props.flip, syncState?.flip, true),
- shift: defaultValue(props.shift, syncState?.shift, 0),
- slide: defaultValue(props.slide, syncState?.slide, true),
- overlap: defaultValue(props.overlap, syncState?.overlap, false),
- sameWidth: defaultValue(props.sameWidth, syncState?.sameWidth, false),
- fitViewport: defaultValue(props.fitViewport, syncState?.fitViewport, false),
- arrowPadding: defaultValue(props.arrowPadding, syncState?.arrowPadding, 4),
- overflowPadding: defaultValue(
- props.overflowPadding,
- syncState?.overflowPadding,
- 8
- ),
anchorElement: defaultValue(syncState?.anchorElement, null),
popoverElement: defaultValue(syncState?.popoverElement, null),
arrowElement: defaultValue(syncState?.arrowElement, null),
+ rendered: Symbol("rendered"),
};
const popover = createStore(initialState, dialog, store);
- const setCurrentPlacement = (placement: Placement) => {
- popover.setState("currentPlacement", placement);
- };
-
- popover.setup(() =>
- rendered.sync(() =>
- popover.syncBatch(
- (state) => {
- if (!state.contentElement?.isConnected) return;
- const popover = state.popoverElement;
- if (!popover) return;
- const anchor = getAnchorElement(state.anchorElement, getAnchorRect);
-
- popover.style.setProperty(
- "--popover-overflow-padding",
- `${state.overflowPadding}px`
- );
-
- const defaultRenderCallback = () => {
- const update = async () => {
- if (!state.mounted) return;
-
- const arrow = state.arrowElement;
-
- const middleware: Middleware[] = [
- // https://floating-ui.com/docs/offset
- middlewares.offset(({ placement }) => {
- const arrowOffset = (arrow?.clientHeight || 0) / 2;
- const finalGutter =
- typeof state.gutter === "number"
- ? state.gutter + arrowOffset
- : state.gutter ?? arrowOffset;
- // If there's no placement alignment (*-start or *-end),
- // we'll fallback to the crossAxis offset as it also works
- // for center-aligned placements.
- const hasAlignment = !!placement.split("-")[1];
- return {
- crossAxis: !hasAlignment ? state.shift : undefined,
- mainAxis: finalGutter,
- alignmentAxis: state.shift,
- };
- }),
- ];
-
- if (state.flip !== false) {
- const fallbackPlacements =
- typeof state.flip === "string"
- ? state.flip.split(" ")
- : undefined;
-
- if (
- fallbackPlacements !== undefined &&
- !fallbackPlacements.every(isValidPlacement)
- ) {
- throw new Error(
- "`flip` expects a spaced-delimited list of placements"
- );
- }
-
- // https://floating-ui.com/docs/flip
- middleware.push(
- middlewares.flip({
- padding: state.overflowPadding,
- fallbackPlacements,
- })
- );
- }
-
- if (state.slide || state.overlap) {
- // https://floating-ui.com/docs/shift
- middleware.push(
- middlewares.shift({
- mainAxis: state.slide,
- crossAxis: state.overlap,
- padding: state.overflowPadding,
- })
- );
- }
-
- // https://floating-ui.com/docs/size
- middleware.push(
- middlewares.size({
- padding: state.overflowPadding,
- apply({ availableWidth, availableHeight, rects }) {
- const referenceWidth = Math.round(rects.reference.width);
- availableWidth = Math.floor(availableWidth);
- availableHeight = Math.floor(availableHeight);
- popover.style.setProperty(
- "--popover-anchor-width",
- `${referenceWidth}px`
- );
- popover.style.setProperty(
- "--popover-available-width",
- `${availableWidth}px`
- );
- popover.style.setProperty(
- "--popover-available-height",
- `${availableHeight}px`
- );
- if (state.sameWidth) {
- popover.style.width = `${referenceWidth}px`;
- }
- if (state.fitViewport) {
- popover.style.maxWidth = `${availableWidth}px`;
- popover.style.maxHeight = `${availableHeight}px`;
- }
- },
- })
- );
-
- if (arrow) {
- // https://floating-ui.com/docs/arrow
- middleware.push(
- middlewares.arrow({
- element: arrow,
- padding: state.arrowPadding,
- })
- );
- }
-
- // https://floating-ui.com/docs/computePosition
- const pos = await computePosition(anchor, popover, {
- placement: state.placement,
- strategy: state.fixed ? "fixed" : "absolute",
- middleware,
- });
-
- setCurrentPlacement(pos.placement);
-
- const x = Math.round(pos.x);
- const y = Math.round(pos.y);
-
- // https://floating-ui.com/docs/misc#subpixel-and-accelerated-positioning
- Object.assign(popover.style, {
- top: "0",
- left: "0",
- transform: `translate3d(${x}px, ${y}px, 0)`,
- });
-
- // https://floating-ui.com/docs/arrow#usage
- if (arrow && pos.middlewareData.arrow) {
- const { x: arrowX, y: arrowY } = pos.middlewareData.arrow;
-
- const dir = pos.placement.split("-")[0] as BasePlacement;
-
- Object.assign(arrow.style, {
- left: arrowX != null ? `${arrowX}px` : "",
- top: arrowY != null ? `${arrowY}px` : "",
- [dir]: "100%",
- });
- }
- };
-
- // https://floating-ui.com/docs/autoUpdate
- return autoUpdate(anchor, popover, update, {
- // JSDOM doesn't support ResizeObserver
- elementResize: typeof ResizeObserver === "function",
- });
- };
-
- if (renderCallback) {
- return renderCallback({
- ...state,
- setPlacement: setCurrentPlacement,
- defaultRenderCallback,
- });
- }
- return defaultRenderCallback();
- },
- [
- "anchorElement",
- "popoverElement",
- "arrowElement",
- "contentElement",
- "gutter",
- "mounted",
- "shift",
- "overlap",
- "flip",
- "overflowPadding",
- "slide",
- "sameWidth",
- "fitViewport",
- "arrowPadding",
- "placement",
- "fixed",
- ]
- )
- )
- );
-
return {
...dialog,
...popover,
setAnchorElement: (element) => popover.setState("anchorElement", element),
setPopoverElement: (element) => popover.setState("popoverElement", element),
setArrowElement: (element) => popover.setState("arrowElement", element),
- render: () => rendered.setState("rendered", []),
- getAnchorRect,
- renderCallback,
+ render: () => popover.setState("rendered", Symbol("rendered")),
};
}
-export interface PopoverStoreRenderCallbackProps
- extends Pick<
- PopoverStoreState,
- | "anchorElement"
- | "popoverElement"
- | "arrowElement"
- | "mounted"
- | "placement"
- | "fixed"
- | "gutter"
- | "shift"
- | "overlap"
- | "flip"
- | "sameWidth"
- | "fitViewport"
- | "arrowPadding"
- | "overflowPadding"
- > {
- /**
- * A method that updates the `currentPlacement` state.
- */
- setPlacement: SetState;
- /**
- * The default render callback that will be called when the `renderCallback`
- * prop is not provided.
- */
- defaultRenderCallback: () => () => void;
-}
-
export interface PopoverStoreState extends DialogStoreState {
/**
* The anchor element.
@@ -393,64 +89,10 @@ export interface PopoverStoreState extends DialogStoreState {
*/
placement: Placement;
/**
- * Whether the popover has `position: fixed` or not.
- * @default false
- */
- fixed: boolean;
- /**
- * The distance between the popover and the anchor element. By default, it's 0
- * plus half of the arrow offset, if it exists.
- */
- gutter: number | undefined;
- /**
- * The skidding of the popover along the anchor element.
- * @default 0
- */
- shift: number;
- /**
- * Controls the behavior of the popover when it overflows the viewport:
- * - If a `boolean`, specifies whether the popover should flip to the
- * opposite side when it overflows.
- * - If a `string`, indicates the preferred fallback placements when it
- * overflows. The placements must be spaced-delimited, e.g. "top left".
- * @default true
+ * A symbol that's used to recompute the popover position when the `render`
+ * method is called.
*/
- flip: boolean | string;
- /**
- * Whether the popover should slide when it overflows.
- * @default true
- */
- slide: boolean;
- /**
- * Whether the popover can overlap the anchor element when it overflows.
- * @default false
- */
- overlap: boolean;
- /**
- * Whether the popover should have the same width as the anchor element. This
- * will be exposed to CSS as `--popover-anchor-width`.
- * @default false
- */
- sameWidth: boolean;
- /**
- * Whether the popover should fit the viewport. If this is set to true, the
- * popover wrapper will have `maxWidth` and `maxHeight` set to the viewport
- * size. This will be exposed to CSS as `--popover-available-width` and
- * `--popover-available-height`.
- * @default false
- */
- fitViewport: boolean;
- /**
- * The minimum padding between the arrow and the popover corner.
- * @default 4
- */
- arrowPadding: number;
- /**
- * The minimum padding between the popover and the viewport edge. This will be
- * exposed to CSS as `--popover-overflow-padding`.
- * @default 8
- */
- overflowPadding: number;
+ rendered: symbol;
}
export interface PopoverStoreFunctions extends DialogStoreFunctions {
@@ -467,41 +109,15 @@ export interface PopoverStoreFunctions extends DialogStoreFunctions {
*/
setArrowElement: SetState;
/**
- * Function that returns the anchor element's DOMRect. If this is explicitly
- * passed, it will override the anchor `getBoundingClientRect` method.
- * @param anchor The anchor element.
- */
- getAnchorRect?: (anchor: HTMLElement | null) => AnchorRect | null;
- /**
- * A function that will be called when the popover needs to calculate its
- * styles. It will override the internal behavior.
- */
- renderCallback?: (
- props: PopoverStoreRenderCallbackProps
- ) => void | (() => void);
- /**
- * A function that can be used to recompute the popover styles. This is useful
- * when the popover anchor changes in a way that affects the popover position.
+ * A function that can be used to recompute the popover position. This is
+ * useful when the popover anchor changes in a way that affects the popover
+ * position.
*/
render: () => void;
}
export interface PopoverStoreOptions
- extends StoreOptions<
- PopoverStoreState,
- | "placement"
- | "fixed"
- | "gutter"
- | "shift"
- | "flip"
- | "slide"
- | "overlap"
- | "sameWidth"
- | "fitViewport"
- | "arrowPadding"
- | "overflowPadding"
- >,
- Partial>,
+ extends StoreOptions,
DialogStoreOptions {
/**
* A reference to another popover store that's controlling another popover to
diff --git a/packages/ariakit-core/src/tooltip/tooltip-store.ts b/packages/ariakit-core/src/tooltip/tooltip-store.ts
index 97f249aa80..fd8a7882e6 100644
--- a/packages/ariakit-core/src/tooltip/tooltip-store.ts
+++ b/packages/ariakit-core/src/tooltip/tooltip-store.ts
@@ -1,21 +1,13 @@
-import { createDisclosureStore } from "../disclosure/disclosure-store.js";
+import { createHovercardStore } from "../hovercard/hovercard-store.js";
import type {
- PopoverStoreFunctions,
- PopoverStoreOptions,
- PopoverStoreState,
-} from "../popover/popover-store.js";
-import { createPopoverStore } from "../popover/popover-store.js";
+ HovercardStoreFunctions,
+ HovercardStoreOptions,
+ HovercardStoreState,
+} from "../hovercard/hovercard-store.js";
import { defaultValue } from "../utils/misc.js";
import type { Store, StoreOptions, StoreProps } from "../utils/store.js";
import { createStore } from "../utils/store.js";
-const tooltips = createStore({ activeRef: null as symbol | null });
-
-function afterTimeout(timeoutMs: number, cb: () => void) {
- const timeoutId = setTimeout(cb, timeoutMs);
- return () => clearTimeout(timeoutId);
-}
-
/**
* Creates a tooltip store.
*/
@@ -23,116 +15,78 @@ export function createTooltipStore(
props: TooltipStoreProps = {}
): TooltipStore {
const syncState = props.store?.getState();
- const open = defaultValue(props.open, syncState?.open, false);
- const disclosure = createDisclosureStore({ ...props, open });
-
- const popover = createPopoverStore({
+ const hovercard = createHovercardStore({
...props,
- open,
placement: defaultValue(
props.placement,
syncState?.placement,
"top" as const
),
- gutter: defaultValue(props.gutter, syncState?.gutter, 8),
+ showTimeout: defaultValue(
+ props.showTimeout,
+ syncState?.showTimeout,
+ props.timeout,
+ 500
+ ),
+ hideTimeout: defaultValue(
+ props.hideTimeout,
+ syncState?.hideTimeout,
+ props.timeout,
+ 0
+ ),
});
const initialState: TooltipStoreState = {
- ...popover.getState(),
- timeout: defaultValue(props.timeout, syncState?.timeout, 0),
+ ...hovercard.getState(),
+ type: defaultValue(props.type, syncState?.type, "description" as const),
+ skipTimeout: defaultValue(props.skipTimeout, syncState?.skipTimeout, 300),
};
- const tooltip = createStore(
- initialState,
- popover,
- disclosure.omit("open", "mounted"),
- props.store
- );
- const ref = Symbol();
-
- tooltip.setup(() =>
- disclosure.sync(
- (state, prev) => {
- const { timeout } = tooltip.getState();
- const { activeRef } = tooltips.getState();
- if (state.open) {
- if (!timeout || activeRef) {
- // If there's no timeout or an open tooltip already, we can show
- // this immediately.
- tooltips.setState("activeRef", ref);
- tooltip.setState("open", true);
- return;
- } else {
- // There may be a reference with focus whose tooltip is still not
- // open. In this case, we want to update it before it gets shown.
- tooltips.setState("activeRef", null);
- // Wait for the timeout to show the tooltip.
- return afterTimeout(timeout, () => {
- tooltips.setState("activeRef", ref);
- });
- }
- } else if (state.open !== prev.open) {
- tooltip.setState("open", false);
- // Let's give some time so people can move from a reference to
- // another and still show tooltips immediately.
- return afterTimeout(timeout, () => {
- tooltips.setState("activeRef", (activeRef) =>
- activeRef === ref ? null : activeRef
- );
- });
- }
- return;
- },
- ["open"]
- )
- );
-
- tooltip.setup(() =>
- tooltips.sync(
- (state) => {
- tooltip.setState("open", state.activeRef === ref);
- },
- ["activeRef"]
- )
- );
-
- tooltip.setup(() => () => {
- tooltips.setState("activeRef", (activeRef) =>
- activeRef === ref ? null : activeRef
- );
- });
+ const tooltip = createStore(initialState, hovercard, props.store);
return {
- ...popover,
- ...disclosure,
+ ...hovercard,
...tooltip,
};
}
-export interface TooltipStoreState extends PopoverStoreState {
- /**
- * @default "top"
- */
- placement: PopoverStoreState["placement"];
+export interface TooltipStoreState extends HovercardStoreState {
/**
- * @default 8
+ * Determines whether the tooltip is being used as a label or a description
+ * for the anchor element.
+ * @default "description"
*/
- gutter: PopoverStoreState["gutter"];
+ type: "label" | "description";
/**
- * The amount in milliseconds to wait before showing the tooltip. When there's
- * already an open tooltip in the page, this value will be ignored and other
- * tooltips will be shown immediately.
- * @default 0
+ * The amount of time after a tooltip is hidden while all tooltips on the
+ * page can be shown immediately, without waiting for the show timeout.
+ * @default 300
*/
- timeout: number;
+ skipTimeout: number;
+ /** @default "top" */
+ placement: HovercardStoreState["placement"];
+ /** @default 0 */
+ timeout: HovercardStoreState["timeout"];
+ /** @default 500 */
+ showTimeout: HovercardStoreState["showTimeout"];
+ /** @default 0 */
+ hideTimeout: HovercardStoreState["hideTimeout"];
}
-export type TooltipStoreFunctions = PopoverStoreFunctions;
+export type TooltipStoreFunctions = HovercardStoreFunctions;
export interface TooltipStoreOptions
- extends StoreOptions,
- PopoverStoreOptions {}
+ extends StoreOptions<
+ TooltipStoreState,
+ | "type"
+ | "placement"
+ | "timeout"
+ | "showTimeout"
+ | "hideTimeout"
+ | "skipTimeout"
+ >,
+ HovercardStoreOptions {}
export type TooltipStoreProps = TooltipStoreOptions &
StoreProps;
diff --git a/packages/ariakit-react-core/package.json b/packages/ariakit-react-core/package.json
index fdd3fc0f89..5b641d3fa1 100644
--- a/packages/ariakit-react-core/package.json
+++ b/packages/ariakit-react-core/package.json
@@ -31,6 +31,7 @@
],
"dependencies": {
"@ariakit/core": "0.1.5",
+ "@floating-ui/dom": "^1.0.0",
"use-sync-external-store": "^1.2.0"
},
"peerDependencies": {
diff --git a/packages/ariakit-react-core/src/popover/popover-store.ts b/packages/ariakit-react-core/src/popover/popover-store.ts
index c4f756c446..521a778057 100644
--- a/packages/ariakit-react-core/src/popover/popover-store.ts
+++ b/packages/ariakit-react-core/src/popover/popover-store.ts
@@ -8,18 +8,11 @@ import {
useDialogStoreOptions,
useDialogStoreProps,
} from "../dialog/dialog-store.js";
-import { useEvent } from "../utils/hooks.js";
import type { Store } from "../utils/store.js";
import { useStore, useStoreProps } from "../utils/store.js";
export function usePopoverStoreOptions(props: PopoverStoreProps) {
- const getAnchorRect = useEvent(props.getAnchorRect);
- const renderCallback = useEvent(props.renderCallback);
- return {
- ...useDialogStoreOptions(props),
- getAnchorRect: props.getAnchorRect ? getAnchorRect : undefined,
- renderCallback: props.renderCallback ? renderCallback : undefined,
- };
+ return useDialogStoreOptions(props);
}
export function usePopoverStoreProps(
@@ -28,16 +21,6 @@ export function usePopoverStoreProps(
) {
store = useDialogStoreProps(store, props);
useStoreProps(store, props, "placement");
- useStoreProps(store, props, "fixed");
- useStoreProps(store, props, "gutter");
- useStoreProps(store, props, "flip");
- useStoreProps(store, props, "shift");
- useStoreProps(store, props, "slide");
- useStoreProps(store, props, "overlap");
- useStoreProps(store, props, "sameWidth");
- useStoreProps(store, props, "fitViewport");
- useStoreProps(store, props, "arrowPadding");
- useStoreProps(store, props, "overflowPadding");
return store;
}
diff --git a/packages/ariakit-react-core/src/popover/popover.tsx b/packages/ariakit-react-core/src/popover/popover.tsx
index 06975249ea..643fc7a0c0 100644
--- a/packages/ariakit-react-core/src/popover/popover.tsx
+++ b/packages/ariakit-react-core/src/popover/popover.tsx
@@ -1,8 +1,19 @@
import type { HTMLAttributes } from "react";
import { useState } from "react";
+import { invariant } from "@ariakit/core/utils/misc";
+import {
+ arrow,
+ autoUpdate,
+ computePosition,
+ flip,
+ offset,
+ shift,
+ size,
+} from "@floating-ui/dom";
import type { DialogOptions } from "../dialog/dialog.js";
import { useDialog } from "../dialog/dialog.js";
import {
+ useEvent,
usePortalRef,
useSafeLayoutEffect,
useWrapElement,
@@ -12,6 +23,178 @@ import type { As, Props } from "../utils/types.js";
import { PopoverContext } from "./popover-context.js";
import type { PopoverStore } from "./popover-store.js";
+type BasePlacement = "top" | "bottom" | "left" | "right";
+
+type Placement =
+ | BasePlacement
+ | `${BasePlacement}-start`
+ | `${BasePlacement}-end`;
+
+type AnchorRect = {
+ x?: number;
+ y?: number;
+ width?: number;
+ height?: number;
+};
+
+function createDOMRect(x = 0, y = 0, width = 0, height = 0) {
+ if (typeof DOMRect === "function") {
+ return new DOMRect(x, y, width, height);
+ }
+ // JSDOM doesn't support DOMRect constructor.
+ const rect = {
+ x,
+ y,
+ width,
+ height,
+ top: y,
+ right: x + width,
+ bottom: y + height,
+ left: x,
+ };
+ return { ...rect, toJSON: () => rect };
+}
+
+function getDOMRect(anchorRect?: AnchorRect | null) {
+ if (!anchorRect) return createDOMRect();
+ const { x, y, width, height } = anchorRect;
+ return createDOMRect(x, y, width, height);
+}
+
+function getAnchorElement(
+ anchorElement: HTMLElement | null,
+ getAnchorRect?: (anchor: HTMLElement | null) => AnchorRect | null
+) {
+ // https://floating-ui.com/docs/virtual-elements
+ const contextElement = anchorElement || undefined;
+ return {
+ contextElement,
+ getBoundingClientRect: () => {
+ const anchor = anchorElement;
+ const anchorRect = getAnchorRect?.(anchor);
+ if (anchorRect || !anchor) {
+ return getDOMRect(anchorRect);
+ }
+ return anchor.getBoundingClientRect();
+ },
+ };
+}
+
+function isValidPlacement(flip: string): flip is Placement {
+ return /^(?:top|bottom|left|right)(?:-(?:start|end))?$/.test(flip);
+}
+
+// https://floating-ui.com/docs/misc#subpixel-and-accelerated-positioning
+function roundByDPR(value: number) {
+ const dpr = window.devicePixelRatio || 1;
+ return Math.round(value * dpr) / dpr;
+}
+
+function getOffsetMiddleware(
+ arrowElement: HTMLElement | null,
+ props: Pick
+) {
+ // https://floating-ui.com/docs/offset
+ return offset(({ placement }) => {
+ const arrowOffset = (arrowElement?.clientHeight || 0) / 2;
+ const finalGutter =
+ typeof props.gutter === "number"
+ ? props.gutter + arrowOffset
+ : props.gutter ?? arrowOffset;
+ // If there's no placement alignment (*-start or *-end),
+ // we'll fallback to the crossAxis offset as it also works
+ // for center-aligned placements.
+ const hasAlignment = !!placement.split("-")[1];
+ return {
+ crossAxis: !hasAlignment ? props.shift : undefined,
+ mainAxis: finalGutter,
+ alignmentAxis: props.shift,
+ };
+ });
+}
+
+function getFlipMiddleware(
+ props: Pick
+) {
+ if (props.flip === false) return;
+ const fallbackPlacements =
+ typeof props.flip === "string" ? props.flip.split(" ") : undefined;
+
+ invariant(
+ !fallbackPlacements || fallbackPlacements.every(isValidPlacement),
+ process.env.NODE_ENV !== "production" &&
+ "`flip` expects a spaced-delimited list of placements"
+ );
+
+ // https://floating-ui.com/docs/flip
+ return flip({
+ padding: props.overflowPadding,
+ fallbackPlacements,
+ });
+}
+
+function getShiftMiddleware(
+ props: Pick
+) {
+ if (!props.slide && !props.overlap) return;
+ // https://floating-ui.com/docs/shift
+ return shift({
+ mainAxis: props.slide,
+ crossAxis: props.overlap,
+ padding: props.overflowPadding,
+ });
+}
+
+function getSizeMiddleware(
+ props: Pick
+) {
+ // https://floating-ui.com/docs/size
+ return size({
+ padding: props.overflowPadding,
+ apply({ elements, availableWidth, availableHeight, rects }) {
+ const wrapper = elements.floating;
+ const referenceWidth = Math.round(rects.reference.width);
+
+ availableWidth = Math.floor(availableWidth);
+ availableHeight = Math.floor(availableHeight);
+
+ wrapper.style.setProperty(
+ "--popover-anchor-width",
+ `${referenceWidth}px`
+ );
+ wrapper.style.setProperty(
+ "--popover-available-width",
+ `${availableWidth}px`
+ );
+ wrapper.style.setProperty(
+ "--popover-available-height",
+ `${availableHeight}px`
+ );
+
+ if (props.sameWidth) {
+ wrapper.style.width = `${referenceWidth}px`;
+ }
+
+ if (props.fitViewport) {
+ wrapper.style.maxWidth = `${availableWidth}px`;
+ wrapper.style.maxHeight = `${availableHeight}px`;
+ }
+ },
+ });
+}
+
+function getArrowMiddleware(
+ arrowElement: HTMLElement | null,
+ props: Pick
+) {
+ if (!arrowElement) return;
+ // https://floating-ui.com/docs/arrow
+ return arrow({
+ element: arrowElement,
+ padding: props.arrowPadding,
+ });
+}
+
/**
* Returns props to create a `Popover` component.
* @see https://ariakit.org/components/popover
@@ -30,44 +213,160 @@ export const usePopover = createHook(
preserveTabOrder = true,
autoFocusOnShow = true,
wrapperProps,
+ fixed = false,
+ flip = true,
+ shift = 0,
+ slide = true,
+ overlap = false,
+ sameWidth = false,
+ fitViewport = false,
+ gutter,
+ arrowPadding = 4,
+ overflowPadding = 8,
+ getAnchorRect,
+ updatePosition,
...props
}) => {
+ const arrowElement = store.useState("arrowElement");
+ const anchorElement = store.useState("anchorElement");
const popoverElement = store.useState("popoverElement");
const contentElement = store.useState("contentElement");
+ const placement = store.useState("placement");
+ const mounted = store.useState("mounted");
+ const rendered = store.useState("rendered");
+
+ // We have to wait for the popover to be positioned for the first time
+ // before we can move focus, otherwise there may be scroll jumps. See
+ // popover-standalone example test-browser file.
+ const [positioned, setPositioned] = useState(false);
- // Makes sure the wrapper element that's passed to popper has the same
- // z-index as the popover element so users only need to set the z-index
- // once.
- useSafeLayoutEffect(() => {
- const wrapper = popoverElement;
- const popover = contentElement;
- if (!wrapper) return;
- if (!popover) return;
- wrapper.style.zIndex = getComputedStyle(popover).zIndex;
- }, [popoverElement, contentElement]);
-
- // We have to wait for the popover to be positioned before we can move
- // focus, otherwise there may be scroll jumps. See popover-standalone
- // example test-browser file.
- const [canAutoFocusOnShow, setCanAutoFocusOnShow] = useState(false);
const { portalRef, domReady } = usePortalRef(portal, props.portalRef);
- const mounted = store.useState("mounted");
+
+ const getAnchorRectProp = useEvent(getAnchorRect);
+ const hasCustomUpdatePosition = !!updatePosition;
+ const updatePositionProp = useEvent(updatePosition);
useSafeLayoutEffect(() => {
- if (!domReady) return;
+ if (!popoverElement?.isConnected) return;
+
+ popoverElement.style.setProperty(
+ "--popover-overflow-padding",
+ `${overflowPadding}px`
+ );
+
+ const anchor = getAnchorElement(anchorElement, getAnchorRectProp);
+
+ const update = async () => {
+ if (!mounted) return;
+
+ const middleware = [
+ getOffsetMiddleware(arrowElement, { gutter, shift }),
+ getFlipMiddleware({ flip, overflowPadding }),
+ getShiftMiddleware({ slide, overlap, overflowPadding }),
+ getArrowMiddleware(arrowElement, { arrowPadding }),
+ getSizeMiddleware({
+ sameWidth,
+ fitViewport,
+ overflowPadding,
+ }),
+ ];
+
+ // https://floating-ui.com/docs/computePosition
+ const pos = await computePosition(anchor, popoverElement, {
+ placement,
+ strategy: fixed ? "fixed" : "absolute",
+ middleware,
+ });
+
+ store.setState("currentPlacement", pos.placement);
+ setPositioned(true);
+
+ const x = roundByDPR(pos.x);
+ const y = roundByDPR(pos.y);
+
+ // https://floating-ui.com/docs/misc#subpixel-and-accelerated-positioning
+ Object.assign(popoverElement.style, {
+ top: "0",
+ left: "0",
+ transform: `translate3d(${x}px,${y}px,0)`,
+ });
+
+ // https://floating-ui.com/docs/arrow#usage
+ if (arrowElement && pos.middlewareData.arrow) {
+ const { x: arrowX, y: arrowY } = pos.middlewareData.arrow;
+
+ const dir = pos.placement.split("-")[0] as BasePlacement;
+
+ Object.assign(arrowElement.style, {
+ left: arrowX != null ? `${arrowX}px` : "",
+ top: arrowY != null ? `${arrowY}px` : "",
+ [dir]: "100%",
+ });
+ }
+ };
+
+ // https://floating-ui.com/docs/autoUpdate
+ return autoUpdate(
+ anchor,
+ popoverElement,
+ () => {
+ if (hasCustomUpdatePosition) {
+ updatePositionProp({ updatePosition: update });
+ } else {
+ update();
+ }
+ },
+ {
+ // JSDOM doesn't support ResizeObserver
+ elementResize: typeof ResizeObserver === "function",
+ }
+ );
+ }, [
+ store,
+ rendered,
+ popoverElement,
+ arrowElement,
+ anchorElement,
+ popoverElement,
+ placement,
+ mounted,
+ domReady,
+ fixed,
+ flip,
+ shift,
+ slide,
+ overlap,
+ sameWidth,
+ fitViewport,
+ gutter,
+ arrowPadding,
+ overflowPadding,
+ getAnchorRectProp,
+ hasCustomUpdatePosition,
+ updatePositionProp,
+ ]);
+
+ // Makes sure the wrapper element that's passed to floating UI has the same
+ // z-index as the popover element so users only need to set the z-index
+ // once.
+ useSafeLayoutEffect(() => {
if (!mounted) return;
+ if (!domReady) return;
+ if (!popoverElement?.isConnected) return;
if (!contentElement?.isConnected) return;
- const raf = requestAnimationFrame(() => {
- setCanAutoFocusOnShow(true);
- });
- return () => {
- cancelAnimationFrame(raf);
+ const applyZIndex = () => {
+ popoverElement.style.zIndex = getComputedStyle(contentElement).zIndex;
};
- }, [domReady, mounted, contentElement]);
+ applyZIndex();
+ // It's possible that the zIndex value changes after the popover is
+ // mounted, so we need to keep checking.
+ let raf = requestAnimationFrame(() => {
+ raf = requestAnimationFrame(applyZIndex);
+ });
+ return () => cancelAnimationFrame(raf);
+ }, [mounted, domReady, popoverElement, contentElement]);
- const position = store.useState((state) =>
- state.fixed ? "fixed" : "absolute"
- );
+ const position = fixed ? "fixed" : "absolute";
// Wrap our element in a div that will be used to position the popover.
// This way the user doesn't need to override the popper's position to
@@ -79,9 +378,11 @@ export const usePopover = createHook(
role="presentation"
{...wrapperProps}
style={{
+ // https://floating-ui.com/docs/computeposition#initial-layout
position,
top: 0,
left: 0,
+ width: "max-content",
...wrapperProps?.style,
}}
ref={store.setPopoverElement}
@@ -115,7 +416,7 @@ export const usePopover = createHook(
modal,
preserveTabOrder,
portal,
- autoFocusOnShow: canAutoFocusOnShow && autoFocusOnShow,
+ autoFocusOnShow: positioned && autoFocusOnShow,
...props,
portalRef,
});
@@ -146,6 +447,7 @@ if (process.env.NODE_ENV !== "production") {
export interface PopoverOptions extends DialogOptions {
/**
* Object returned by the `usePopoverStore` hook.
+ * @see https://ariakit.org/guide/component-stores
*/
store: PopoverStore;
/**
@@ -153,6 +455,92 @@ export interface PopoverOptions extends DialogOptions {
* be used to position the popover.
*/
wrapperProps?: HTMLAttributes;
+ /**
+ * Whether the popover has `position: fixed` or not.
+ * @default false
+ */
+ fixed?: boolean;
+ /**
+ * The distance between the popover and the anchor element.
+ * @default 0
+ */
+ gutter?: number;
+ /**
+ * The skidding of the popover along the anchor element. Can be set to
+ * negative values to make the popover shift to the opposite side.
+ * @default 0
+ */
+ shift?: number;
+ /**
+ * Controls the behavior of the popover when it overflows the viewport:
+ * - If a `boolean`, specifies whether the popover should flip to the
+ * opposite side when it overflows.
+ * - If a `string`, indicates the preferred fallback placements when it
+ * overflows. The placements must be spaced-delimited, e.g. "top left".
+ * @default true
+ */
+ flip?: boolean | string;
+ /**
+ * Whether the popover should slide when it overflows.
+ * @default true
+ */
+ slide?: boolean;
+ /**
+ * Whether the popover can overlap the anchor element when it overflows.
+ * @default false
+ */
+ overlap?: boolean;
+ /**
+ * Whether the popover should have the same width as the anchor element. This
+ * will be exposed to CSS as
+ * [`--popover-anchor-width`](https://ariakit.org/guide/styling#--popover-anchor-width).
+ * @default false
+ */
+ sameWidth?: boolean;
+ /**
+ * Whether the popover should fit the viewport. If this is set to true, the
+ * popover wrapper will have `maxWidth` and `maxHeight` set to the viewport
+ * size. This will be exposed to CSS as
+ * [`--popover-available-width`](https://ariakit.org/guide/styling#--popover-available-width)
+ * and
+ * [`--popover-available-height`](https://ariakit.org/guide/styling#--popover-available-height).
+ * @default false
+ */
+ fitViewport?: boolean;
+ /**
+ * The minimum padding between the arrow and the popover corner.
+ * @default 4
+ */
+ arrowPadding?: number;
+ /**
+ * The minimum padding between the popover and the viewport edge. This will be
+ * exposed to CSS as
+ * [`--popover-overflow-padding`](https://ariakit.org/guide/styling#--popover-overflow-padding).
+ * @default 8
+ */
+ overflowPadding?: number;
+ /**
+ * Function that returns the anchor element's DOMRect. If this is explicitly
+ * passed, it will override the anchor `getBoundingClientRect` method.
+ *
+ * Examples using this prop:
+ * - [Textarea with inline combobox](https://ariakit.org/examples/combobox-textarea)
+ * - [Standalone Popover](https://ariakit.org/examples/popover-standalone)
+ * - [Context menu](https://ariakit.org/examples/menu-context-menu)
+ * - [Selection Popover](https://ariakit.org/examples/popover-selection)
+ * @param anchor The anchor element.
+ */
+ getAnchorRect?: (anchor: HTMLElement | null) => AnchorRect | null;
+ /**
+ * A callback that will be called when the popover needs to calculate its
+ * position. This will override the internal `updatePosition` function. The
+ * original `updatePosition` function will be passed as an argument, so it can
+ * be called inside the callback to apply the default behavior.
+ *
+ * Example susing this prop:
+ * - [Responsive Popover](https://ariakit.org/examples/popover-responsive)
+ */
+ updatePosition?: (props: { updatePosition: () => Promise }) => void;
}
export type PopoverProps = Props>;
diff --git a/packages/ariakit-react-core/src/portal/portal.tsx b/packages/ariakit-react-core/src/portal/portal.tsx
index 71828ec8d9..c5ee20ecd2 100644
--- a/packages/ariakit-react-core/src/portal/portal.tsx
+++ b/packages/ariakit-react-core/src/portal/portal.tsx
@@ -65,10 +65,10 @@ export const usePortal = createHook(
const context = useContext(PortalContext);
const [portalNode, setPortalNode] = useState(null);
- const beforeOutsideRef = useRef(null);
- const beforeInsideRef = useRef(null);
- const afterInsideRef = useRef(null);
- const afterOutsideRef = useRef(null);
+ const outerBeforeRef = useRef(null);
+ const innerBeforeRef = useRef(null);
+ const innerAfterRef = useRef(null);
+ const outerAfterRef = useRef(null);
// Create the portal node and attach it to the DOM.
useSafeLayoutEffect(() => {
@@ -92,7 +92,7 @@ export const usePortal = createHook(
if (!portalEl.id) {
// Use the element's id so rendering will
// produce predictable results.
- portalEl.id = element.id ? `${element.id}-portal` : getRandomId();
+ portalEl.id = element.id ? `portal/${element.id}` : getRandomId();
}
// Set the internal portal node state and the portalRef prop.
setPortalNode(portalEl);
@@ -170,12 +170,13 @@ export const usePortal = createHook(
<>
{preserveTabOrder && portalNode && (
{
if (isFocusEventOutside(event, portalNode)) {
queueFocus(getNextTabbable());
} else {
- queueFocus(beforeOutsideRef.current);
+ queueFocus(outerBeforeRef.current);
}
}}
/>
@@ -183,12 +184,13 @@ export const usePortal = createHook(
{element}
{preserveTabOrder && portalNode && (
{
if (isFocusEventOutside(event, portalNode)) {
queueFocus(getPreviousTabbable());
} else {
- queueFocus(afterOutsideRef.current);
+ queueFocus(outerAfterRef.current);
}
}}
/>
@@ -204,10 +206,17 @@ export const usePortal = createHook(
<>
{preserveTabOrder && portalNode && (
{
- if (isFocusEventOutside(event, portalNode)) {
- queueFocus(beforeInsideRef.current);
+ // If the event is coming from the outer after focus trap, it
+ // means there's no tabbable element inside the portal. In
+ // this case, we don't focus the inner before focus trap, but
+ // the previous tabbable element outside the portal.
+ const fromOuter =
+ event.relatedTarget === outerAfterRef.current;
+ if (!fromOuter && isFocusEventOutside(event, portalNode)) {
+ queueFocus(innerBeforeRef.current);
} else {
queueFocus(getPreviousTabbable());
}
@@ -222,12 +231,25 @@ export const usePortal = createHook(
{element}
{preserveTabOrder && portalNode && (
{
if (isFocusEventOutside(event, portalNode)) {
- queueFocus(afterInsideRef.current);
+ queueFocus(innerAfterRef.current);
} else {
- queueFocus(getNextTabbable());
+ const nextTabbable = getNextTabbable();
+ // If the next tabbable element is the inner before focus
+ // trap, this means we're at the end of the document or the
+ // portal was placed right after the original spot in the
+ // React tree. We need to wait for the next frame so the
+ // preserveTabOrder effect can run and disable the inner
+ // before focus trap. If there's no tabbable element after
+ // that, the focus will stay on this element.
+ if (nextTabbable === innerBeforeRef.current) {
+ requestAnimationFrame(() => getNextTabbable()?.focus());
+ return;
+ }
+ queueFocus(nextTabbable);
}
}}
/>
diff --git a/packages/ariakit-react-core/src/tooltip/tooltip-anchor.ts b/packages/ariakit-react-core/src/tooltip/tooltip-anchor.ts
index 6b2b02950e..f18f805e21 100644
--- a/packages/ariakit-react-core/src/tooltip/tooltip-anchor.ts
+++ b/packages/ariakit-react-core/src/tooltip/tooltip-anchor.ts
@@ -1,11 +1,20 @@
-import type { FocusEvent, MouseEvent } from "react";
-import type { PopoverAnchorOptions } from "../popover/popover-anchor.js";
-import { usePopoverAnchor } from "../popover/popover-anchor.js";
+import { useEffect } from "react";
+import type { FocusEvent } from "react";
+import { isFalsyBooleanCallback } from "@ariakit/core/utils/misc";
+import { createStore } from "@ariakit/core/utils/store";
+import type { HovercardAnchorOptions } from "../hovercard/hovercard-anchor.js";
+import { useHovercardAnchor } from "../hovercard/hovercard-anchor.js";
import { useEvent } from "../utils/hooks.js";
import { createComponent, createElement, createHook } from "../utils/system.js";
import type { As, Props } from "../utils/types.js";
import type { TooltipStore } from "./tooltip-store.js";
+// Create a global store to keep track of the active tooltip store so we can
+// show other tooltips without a delay when there's already an active tooltip.
+const globalStore = createStore<{ activeStore: TooltipStore | null }>({
+ activeStore: null,
+});
+
/**
* Returns props to create a `TooltipAnchor` component.
* @see https://ariakit.org/components/tooltip
@@ -18,11 +27,38 @@ import type { TooltipStore } from "./tooltip-store.js";
* ```
*/
export const useTooltipAnchor = createHook(
- ({ store, described, ...props }) => {
- const onFocusProp = props.onFocus;
-
- const onFocus = useEvent((event: FocusEvent) => {
- onFocusProp?.(event);
+ ({ store, showOnHover = true, ...props }) => {
+ useEffect(() => {
+ return store.sync(
+ (state) => {
+ // If the current tooltip is open, we should immediately hide the
+ // active one and set the current one as the active tooltip.
+ if (state.mounted) {
+ const { activeStore } = globalStore.getState();
+ if (activeStore !== store) {
+ activeStore?.hide();
+ }
+ return globalStore.setState("activeStore", store);
+ }
+ // Otherwise, if the current tooltip is closed, we should set a
+ // timeout to hide the active tooltip in the global store. This is so
+ // we can show other tooltips without a delay when there's already an
+ // active tooltip (see the showOnHover method below).
+ const id = setTimeout(() => {
+ const { activeStore } = globalStore.getState();
+ if (activeStore !== store) return;
+ globalStore.setState("activeStore", null);
+ }, state.skipTimeout);
+ return () => clearTimeout(id);
+ },
+ ["mounted", "skipTimeout"]
+ );
+ }, [store]);
+
+ const onFocusVisibleProp = props.onFocusVisible;
+
+ const onFocusVisible = useEvent((event: FocusEvent) => {
+ onFocusVisibleProp?.(event);
if (event.defaultPrevented) return;
store.setAnchorElement(event.currentTarget);
store.show();
@@ -33,40 +69,42 @@ export const useTooltipAnchor = createHook(
const onBlur = useEvent((event: FocusEvent) => {
onBlurProp?.(event);
if (event.defaultPrevented) return;
- store.hide();
- });
-
- const onMouseEnterProp = props.onMouseEnter;
-
- const onMouseEnter = useEvent((event: MouseEvent) => {
- onMouseEnterProp?.(event);
- if (event.defaultPrevented) return;
- store.setAnchorElement(event.currentTarget);
- store.show();
+ const { activeStore } = globalStore.getState();
+ // If the current tooltip is the active tooltip and the anchor loses focus
+ // (for example, if the anchor is a menu button, clicking on the menu
+ // button will automatically focus on the menu), we don't want to show
+ // subsequent tooltips without a delay. So we set the active tooltip to
+ // null.
+ if (activeStore === store) {
+ globalStore.setState("activeStore", null);
+ }
});
- const onMouseLeaveProp = props.onMouseLeave;
-
- const onMouseLeave = useEvent((event: MouseEvent) => {
- onMouseLeaveProp?.(event);
- if (event.defaultPrevented) return;
- store.hide();
- });
-
- const contentElement = store.useState("contentElement");
+ const type = store.useState("type");
+ const contentId = store.useState((state) => state.contentElement?.id);
props = {
tabIndex: 0,
- "aria-labelledby": !described ? contentElement?.id : undefined,
- "aria-describedby": described ? contentElement?.id : undefined,
+ "aria-labelledby": type === "label" ? contentId : undefined,
+ "aria-describedby": type === "description" ? contentId : undefined,
...props,
- onFocus,
+ onFocusVisible,
onBlur,
- onMouseEnter,
- onMouseLeave,
};
- props = usePopoverAnchor({ store, ...props });
+ props = useHovercardAnchor({
+ store,
+ showOnHover: (event) => {
+ if (isFalsyBooleanCallback(showOnHover, event)) return false;
+ const { activeStore } = globalStore.getState();
+ if (!activeStore) return true;
+ // Show the tooltip immediately if the current tooltip is the active
+ // tooltip instead of waiting for the showTimeout delay.
+ store.show();
+ return false;
+ },
+ ...props,
+ });
return props;
}
@@ -94,27 +132,11 @@ if (process.env.NODE_ENV !== "production") {
}
export interface TooltipAnchorOptions
- extends PopoverAnchorOptions {
+ extends HovercardAnchorOptions {
/**
* Object returned by the `useTooltipStore` hook.
*/
store: TooltipStore;
- /**
- * Determines wether the tooltip anchor is described or labelled by the
- * tooltip. If `true`, the tooltip id will be set as the `aria-describedby`
- * attribute on the anchor element, and not as the `aria-labelledby`
- * attribute.
- * @default false
- * @example
- * ```jsx
- * const tooltip = useTooltipStore();
- *
- * This is an element with a visible label.
- *
- * Description
- * ```
- */
- described?: boolean;
}
export type TooltipAnchorProps = Props<
diff --git a/packages/ariakit-react-core/src/tooltip/tooltip-store.ts b/packages/ariakit-react-core/src/tooltip/tooltip-store.ts
index f66d98e82c..ef64144cdd 100644
--- a/packages/ariakit-react-core/src/tooltip/tooltip-store.ts
+++ b/packages/ariakit-react-core/src/tooltip/tooltip-store.ts
@@ -1,25 +1,25 @@
import * as Core from "@ariakit/core/tooltip/tooltip-store";
import type {
- PopoverStoreFunctions,
- PopoverStoreOptions,
- PopoverStoreState,
-} from "../popover/popover-store.js";
+ HovercardStoreFunctions,
+ HovercardStoreOptions,
+ HovercardStoreState,
+} from "../hovercard/hovercard-store.js";
import {
- usePopoverStoreOptions,
- usePopoverStoreProps,
-} from "../popover/popover-store.js";
+ useHovercardStoreOptions,
+ useHovercardStoreProps,
+} from "../hovercard/hovercard-store.js";
import type { Store } from "../utils/store.js";
import { useStore } from "../utils/store.js";
export function useTooltipStoreOptions(props: TooltipStoreProps) {
- return usePopoverStoreOptions(props);
+ return useHovercardStoreOptions(props);
}
export function useTooltipStoreProps(
store: T,
props: TooltipStoreProps
) {
- return usePopoverStoreProps(store, props);
+ return useHovercardStoreProps(store, props);
}
/**
@@ -42,15 +42,15 @@ export function useTooltipStore(props: TooltipStoreProps = {}): TooltipStore {
export interface TooltipStoreState
extends Core.TooltipStoreState,
- PopoverStoreState {}
+ HovercardStoreState {}
export interface TooltipStoreFunctions
extends Core.TooltipStoreFunctions,
- PopoverStoreFunctions {}
+ HovercardStoreFunctions {}
export interface TooltipStoreOptions
extends Core.TooltipStoreOptions,
- PopoverStoreOptions {}
+ HovercardStoreOptions {}
export type TooltipStoreProps = TooltipStoreOptions & Core.TooltipStoreProps;
diff --git a/packages/ariakit-react-core/src/tooltip/tooltip.tsx b/packages/ariakit-react-core/src/tooltip/tooltip.tsx
index 64aee73725..34e44e5947 100644
--- a/packages/ariakit-react-core/src/tooltip/tooltip.tsx
+++ b/packages/ariakit-react-core/src/tooltip/tooltip.tsx
@@ -1,16 +1,8 @@
-import type { HTMLAttributes } from "react";
-import { useEffect } from "react";
-import { addGlobalEventListener } from "@ariakit/core/utils/events";
-import type { BooleanOrCallback } from "@ariakit/core/utils/types";
-import type { DisclosureContentOptions } from "../disclosure/disclosure-content.js";
-import { useDisclosureContent } from "../disclosure/disclosure-content.js";
-import type { PortalOptions } from "../portal/portal.js";
-import { usePortal } from "../portal/portal.js";
-import {
- useBooleanEvent,
- useSafeLayoutEffect,
- useWrapElement,
-} from "../utils/hooks.js";
+import { contains } from "@ariakit/core/utils/dom";
+import { isFalsyBooleanCallback } from "@ariakit/core/utils/misc";
+import { useHovercard } from "../hovercard/hovercard.js";
+import type { HovercardOptions } from "../hovercard/hovercard.js";
+import { useWrapElement } from "../utils/hooks.js";
import { createComponent, createElement, createHook } from "../utils/system.js";
import type { As, Props } from "../utils/types.js";
import { TooltipContext } from "./tooltip-context.js";
@@ -31,73 +23,12 @@ export const useTooltip = createHook(
({
store,
portal = true,
- hideOnEscape = true,
- hideOnControl = false,
- wrapperProps,
+ gutter = 8,
+ preserveTabOrder = false,
+ hideOnHoverOutside = true,
+ hideOnInteractOutside = true,
...props
}) => {
- const popoverElement = store.useState("popoverElement");
- const contentElement = store.useState("contentElement");
-
- // Makes sure the wrapper element that's passed to popper has the same
- // z-index as the popover element so users only need to set the z-index
- // once.
- useSafeLayoutEffect(() => {
- const wrapper = popoverElement;
- const popover = contentElement;
- if (!wrapper) return;
- if (!popover) return;
- wrapper.style.zIndex = getComputedStyle(popover).zIndex;
- }, [popoverElement, contentElement]);
-
- const hideOnEscapeProp = useBooleanEvent(hideOnEscape);
- const hideOnControlProp = useBooleanEvent(hideOnControl);
-
- const open = store.useState("open");
-
- // Hide on Escape/Control. Popover already handles this, but only when the
- // dialog, the backdrop or the disclosure elements are focused. Since the
- // tooltip, by default, does not receive focus when it's shown, we need to
- // handle this globally here.
- useEffect(() => {
- if (!open) return;
- return addGlobalEventListener("keydown", (event) => {
- if (event.defaultPrevented) return;
- const isEscape = event.key === "Escape" && hideOnEscapeProp(event);
- const isControl = event.key === "Control" && hideOnControlProp(event);
- if (isEscape || isControl) {
- store.hide();
- }
- });
- }, [store, open, hideOnEscapeProp, hideOnControlProp]);
-
- const position = store.useState((state) =>
- state.fixed ? "fixed" : "absolute"
- );
-
- // Wrap our element in a div that will be used to position the popover.
- // This way the user doesn't need to override the popper's position to
- // create animations.
- props = useWrapElement(
- props,
- (element) => (
-
- {element}
-
- ),
- [store, position, wrapperProps]
- );
-
props = useWrapElement(
props,
(element) => (
@@ -108,13 +39,41 @@ export const useTooltip = createHook(
[store]
);
- props = {
- role: "tooltip",
- ...props,
- };
+ const role = store.useState((state) =>
+ state.type === "description" ? "tooltip" : "none"
+ );
+
+ props = { role, ...props };
- props = useDisclosureContent({ store, ...props });
- props = usePortal({ portal, ...props, preserveTabOrder: false });
+ props = useHovercard({
+ ...props,
+ store,
+ portal,
+ gutter,
+ preserveTabOrder,
+ hideOnHoverOutside: (event) => {
+ if (isFalsyBooleanCallback(hideOnHoverOutside, event)) return false;
+ const { anchorElement } = store.getState();
+ if (!anchorElement) return true;
+ // If the anchor element has the `data-focus-visible` attribute (added
+ // by the `Focusable` component that is used by several components), we
+ // don't hide the tooltip when the mouse leaves the anchor element. In
+ // this case, the tooltip will be hidden only if the user presses the
+ // Escape key or if the anchor element loses focus.
+ if ("focusVisible" in anchorElement.dataset) return false;
+ return true;
+ },
+ hideOnInteractOutside: (event) => {
+ if (isFalsyBooleanCallback(hideOnInteractOutside, event)) return false;
+ const { anchorElement } = store.getState();
+ if (!anchorElement) return true;
+ // Prevent hiding the tooltip when the user interacts with the anchor
+ // element. It's up to the developer to hide the tooltip when the user
+ // clicks on the anchor element if that's the intended behavior.
+ if (contains(anchorElement, event.target as Node)) return false;
+ return true;
+ },
+ });
return props;
}
@@ -140,32 +99,15 @@ if (process.env.NODE_ENV !== "production") {
}
export interface TooltipOptions
- extends DisclosureContentOptions,
- Omit, "preserveTabOrder"> {
+ extends HovercardOptions {
/**
* Object returned by the `useTooltipStore` hook.
*/
store: TooltipStore;
- /**
- * Determines whether the tooltip will be hidden when the user presses the
- * Escape key.
- * @default true
- */
- hideOnEscape?: BooleanOrCallback;
- /**
- * Determines whether the tooltip will be hidden when the user presses the
- * Control key. This has been proposed as an alternative to the Escape key,
- * which may introduce some issues, especially when tooltips are used within
- * dialogs that also hide on Escape. See
- * https://github.com/w3c/aria-practices/issues/1506
- * @default false
- */
- hideOnControl?: BooleanOrCallback;
- /**
- * Props that will be passed to the popover wrapper element. This element
- * will be used to position the popover.
- */
- wrapperProps?: HTMLAttributes;
+ /** @default true */
+ portal?: HovercardOptions["portal"];
+ /** @default 8 */
+ gutter?: HovercardOptions["gutter"];
}
export type TooltipProps = Props>;
diff --git a/packages/ariakit-react/package.json b/packages/ariakit-react/package.json
index a918d48f3e..7e2d4e721b 100644
--- a/packages/ariakit-react/package.json
+++ b/packages/ariakit-react/package.json
@@ -37,9 +37,7 @@
"components"
],
"dependencies": {
- "@ariakit/core": "0.1.5",
- "@ariakit/react-core": "0.1.8",
- "@floating-ui/dom": "^1.0.0"
+ "@ariakit/react-core": "0.1.8"
},
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0",
diff --git a/playwright.config.ts b/playwright.config.ts
index 796acf7d63..b783833359 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -14,7 +14,7 @@ export default defineConfig({
forbidOnly: CI,
reportSlowTests: null,
reporter: CI ? [["github"], ["dot"]] : [["list"]],
- retries: 1,
+ retries: CI ? 1 : 0,
webServer: {
command: "npm start",
reuseExistingServer: !CI,
@@ -41,7 +41,7 @@ export default defineConfig({
{
name: "firefox",
testMatch: [/\/test[^\/]*\-firefox/, /\/test[^\/]*\-browser/],
- retries: 2,
+ retries: CI ? 2 : 0,
use: devices["Desktop Firefox"],
},
{
@@ -49,5 +49,15 @@ export default defineConfig({
testMatch: [/\/test[^\/]*\-safari/, /\/test[^\/]*\-browser/],
use: devices["Desktop Safari"],
},
+ {
+ name: "ios",
+ testMatch: [/\/test[^\/]*\-ios/, /\/test[^\/]*\-mobile/],
+ use: devices["iPhone 13 Pro Max"],
+ },
+ {
+ name: "android",
+ testMatch: [/\/test[^\/]*\-android/, /\/test[^\/]*\-mobile/],
+ use: devices["Pixel 5"],
+ },
],
});
diff --git a/website/app/(main)/[category]/[page]/table-of-contents.tsx b/website/app/(main)/[category]/[page]/table-of-contents.tsx
index 74c989e3bb..0ddbf8efea 100644
--- a/website/app/(main)/[category]/[page]/table-of-contents.tsx
+++ b/website/app/(main)/[category]/[page]/table-of-contents.tsx
@@ -93,11 +93,7 @@ function getDataIds(data: Data): string[] {
export function TableOfContents({ data }: Props) {
const isLarge = useMedia("(min-width: 768px)", true);
- const popover = Ariakit.usePopoverStore({
- fixed: true,
- gutter: 8,
- overflowPadding: 12,
- });
+ const popover = Ariakit.usePopoverStore();
const [activeId, setActiveId] = useState(null);
const ref = useRef(null);
@@ -181,8 +177,11 @@ export function TableOfContents({ data }: Props) {
store={popover}
as={Popup}
portal
- className={style.popover}
+ fixed
tabIndex={0}
+ gutter={8}
+ overflowPadding={12}
+ className={style.popover}
>
Table of Contents
diff --git a/website/app/previews/[page]/page.tsx b/website/app/previews/[page]/page.tsx
index c95ade3be6..1b6d05c5cd 100644
--- a/website/app/previews/[page]/page.tsx
+++ b/website/app/previews/[page]/page.tsx
@@ -7,7 +7,7 @@ import { getPageName } from "build-pages/get-page-name.js";
import { getPageSourceFiles } from "build-pages/get-page-source-files.js";
import pagesIndex from "build-pages/index.js";
import { parseCSSFile } from "build-pages/parse-css-file.js";
-import { Preview } from "components/preview.js";
+import { Preview } from "components/preview.jsx";
import { notFound } from "next/navigation.js";
import { getNextPageMetadata } from "utils/get-next-page-metadata.js";
import { tw } from "utils/tw.js";
diff --git a/website/components/header-menu.tsx b/website/components/header-menu.tsx
index 2ef92f71b6..92a389fa52 100644
--- a/website/components/header-menu.tsx
+++ b/website/components/header-menu.tsx
@@ -235,10 +235,8 @@ export const HeaderMenu = forwardRef(
const popoverRef = useRef(null);
const parent = useContext(ParentContext);
const combobox = useComboboxStore({
- fitViewport: true,
- focusLoop: false,
includesBaseElement: false,
- gutter: 4,
+ focusLoop: false,
open,
setOpen: (open) => {
if (onToggle) {
@@ -257,15 +255,7 @@ export const HeaderMenu = forwardRef(
});
const menu = useMenuStore({
combobox,
- fixed: true,
placement: parent ? "right-start" : "bottom-start",
- getAnchorRect: (anchor) => {
- if (parent?.current) {
- return parent.current.getBoundingClientRect();
- }
- if (!anchor) return null;
- return anchor.getBoundingClientRect();
- },
});
const idle = useIdle();
@@ -418,6 +408,9 @@ export const HeaderMenu = forwardRef(
store={select}
typeahead={!searchable}
composite={!searchable}
+ fixed
+ fitViewport
+ gutter={4}
>
{(props) => (
(
{
+ if (parent?.current) {
+ return parent.current.getBoundingClientRect();
+ }
+ if (!anchor) return null;
+ return anchor.getBoundingClientRect();
+ }}
>
{renderPopover}
diff --git a/website/components/header-version-select.tsx b/website/components/header-version-select.tsx
index 665da73ebc..c68621ba5a 100644
--- a/website/components/header-version-select.tsx
+++ b/website/components/header-version-select.tsx
@@ -106,11 +106,7 @@ interface Props {
}
export function HeaderVersionSelect({ versions }: Props) {
- const select = useSelectStore({
- defaultValue: getValueFromPkg(pkg),
- gutter: 4,
- shift: -1,
- });
+ const select = useSelectStore({ defaultValue: getValueFromPkg(pkg) });
const renderItem = (value: string, tag: string) => {
const { version } = getPkgFromValue(value);
@@ -143,7 +139,13 @@ export function HeaderVersionSelect({ versions }: Props) {
{selectMounted && (
-
+
{Object.entries(versions).map(([name, tags]) => (
diff --git a/website/components/playground-toolbar.tsx b/website/components/playground-toolbar.tsx
index 3fa79ddfa4..26d535e540 100644
--- a/website/components/playground-toolbar.tsx
+++ b/website/components/playground-toolbar.tsx
@@ -92,12 +92,11 @@ export function PlaygroundToolbar({
value: language,
setValue: setLanguage,
placement: "bottom-start",
- shift: -6,
});
const isJS = select.useState((state) => state.value === "js");
- const menu = useMenuStore({ shift: -6 });
+ const menu = useMenuStore();
const [firstFile] = Object.keys(files);
const isAppDir =
@@ -138,7 +137,13 @@ export function PlaygroundToolbar({
)}
-
+
Language
TypeScript
@@ -162,13 +167,7 @@ export function PlaygroundToolbar({
)}
-
+
{previewLink && (
export const TooltipButton = createComponent(
({ title, tooltipProps, fixed, ...props }) => {
- const tooltip = useTooltipStore({ timeout: 500, fixed });
+ const tooltip = useTooltipStore();
const mounted = tooltip.useState("mounted");
return (
<>
@@ -31,9 +31,10 @@ export const TooltipButton = createComponent(
{mounted && (