diff --git a/.changeset/chat-input-bar.md b/.changeset/chat-input-bar.md
new file mode 100644
index 0000000000..3c6a027a5a
--- /dev/null
+++ b/.changeset/chat-input-bar.md
@@ -0,0 +1,10 @@
+---
+'@lg-chat/input-bar': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): `InputBar` renders results menu in top layer using popover API. As a result, the following props are deprecated and removed:
+- `portalClassName`
+- `portalContainer`
+- `portalRef`
+- `scrollContainer`
+- `usePortal`
diff --git a/.changeset/chip.md b/.changeset/chip.md
new file mode 100644
index 0000000000..6b675aac4a
--- /dev/null
+++ b/.changeset/chip.md
@@ -0,0 +1,17 @@
+---
+'@leafygreen-ui/chip': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): Removes `popoverZIndex` prop because the `InlineDefinition` component instance will now render in the top layer
+
+#### Migration guide
+
+##### Old
+```js
+
+```
+
+##### New
+```js
+
+```
diff --git a/.changeset/cli.md b/.changeset/cli.md
new file mode 100644
index 0000000000..ddc73f0dc0
--- /dev/null
+++ b/.changeset/cli.md
@@ -0,0 +1,5 @@
+---
+'@lg-tools/cli': minor
+---
+
+Adds `--packages` flag to `lg codemod` command. Passing in this flag will specify which package names should be filtered for in a given codemod.
diff --git a/.changeset/code.md b/.changeset/code.md
new file mode 100644
index 0000000000..3b86a08e18
--- /dev/null
+++ b/.changeset/code.md
@@ -0,0 +1,26 @@
+---
+'@leafygreen-ui/code': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): `Code` renders the copy button tooltip and language selector in the top layer using popover API. As a result, the following props are removed:
+- `popoverZIndex`
+- `portalClassName`
+- `portalContainer`
+- `scrollContainer`
+- `usePortal`
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/.changeset/codemods.md b/.changeset/codemods.md
new file mode 100644
index 0000000000..65651c2c78
--- /dev/null
+++ b/.changeset/codemods.md
@@ -0,0 +1,42 @@
+---
+'@lg-tools/codemods': minor
+---
+
+[LG-4525](https://jira.mongodb.org/browse/LG-4525) Adds `popover-v12` codemod which can be used to refactor popover component instances. Users can filter for specific packages using the `--packages` flag.
+
+This codemod does the following:
+
+1. Adds an explicit `usePortal={true}` declaration if left undefined and consolidates the `usePortal` and `renderMode` props into a single `renderMode` prop for components in the following packages:
+
+- `@leafygreen-ui/combobox`
+- `@leafygreen-ui/menu`
+- `@leafygreen-ui/popover`
+- `@leafygreen-ui/select`
+- `@leafygreen-ui/split-button`
+- `@leafygreen-ui/tooltip`
+
+2. Removes `popoverZIndex`, `portalClassName`, `portalContainer`, `portalRef`, `scrollContainer`, and `usePortal` props from the following components:
+
+- `@leafygreen-ui/info-sprinkle`
+- `@leafygreen-ui/inline-definition`
+- `@leafygreen-ui/number-input`
+
+3. Removes `popoverZIndex`, `portalClassName`, `portalContainer`, `portalRef`, and `scrollContainer` props from the following components:
+
+- `@leafygreen-ui/date-picker`
+- `@leafygreen-ui/guide-cue`
+
+4. Removes `popoverZIndex`, `portalClassName`, `portalContainer`, `scrollContainer`, and `usePortal` props from `Code` component in the `@leafygreen-ui/code` package
+
+5. Removes `portalClassName`, `portalContainer`, `portalRef`, `scrollContainer`, and `usePortal` props from `SearchInput` component in the `@leafygreen-ui/search-input` package
+
+6. Removes `shouldTooltipUsePortal` prop from `Copyable` component in the `@leafygreen-ui/copyable` package
+
+7. Replaces `justify="fit"` prop value with `justify="middle"` for components in the following packages:
+
+- `@leafygreen-ui/date-picker`
+- `@leafygreen-ui/info-sprinkle`
+- `@leafygreen-ui/inline-definition`
+- `@leafygreen-ui/menu`
+- `@leafygreen-ui/popover`
+- `@leafygreen-ui/tooltip`
diff --git a/.changeset/combobox.md b/.changeset/combobox.md
new file mode 100644
index 0000000000..84e23684d8
--- /dev/null
+++ b/.changeset/combobox.md
@@ -0,0 +1,23 @@
+---
+'@leafygreen-ui/combobox': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): Replaces `usePortal` prop with `renderMode` prop. `renderMode="inline"` and `renderMode="portal"` are deprecated, and all popover elements should migrate to using the top layer. The old default was `usePortal=true`, and the new default is `renderMode="top-layer"`.
+
+See [@leafygreen-ui/popover package 12.0.0 changelog](https://github.com/mongodb/leafygreen-ui/blob/main/packages/popover/CHANGELOG.md#1200) for more info.
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/.changeset/copyable.md b/.changeset/copyable.md
new file mode 100644
index 0000000000..542d0c8a41
--- /dev/null
+++ b/.changeset/copyable.md
@@ -0,0 +1,21 @@
+---
+'@leafygreen-ui/copyable': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): `Copyable` renders tooltip in the top layer using popover API. As a result, the `shouldTooltipUsePortal` prop is removed
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/.changeset/date-picker.md b/.changeset/date-picker.md
new file mode 100644
index 0000000000..67a596bcab
--- /dev/null
+++ b/.changeset/date-picker.md
@@ -0,0 +1,30 @@
+---
+'@leafygreen-ui/date-picker': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): `DatePicker` renders menu, month selector, and year selector in top layer using popover API. As a result, the following props are deprecated and removed:
+- `popoverZIndex`
+- `portalClassName`
+- `portalContainer`
+- `portalRef`
+- `scrollContainer`
+
+Additional changes include:
+- Deprecates and removes `justify="fit"`. Instead, use `justify="middle"`
+- Removes unused `contentClassName` prop
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/.changeset/guide-cue.md b/.changeset/guide-cue.md
new file mode 100644
index 0000000000..de58a04927
--- /dev/null
+++ b/.changeset/guide-cue.md
@@ -0,0 +1,26 @@
+---
+'@leafygreen-ui/guide-cue': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): `GuideCue` renders beacon and tooltip using popover API. As a result, the following props are removed:
+- `popoverZIndex`
+- `portalClassName`
+- `portalContainer`
+- `portalRef`
+- `scrollContainer`
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/.changeset/hooks.md b/.changeset/hooks.md
new file mode 100644
index 0000000000..00024ecfb8
--- /dev/null
+++ b/.changeset/hooks.md
@@ -0,0 +1,5 @@
+---
+'@leafygreen-ui/hooks': minor
+---
+
+Add `useMergeRefs` hook for merging array of refs into a single memoized callback ref or `null`
diff --git a/.changeset/info-sprinkle.md b/.changeset/info-sprinkle.md
new file mode 100644
index 0000000000..a92a6ce9d4
--- /dev/null
+++ b/.changeset/info-sprinkle.md
@@ -0,0 +1,31 @@
+---
+'@leafygreen-ui/info-sprinkle': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): `InfoSprinkle` renders tooltip in the top layer using popover API. As a result, the following props are removed:
+- `popoverZIndex`
+- `portalClassName`
+- `portalContainer`
+- `portalRef`
+- `scrollContainer`
+- `usePortal`
+
+Additional changes include:
+- Deprecates and removes `justify="fit"`. Instead, use `justify="middle"`
+- Opens tooltip immediately on hover instead of default 500ms delay
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/.changeset/inline-definition.md b/.changeset/inline-definition.md
new file mode 100644
index 0000000000..df57fd05bb
--- /dev/null
+++ b/.changeset/inline-definition.md
@@ -0,0 +1,32 @@
+---
+'@leafygreen-ui/inline-definition': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): `InlineDefinition` renders tooltip in the top layer using popover API. As a result, the following props are removed:
+- `popoverZIndex`
+- `portalClassName`
+- `portalContainer`
+- `portalRef`
+- `scrollContainer`
+- `usePortal`
+
+Additional changes include:
+- Deprecates and removes `justify="fit"`. Instead, use `justify="middle"`
+- Opens tooltip immediately on hover instead of default 500ms delay
+- Reorganizes file structure
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/.changeset/lg-provider.md b/.changeset/lg-provider.md
new file mode 100644
index 0000000000..d2106eb9d6
--- /dev/null
+++ b/.changeset/lg-provider.md
@@ -0,0 +1,7 @@
+---
+'@leafygreen-ui/leafygreen-provider': minor
+---
+
+[LG-4446](https://jira.mongodb.org/browse/LG-4446): Adds `PopoverPropsContext` to pass props to a deeply nested popover element
+
+Additionally exposes a `forceUseTopLayer` prop in the `LeafyGreenProvider` which can be used to test interactions with all LG popover elements forcibly set to `renderMode="top-layer"`. This can help pressure test for any regressions to more confidently and safely migrate. However, this should only be used when all LG dependencies are relying on v12+ of `@leafygreen-ui/popover`.
diff --git a/.changeset/menu.md b/.changeset/menu.md
new file mode 100644
index 0000000000..68b1a325d2
--- /dev/null
+++ b/.changeset/menu.md
@@ -0,0 +1,26 @@
+---
+'@leafygreen-ui/menu': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): Replaces `usePortal` prop with `renderMode` prop. `renderMode="inline"` and `renderMode="portal"` are deprecated, and all popover elements should migrate to using the top layer. The old default was `usePortal=true`, and the new default is `renderMode="top-layer"`.
+
+See [@leafygreen-ui/popover package 12.0.0 changelog](https://github.com/mongodb/leafygreen-ui/blob/main/packages/popover/CHANGELOG.md#1200) for more info.
+
+Additional changes include:
+- Deprecates and removes `justify="fit"`. Instead, use `justify="middle"`
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/.changeset/number-input.md b/.changeset/number-input.md
new file mode 100644
index 0000000000..d7341019f3
--- /dev/null
+++ b/.changeset/number-input.md
@@ -0,0 +1,30 @@
+---
+'@leafygreen-ui/number-input': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): `NumberInput` renders unit selector and tooltip in the top layer using popover API. As a result, the following props are removed:
+- `popoverZIndex`
+- `portalClassName`
+- `portalContainer`
+- `portalRef`
+- `scrollContainer`
+- `usePortal`
+
+Additional changes include:
+- Opens tooltip immediately on hover instead of default 500ms delay
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/.changeset/odd-llamas-work.md b/.changeset/odd-llamas-work.md
new file mode 100644
index 0000000000..fdcfbb9e0c
--- /dev/null
+++ b/.changeset/odd-llamas-work.md
@@ -0,0 +1,5 @@
+---
+'@lg-tools/validate': minor
+---
+
+Updates `ignoreFilePatterns` and `depcheckOptions.ignorePatterns` to exclude validating dependencies in `*.input.*` and `*.output.*` files in `tools/codemods` directory
diff --git a/.changeset/pagination.md b/.changeset/pagination.md
new file mode 100644
index 0000000000..3fd591786a
--- /dev/null
+++ b/.changeset/pagination.md
@@ -0,0 +1,5 @@
+---
+'@leafygreen-ui/pagination': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): `Pagination` renders page selectors in the top layer using popover API
diff --git a/.changeset/pipeline.md b/.changeset/pipeline.md
new file mode 100644
index 0000000000..dced02c2da
--- /dev/null
+++ b/.changeset/pipeline.md
@@ -0,0 +1,7 @@
+---
+'@leafygreen-ui/pipeline': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): `Pipeline` renders tooltip in the top layer using popover API
+
+Additionally, the tooltip opens immediately on hover instead of default 500ms delay
diff --git a/.changeset/popover.md b/.changeset/popover.md
new file mode 100644
index 0000000000..b9682641b8
--- /dev/null
+++ b/.changeset/popover.md
@@ -0,0 +1,69 @@
+---
+'@leafygreen-ui/popover': major
+---
+
+[LG-4445](https://jira.mongodb.org/browse/LG-4445): Replaces `usePortal` prop with `renderMode` prop with values of `'inline'`, `'portal'`, and `'top-layer'`. `renderMode="inline"` and `renderMode="portal"` are deprecated, and all popover elements should migrate to using the top layer. The old default was `usePortal=true`, and the new default is `renderMode="top-layer"`.
+ - When `renderMode="top-layer"` or `renderMode` is `undefined`, the popover element will render in the top layer using the [popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API)
+ - Adds `dismissMode` prop to control dismissal behavior of the popover element. [Read more about the popover attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/popover)
+ - Adds `onToggle` prop to run a callback function when the visibility of a popover element rendered in the top layer is toggled
+ - When `renderMode="inline"`, the popover element will render inline in the DOM where it's written
+ - When `renderMode="portal"`, the popover element will portal into a new div appended to the body. Alternatively, it can be portaled into a provided `portalContainer` element
+
+[LG-4446](https://jira.mongodb.org/browse/LG-4446): The `PopoverPropsProvider` from the `@leafygreen-ui/leafygreen-provider` package can be used to pass props to a deeply nested popover element. It will read `PopoverPropsContext` values if an explicit prop is not defined in the popover component instance. This applies for the following props:
+ - `dismissMode`
+ - `onEnter`
+ - `onEntering`
+ - `onEntered`
+ - `onExit`
+ - `onExiting`
+ - `onExited`
+ - `onToggle`
+ - `popoverZIndex`
+ - `portalClassName`
+ - `portalContainer`
+ - `portalRef`
+ - `renderMode`
+ - `scrollContainer`
+ - `spacing`
+
+Additional changes include:
+- Adds and exports `getPopoverRenderModeProps` util to pick popover props based on given `renderMode` value
+- Deprecates and removes `justify="fit"`. Instead, use `justify="middle"`
+- Removes unused `contentClassName` prop
+- Updates default value of `spacing` prop from 10px to 4px
+- Replaces internal position utils with `@floating-ui/react`
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+
+```
+
+##### New
+```js
+
+
+
+```
+
+##### Globally render popover elements in top layer
+After running the codemod and addressing manual updates, the new `forceUseTopLayer` prop in the `LeafyGreenProvider` can be used to test interactions with all LG popover elements forcibly set to `renderMode="top-layer"`. This can help pressure test for any regressions to more confidently and safely migrate.
+
+```js
+import { Combobox } from '@leafygreen-ui/combobox';
+import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider';
+import Popover from '@leafygreen-ui/popover';
+import { Select } from '@leafygreen-ui/select';
+
+{/* all LG popover elements will render in top layer */}
+
+
+
+
+ ;
+```
diff --git a/.changeset/search-input.md b/.changeset/search-input.md
new file mode 100644
index 0000000000..e892f98dc5
--- /dev/null
+++ b/.changeset/search-input.md
@@ -0,0 +1,26 @@
+---
+'@leafygreen-ui/search-input': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): `SearchInput` renders results menu in the top layer using popover API. As a result, the following props are removed:
+- `portalClassName`
+- `portalContainer`
+- `portalRef`
+- `scrollContainer`
+- `usePortal`
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/.changeset/select.md b/.changeset/select.md
new file mode 100644
index 0000000000..3c279f7fda
--- /dev/null
+++ b/.changeset/select.md
@@ -0,0 +1,23 @@
+---
+'@leafygreen-ui/select': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): Replaces `usePortal` prop with `renderMode` prop. `renderMode="inline"` and `renderMode="portal"` are deprecated, and all popover elements should migrate to using the top layer. The old default was `usePortal=true`, and the new default is `renderMode="top-layer"`.
+
+See [@leafygreen-ui/popover package 12.0.0 changelog](https://github.com/mongodb/leafygreen-ui/blob/main/packages/popover/CHANGELOG.md#1200) for more info.
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/.changeset/side-nav.md b/.changeset/side-nav.md
new file mode 100644
index 0000000000..f6be2b410a
--- /dev/null
+++ b/.changeset/side-nav.md
@@ -0,0 +1,5 @@
+---
+'@leafygreen-ui/side-nav': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): The tooltip for the expand/collapse toggle button renders in the top layer using popover API
diff --git a/.changeset/split-button.md b/.changeset/split-button.md
new file mode 100644
index 0000000000..97a6fdeb87
--- /dev/null
+++ b/.changeset/split-button.md
@@ -0,0 +1,23 @@
+---
+'@leafygreen-ui/split-button': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): Replaces `usePortal` prop with `renderMode` prop with values of `'inline'`, `'portal'`, and `'top-layer'`. `renderMode="inline"` and `renderMode="portal"` are deprecated, and all popover elements should migrate to using the top layer.
+
+See [@leafygreen-ui/menu package 26.0.0 changelog](https://github.com/mongodb/leafygreen-ui/blob/main/packages/menu/CHANGELOG.md#2600) for more info.
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/.changeset/stepper.md b/.changeset/stepper.md
new file mode 100644
index 0000000000..3ff9aeeb95
--- /dev/null
+++ b/.changeset/stepper.md
@@ -0,0 +1,7 @@
+---
+'@leafygreen-ui/stepper': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): The tooltip for the ellipses step renders in the top layer using popover API
+
+Additionally, the tooltip opens immediately on hover instead of default 500ms delay
diff --git a/.changeset/tooltip.md b/.changeset/tooltip.md
new file mode 100644
index 0000000000..c0c2c5a27c
--- /dev/null
+++ b/.changeset/tooltip.md
@@ -0,0 +1,26 @@
+---
+'@leafygreen-ui/tooltip': major
+---
+
+[LG-4121](https://jira.mongodb.org/browse/LG-4121): Replaces `usePortal` prop with `renderMode` prop. `renderMode="inline"` and `renderMode="portal"` are deprecated, and all popover elements should migrate to using the top layer. The old default was `usePortal=true`, and the new default is `renderMode="top-layer"`.
+
+See [@leafygreen-ui/popover package 12.0.0 changelog](https://github.com/mongodb/leafygreen-ui/blob/main/packages/popover/CHANGELOG.md#1200) for more info.
+
+Additional changes include:
+- Deprecates and removes `justify="fit"`. Instead, use `justify="middle"`
+
+#### Migration guide
+
+Use [popover-v12 codemod](https://github.com/mongodb/leafygreen-ui/tree/main/tools/codemods#popover-v12) for migration assistance.
+
+##### Old
+```js
+
+
+```
+
+##### New
+```js
+
+
+```
diff --git a/chat/fixed-chat-window/src/FixedChatWindow.stories.tsx b/chat/fixed-chat-window/src/FixedChatWindow.stories.tsx
index e66891405f..541a538e57 100644
--- a/chat/fixed-chat-window/src/FixedChatWindow.stories.tsx
+++ b/chat/fixed-chat-window/src/FixedChatWindow.stories.tsx
@@ -22,6 +22,19 @@ const meta: StoryMetaType = {
parameters: {
default: 'Uncontrolled',
},
+ decorators: [
+ StoryFn => (
+
+
+
+ ),
+ ],
};
export default meta;
diff --git a/chat/input-bar/src/InputBar/InputBar.types.ts b/chat/input-bar/src/InputBar/InputBar.types.ts
index 9cd8ad3874..6f03fc6f5e 100644
--- a/chat/input-bar/src/InputBar/InputBar.types.ts
+++ b/chat/input-bar/src/InputBar/InputBar.types.ts
@@ -2,7 +2,7 @@ import { FormEvent, ReactElement } from 'react';
import { TextareaAutosizeProps } from 'react-textarea-autosize';
import { DarkModeProps, HTMLElementProps } from '@leafygreen-ui/lib';
-import { PortalControlProps } from '@leafygreen-ui/popover';
+import { PopoverRenderModeProps } from '@leafygreen-ui/popover';
export type InputBarProps = HTMLElementProps<'form'> &
DarkModeProps & {
@@ -46,9 +46,18 @@ export type InputBarProps = HTMLElementProps<'form'> &
dropdownFooterSlot?: ReactElement;
/**
- * Props passed to the Popover that renders the suggested promps.
- */
- dropdownProps?: PortalControlProps;
+ * Props passed to the Popover that renders the suggested prompts.
+ */
+ dropdownProps?: Omit<
+ PopoverRenderModeProps,
+ | 'dismissMode'
+ | 'onToggle'
+ | 'portalClassName'
+ | 'portalContainer'
+ | 'portalRef'
+ | 'renderMode'
+ | 'scrollContainer'
+ >;
};
export type { TextareaAutosizeProps };
diff --git a/packages/chip/README.md b/packages/chip/README.md
index e626376e61..eb7cc40d16 100644
--- a/packages/chip/README.md
+++ b/packages/chip/README.md
@@ -33,7 +33,6 @@ or
baseFontSize={13}
disabled
onDismiss={() => {}}
- popoverZIndex={1}
chipCharacterLimit={10}
chipTruncationLocation="end"
dismissButtonAriaLabel="aria-label"
@@ -49,7 +48,6 @@ or
| `label` | `React.ReactNode` | Label rendered in the chip | |
| `chipTruncationLocation` | `'end'` \| `'middle'` \| `'none'` \| `'start'` | Defines where the ellipses will appear in a Chip when the label length exceeds the `chipCharacterLimit`. If `none` is passed, the chip will not truncate. **Note**: If there is any truncation, the full label text will appear inside a tooltip on hover | `none` |
| `chipCharacterLimit` | `number` | Defines the character limit of a Chip before they start truncating. **Note**: the three ellipses dots are included in the character limit and the chip will only truncate if the chip length is greater than the `chipCharacterLimit`. | |
-| `popoverZIndex` | `number` | Number that controls the z-index of the tooltip containing the full label text. | |
| `baseFontSize` | `'13'` \| `'16'` | Determines the base font-size of the chip. | |
| `variant` | `'gray'` \| `'blue'` \| `'green'` \| `'purple'` \| `'red'` \| `'yellow'` \| `'blue'` | The color of the chip. | |
| `glyph` | `React.ReactElement` | An icon glyph rendered before the text. To use a custom icon, see [Link](https://github.com/mongodb/leafygreen-ui/blob/main/packages/icon/README.md#usage-registering-custom-icon-sets) docs | |
diff --git a/packages/chip/src/Chip/Chip.spec.tsx b/packages/chip/src/Chip/Chip.spec.tsx
index c06de541d9..706db75e12 100644
--- a/packages/chip/src/Chip/Chip.spec.tsx
+++ b/packages/chip/src/Chip/Chip.spec.tsx
@@ -353,7 +353,6 @@ describe('packages/chip', () => {
baseFontSize={13}
disabled
onDismiss={() => {}}
- popoverZIndex={1}
chipCharacterLimit={10}
chipTruncationLocation="end"
dismissButtonAriaLabel="deselect"
diff --git a/packages/chip/src/Chip/Chip.styles.ts b/packages/chip/src/Chip/Chip.styles.ts
index 99f6f81792..caa4a47248 100644
--- a/packages/chip/src/Chip/Chip.styles.ts
+++ b/packages/chip/src/Chip/Chip.styles.ts
@@ -329,3 +329,7 @@ export const getTextStyles = (
[textDisabledStyles(theme)]: isDisabled,
[textDismissibleStyles]: isDismissible,
});
+
+export const inlineDefinitionStyles = css`
+ white-space: normal;
+`;
diff --git a/packages/chip/src/Chip/Chip.tsx b/packages/chip/src/Chip/Chip.tsx
index 858a7513ae..999eb069b8 100644
--- a/packages/chip/src/Chip/Chip.tsx
+++ b/packages/chip/src/Chip/Chip.tsx
@@ -14,6 +14,7 @@ import {
chipTextClassName,
getTextStyles,
getWrapperStyles,
+ inlineDefinitionStyles,
} from './Chip.styles';
import { ChipProps, TruncationLocation, Variant } from './Chip.types';
@@ -28,7 +29,6 @@ export const Chip = React.forwardRef(
darkMode: darkModeProp,
label,
onDismiss,
- popoverZIndex,
className,
dismissButtonAriaLabel,
glyph,
@@ -82,8 +82,8 @@ export const Chip = React.forwardRef(
darkMode={darkMode}
definition={label}
align="bottom"
- popoverZIndex={popoverZIndex}
className={chipInlineDefinitionClassName}
+ tooltipClassName={inlineDefinitionStyles}
>
{truncatedName}
@@ -111,7 +111,6 @@ Chip.propTypes = {
label: PropTypes.string.isRequired,
chipCharacterLimit: PropTypes.number,
chipTruncationLocation: PropTypes.oneOf(Object.values(TruncationLocation)),
- popoverZIndex: PropTypes.number,
baseFontSize: PropTypes.oneOf(Object.values(BaseFontSize)),
variant: PropTypes.oneOf(Object.values(Variant)),
onDismiss: PropTypes.func,
diff --git a/packages/chip/src/Chip/Chip.types.ts b/packages/chip/src/Chip/Chip.types.ts
index 4c60a8315d..a2c903b164 100644
--- a/packages/chip/src/Chip/Chip.types.ts
+++ b/packages/chip/src/Chip/Chip.types.ts
@@ -43,11 +43,6 @@ export interface ChipProps
*/
chipCharacterLimit?: number;
- /**
- * Number that controls the z-index of the tooltip containing the full label text.
- */
- popoverZIndex?: number;
-
/**
* Determines the base font-size of the component
*
diff --git a/packages/code/README.md b/packages/code/README.md
index ce67888136..34e0383c74 100644
--- a/packages/code/README.md
+++ b/packages/code/README.md
@@ -119,11 +119,6 @@ const SomeComponent = () => {codeSnippet}
;
| `highlightLines` | `Array` | An optional array of lines to highlight. The array can only contain numbers corresponding to the line numbers to highlight, and / or tuples representing a range (e.g. `[6, 10]`); | |
| `languageOptions` | `Array` (see below) | An array of language options. When provided, a LanguageSwitcher dropdown is rendered. | |
| `onChange` | `(language: LanguageOption) => void` | A change handler triggered when the language is changed. Invalid when no `languageOptions` are provided | |
-| `usePortal` | `boolean` | Will position the language switcher dropdown relative to its parent without using a Portal if `usePortal` is set to false. | `true` |
-| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the language switcher's dropdown's portal. | |
-| `scrollContainer` | `HTMLElement` \| `null` | If the language switcher's dropdown's portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. | |
-| `portalClassName` | `string` | Passes the given className to the language switcher's dropdown's portal container if the default portal container is being used. | |
-| `popoverZIndex` | `number` | Sets the z-index CSS property for the language switcher's dropdown. | |
```
interface LanguageOption {
diff --git a/packages/code/src/Code/Code.tsx b/packages/code/src/Code/Code.tsx
index 062054e4db..a57c262bbe 100644
--- a/packages/code/src/Code/Code.tsx
+++ b/packages/code/src/Code/Code.tsx
@@ -82,11 +82,6 @@ function Code({
onChange,
customActionButtons = [],
showCustomActionButtons = false,
- usePortal = true,
- portalClassName,
- portalContainer,
- scrollContainer,
- popoverZIndex,
...rest
}: CodeProps) {
const scrollableElementRef = useRef(null);
@@ -205,18 +200,6 @@ function Code({
debounceScroll(e);
};
- const popoverProps = {
- popoverZIndex,
- ...(usePortal
- ? {
- usePortal,
- portalClassName,
- portalContainer,
- scrollContainer,
- }
- : { usePortal }),
- } as const;
-
const showExpandButton = !!(
expandable &&
numOfLinesOfCode &&
@@ -286,7 +269,6 @@ function Code({
isMultiline={isMultiline}
customActionButtons={filteredCustomActionIconButtons}
showCustomActionButtons={showCustomActionsInPanel}
- {...popoverProps}
/>
)}
diff --git a/packages/code/src/CopyButton/CopyButton.styles.ts b/packages/code/src/CopyButton/CopyButton.styles.ts
index 381fd9d61c..67b845f29c 100644
--- a/packages/code/src/CopyButton/CopyButton.styles.ts
+++ b/packages/code/src/CopyButton/CopyButton.styles.ts
@@ -2,6 +2,13 @@ import { css } from '@leafygreen-ui/emotion';
import { Theme } from '@leafygreen-ui/lib';
import { palette } from '@leafygreen-ui/palette';
+export const tooltipStyles = css`
+ svg {
+ width: 26px;
+ height: 26px;
+ }
+`;
+
export const copiedThemeStyle: Record = {
[Theme.Light]: css`
color: ${palette.white};
diff --git a/packages/code/src/CopyButton/CopyButton.tsx b/packages/code/src/CopyButton/CopyButton.tsx
index 02a4362d73..adeb86f111 100644
--- a/packages/code/src/CopyButton/CopyButton.tsx
+++ b/packages/code/src/CopyButton/CopyButton.tsx
@@ -12,34 +12,44 @@ import {
usePopoverPortalContainer,
} from '@leafygreen-ui/leafygreen-provider';
import { keyMap } from '@leafygreen-ui/lib';
-import Tooltip, { Align, Justify } from '@leafygreen-ui/tooltip';
+import Tooltip, {
+ Align,
+ hoverDelay,
+ Justify,
+ RenderMode,
+} from '@leafygreen-ui/tooltip';
import { COPIED_SUCCESS_DURATION, COPIED_TEXT, COPY_TEXT } from './constants';
-import { copiedThemeStyle, copyButtonThemeStyles } from './CopyButton.styles';
+import {
+ copiedThemeStyle,
+ copyButtonThemeStyles,
+ tooltipStyles,
+} from './CopyButton.styles';
import { CopyProps } from './CopyButton.types';
function CopyButton({ onCopy, contents }: CopyProps) {
const [copied, setCopied] = useState(false);
/**
- * `CopyButton` controls `open` state of tooltip because when `copied` state
+ * `CopyButton` controls `tooltipOpen` state because when `copied` state
* changes, it causes the tooltip to re-render
*/
- const [open, setOpen] = useState(false);
+ const [tooltipOpen, setTooltipOpen] = useState(false);
const buttonRef = useRef(null);
+ const timeoutRef = useRef(null);
const { theme } = useDarkMode();
const { portalContainer } = usePopoverPortalContainer();
/**
- * toggles `open` state of tooltip
+ * toggles `tooltipOpen` state
*/
- const closeTooltip = () => setOpen(false);
- const openTooltip = () => setOpen(true);
+ const closeTooltip = () => setTooltipOpen(false);
+ const openTooltip = () => setTooltipOpen(true);
/**
* forcibly closes tooltip if user tabs focus on tooltip and clicks
* outside of the trigger
*/
- useBackdropClick(closeTooltip, buttonRef, open);
+ useBackdropClick(closeTooltip, buttonRef, tooltipOpen);
useEffect(() => {
if (!buttonRef.current) {
@@ -90,14 +100,20 @@ function CopyButton({ onCopy, contents }: CopyProps) {
};
/**
- * `handleMouseEnter` and `handleMouseLeave` are used to control `open`
+ * `handleMouseEnter` and `handleMouseLeave` are used to control `tooltipOpen`
* state when mouse hovers over tooltip trigger
*/
const handleMouseEnter = () => {
- openTooltip();
+ timeoutRef.current = setTimeout(() => {
+ openTooltip();
+ }, hoverDelay);
};
const handleMouseLeave = () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
closeTooltip();
};
@@ -105,15 +121,17 @@ function CopyButton({ onCopy, contents }: CopyProps) {
* `shouldClose` indicates to `Tooltip` component that tooltip should
* remain open even if trigger re-renders
*/
- const shouldClose = () => !open;
+ const shouldClose = () => !tooltipOpen;
return (
;
onChange: (arg0: LanguageOption) => void;
}
-function LanguageSwitcher({
- language,
- languageOptions,
- onChange,
- usePortal,
- portalClassName,
- portalContainer,
- scrollContainer,
- popoverZIndex,
-}: Props) {
+function LanguageSwitcher({ language, languageOptions, onChange }: Props) {
const { theme, darkMode } = useDarkMode();
const previousLanguage = usePrevious(language);
@@ -83,14 +74,6 @@ function LanguageSwitcher({
}
}
- const popoverProps = {
- popoverZIndex,
- usePortal,
- portalClassName,
- portalContainer,
- scrollContainer,
- } as const;
-
return (
> & {
customActionButtons?: Array;
showCustomActionButtons?: boolean;
className?: string;
-} & PopoverProps;
+};
function Panel({
language,
@@ -38,23 +37,10 @@ function Panel({
showCopyButton,
customActionButtons,
showCustomActionButtons,
- usePortal,
- portalClassName,
- portalContainer,
- scrollContainer,
- popoverZIndex,
className,
}: PanelProps) {
const { theme } = useDarkMode();
- const popoverProps = {
- popoverZIndex,
- usePortal,
- portalClassName,
- portalContainer,
- scrollContainer,
- } as const;
-
return (
)}
diff --git a/packages/code/src/types.ts b/packages/code/src/types.ts
index 650c1d10ae..a56164b2c9 100644
--- a/packages/code/src/types.ts
+++ b/packages/code/src/types.ts
@@ -52,36 +52,6 @@ export interface SyntaxProps extends HTMLElementProps<'code'> {
highlightLines?: LineHighlightingDefinition;
}
-export interface PopoverProps {
- /**
- * Specifies that the popover content should be rendered at the end of the DOM,
- * rather than in the DOM tree.
- *
- * default: `true`
- */
- usePortal?: boolean;
-
- /**
- * When usePortal is `true`, specifies a class name to apply to the root element of the portal.
- */
- portalClassName?: string;
-
- /**
- * When usePortal is `true`, specifies an element to portal within. The default behavior is to generate a div at the end of the document to render within.
- */
- portalContainer?: HTMLElement | null;
-
- /**
- * When usePortal is `true`, specifies the scrollable element to position relative to.
- */
- scrollContainer?: HTMLElement | null;
-
- /**
- * Number that controls the z-index of the popover element directly.
- */
- popoverZIndex?: number;
-}
-
export type CodeProps = Omit<
SyntaxProps,
'onCopy' | 'language' | 'onChange'
@@ -160,8 +130,7 @@ export type CodeProps = Omit<
*/
onChange: (arg0: LanguageOption) => void;
}
- ) &
- PopoverProps;
+ );
export interface LanguageOption {
displayName: string;
@@ -169,7 +138,7 @@ export interface LanguageOption {
image?: React.ReactElement;
}
-export interface LanguageSwitcher extends PopoverProps {
+export interface LanguageSwitcher {
onChange: (arg0: LanguageOption) => void;
language: LanguageOption['displayName'];
languageOptions: Array;
diff --git a/packages/combobox/README.md b/packages/combobox/README.md
index ee02b08710..5d25d53301 100644
--- a/packages/combobox/README.md
+++ b/packages/combobox/README.md
@@ -78,7 +78,7 @@ import { Combobox, ComboboxOption } from '@leafygreen-ui/combobox';
## Properties
| Prop | Type | Description | Default |
-| ------------------------ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
+| ------------------------ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------ |
| `children` | ``, `` | Define the Combobox Options by passing children | |
| `multiselect` | `boolean` | Defines whether a user can select multiple options, or only a single option. When using TypeScript, `multiselect` affects the valid values of `initialValue`, `value`, and `onChange` | `false` |
| `initialValue` | `Array`, `string` | The initial selection. Must be a string (or array of strings) that matches the `value` prop of a `ComboboxOption`. Changing the `initialValue` after initial render will not change the selection. | |
@@ -106,7 +106,7 @@ import { Combobox, ComboboxOption } from '@leafygreen-ui/combobox';
| `chipTruncationLocation` | `'start'`, `'middle'`, `'end'`, `'none'` | Defines where the ellipses appear in a Chip when the length exceeds the `chipCharacterLimit` | 'none' |
| `chipCharacterLimit` | `number` | Defined the character limit of a multiselect Chip before they start truncating. Note: the three ellipses dots are included in the character limit. | 12 |
| `className` | `string` | The className passed to the root element of the component. | |
-| `usePortal` | `boolean` | Will position Popover's children relative to its parent without using a Portal, if `usePortal` is set to false. NOTE: The parent element should be CSS position `relative`, `fixed`, or `absolute` if using this option. | `true` |
+| `renderMode` | `'inline'` \| `'portal'` \| `'top-layer'` | Options to render the popover element \* [deprecated] `'inline'` will render the popover element inline in the DOM where it's written \* [deprecated] `'portal'` will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer` \* `'top-layer'` will render the popover element in the top layer | `'top-layer'` | `true` |
| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. | |
| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. | |
| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
diff --git a/packages/combobox/src/Combobox/Combobox.spec.tsx b/packages/combobox/src/Combobox/Combobox.spec.tsx
index 792af32979..f88ccecd08 100644
--- a/packages/combobox/src/Combobox/Combobox.spec.tsx
+++ b/packages/combobox/src/Combobox/Combobox.spec.tsx
@@ -16,6 +16,7 @@ import isUndefined from 'lodash/isUndefined';
import Button from '@leafygreen-ui/button';
import { keyMap } from '@leafygreen-ui/lib';
+import { RenderMode } from '@leafygreen-ui/popover';
import { eventContainingTargetValue } from '@leafygreen-ui/testing-lib';
import { OptionObject } from '../ComboboxOption/ComboboxOption.types';
@@ -35,9 +36,12 @@ import {
describe('packages/combobox', () => {
describe('A11y', () => {
test('does not have basic accessibility violations', async () => {
- const { container, openMenu } = renderCombobox();
- openMenu();
+ const { container, openMenu } = renderCombobox('single', {
+ renderMode: 'portal',
+ });
+ const { menuContainerEl } = openMenu();
await waitFor(async () => {
+ expect(menuContainerEl).toBeVisible();
const results = await axe(container);
expect(results).toHaveNoViolations();
});
@@ -74,6 +78,7 @@ describe('packages/combobox', () => {
document.body.appendChild(portalContainer);
const portalRef = createRef();
const { openMenu } = renderCombobox(select, {
+ renderMode: RenderMode.Portal,
portalContainer,
portalRef,
});
@@ -802,7 +807,7 @@ describe('packages/combobox', () => {
});
describe('Click clear button', () => {
- test('Clicking clear all button clears selection', () => {
+ test('Clicking clear all button clears selection', async () => {
const initialValue =
select === 'single' ? 'apple' : ['apple', 'banana', 'carrot'];
const { inputEl, clearButtonEl, queryAllChips } = renderCombobox(
@@ -818,7 +823,7 @@ describe('packages/combobox', () => {
if (select === 'multiple') {
expect(queryAllChips()).toHaveLength(0);
} else {
- expect(inputEl).toHaveValue('');
+ await waitFor(() => expect(inputEl).toHaveValue(''));
}
});
});
diff --git a/packages/combobox/src/Combobox/Combobox.tsx b/packages/combobox/src/Combobox/Combobox.tsx
index c5f31d11a5..4f84dce82c 100644
--- a/packages/combobox/src/Combobox/Combobox.tsx
+++ b/packages/combobox/src/Combobox/Combobox.tsx
@@ -32,9 +32,15 @@ import {
import Icon from '@leafygreen-ui/icon';
import IconButton from '@leafygreen-ui/icon-button';
import LeafyGreenProvider, {
+ PopoverPropsProvider,
useDarkMode,
} from '@leafygreen-ui/leafygreen-provider';
import { consoleOnce, isComponentType, keyMap } from '@leafygreen-ui/lib';
+import {
+ DismissMode,
+ getPopoverRenderModeProps,
+ RenderMode,
+} from '@leafygreen-ui/popover';
import { Description, Label } from '@leafygreen-ui/typography';
import { ComboboxChip } from '../ComboboxChip';
@@ -128,7 +134,7 @@ export function Combobox({
chipTruncationLocation,
chipCharacterLimit = 12,
className,
- usePortal = true,
+ renderMode = RenderMode.TopLayer,
portalClassName,
portalContainer,
portalRef,
@@ -1156,15 +1162,14 @@ export function Combobox({
const popoverProps = {
popoverZIndex,
- ...(usePortal
- ? {
- usePortal,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
- }
- : { usePortal }),
+ ...getPopoverRenderModeProps({
+ dismissMode: DismissMode.Manual,
+ portalClassName,
+ portalContainer,
+ portalRef,
+ renderMode,
+ scrollContainer,
+ }),
} as const;
const formFieldFeedbackProps = {
@@ -1321,19 +1326,20 @@ export function Combobox({
* Menu *
/ *******/}
-
+
+
+
@@ -1426,7 +1432,7 @@ Combobox.propTypes = {
filteredOptions: PropTypes.arrayOf(PropTypes.string),
// Popover Props
popoverZIndex: PropTypes.number,
- usePortal: PropTypes.bool,
+ renderMode: PropTypes.oneOf(Object.values(RenderMode)),
scrollContainer: PropTypes.elementType,
portalContainer: PropTypes.elementType,
portalRef: PropTypes.shape({
diff --git a/packages/combobox/src/Combobox/Combobox.types.ts b/packages/combobox/src/Combobox/Combobox.types.ts
index 25b24f1370..f247537e26 100644
--- a/packages/combobox/src/Combobox/Combobox.types.ts
+++ b/packages/combobox/src/Combobox/Combobox.types.ts
@@ -2,7 +2,7 @@ import React, { ReactNode } from 'react';
import { type ChipProps } from '@leafygreen-ui/chip';
import { Either, HTMLElementProps } from '@leafygreen-ui/lib';
-import { PortalControlProps } from '@leafygreen-ui/popover';
+import { PopoverProps } from '@leafygreen-ui/popover';
import {
ComboboxSize,
@@ -56,11 +56,19 @@ export interface ComboboxMultiselectProps {
type PartialChipProps = Pick<
ChipProps,
- 'chipTruncationLocation' | 'chipCharacterLimit' | 'popoverZIndex'
+ 'chipTruncationLocation' | 'chipCharacterLimit'
>;
export type BaseComboboxProps = Omit, 'onChange'> &
- PortalControlProps &
+ Pick<
+ PopoverProps,
+ | 'popoverZIndex'
+ | 'portalClassName'
+ | 'portalContainer'
+ | 'portalRef'
+ | 'renderMode'
+ | 'scrollContainer'
+ > &
PartialChipProps & {
/**
* Defines the Combobox Options by passing children. Must be `ComboboxOption` or `ComboboxGroup`
diff --git a/packages/combobox/src/ComboboxChip/ComboboxChip.tsx b/packages/combobox/src/ComboboxChip/ComboboxChip.tsx
index 0c5a52828b..b5569ebfd7 100644
--- a/packages/combobox/src/ComboboxChip/ComboboxChip.tsx
+++ b/packages/combobox/src/ComboboxChip/ComboboxChip.tsx
@@ -25,7 +25,6 @@ export const ComboboxChip = React.forwardRef<
overflow,
chipTruncationLocation = TruncationLocation.End,
chipCharacterLimit = 12,
- popoverZIndex,
} = useContext(ComboboxContext);
const updatedChipTruncationLocation =
@@ -80,7 +79,6 @@ export const ComboboxChip = React.forwardRef<
baseFontSize={BaseFontSize.Body1}
chipCharacterLimit={chipCharacterLimit}
chipTruncationLocation={updatedChipTruncationLocation}
- popoverZIndex={popoverZIndex}
variant={Variant.Gray}
ref={chipRef}
disabled={disabled}
diff --git a/packages/combobox/src/ComboboxMenu/ComboboxMenu.tsx b/packages/combobox/src/ComboboxMenu/ComboboxMenu.tsx
index 90c2791865..b70ef432e0 100644
--- a/packages/combobox/src/ComboboxMenu/ComboboxMenu.tsx
+++ b/packages/combobox/src/ComboboxMenu/ComboboxMenu.tsx
@@ -6,7 +6,7 @@ import { useAvailableSpace, useForwardedRef } from '@leafygreen-ui/hooks';
import Icon from '@leafygreen-ui/icon';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
import { palette } from '@leafygreen-ui/palette';
-import Popover, { PortalControlProps } from '@leafygreen-ui/popover';
+import Popover from '@leafygreen-ui/popover';
import { Error } from '@leafygreen-ui/typography';
import { ComboboxProps } from '../Combobox';
@@ -30,14 +30,10 @@ type ComboboxMenuProps = {
id: string;
labelId: string;
menuWidth: number;
-} & PortalControlProps &
- Pick<
- ComboboxProps,
- | 'searchLoadingMessage'
- | 'searchErrorMessage'
- | 'searchEmptyMessage'
- | 'popoverZIndex'
- >;
+} & Pick<
+ ComboboxProps,
+ 'searchLoadingMessage' | 'searchErrorMessage' | 'searchEmptyMessage'
+>;
export const ComboboxMenu = React.forwardRef(
(
@@ -50,7 +46,6 @@ export const ComboboxMenu = React.forwardRef(
searchLoadingMessage,
searchErrorMessage,
searchEmptyMessage,
- ...popoverProps
}: ComboboxMenuProps,
forwardedRef,
) => {
@@ -140,7 +135,6 @@ export const ComboboxMenu = React.forwardRef(
refEl={refEl}
adjustOnMutation={true}
className={cx(popoverStyle(menuWidth), popoverThemeStyle[theme])}
- {...popoverProps}
>
= {
},
args: {
copyable: true,
- shouldTooltipUsePortal: true,
darkMode: false,
label: 'Label',
description: 'Description',
@@ -39,7 +38,6 @@ const meta: StoryMetaType
= {
copyable: { control: 'boolean' },
label: { control: 'text' },
description: { control: 'text' },
- shouldTooltipUsePortal: { control: 'boolean' },
children: storybookArgTypes.children,
darkMode: storybookArgTypes.darkMode,
},
diff --git a/packages/copyable/src/Copyable/Copyable.spec.tsx b/packages/copyable/src/Copyable/Copyable.spec.tsx
index 2ab8ada253..9bccf9c2ce 100644
--- a/packages/copyable/src/Copyable/Copyable.spec.tsx
+++ b/packages/copyable/src/Copyable/Copyable.spec.tsx
@@ -1,11 +1,5 @@
import React from 'react';
-import {
- act,
- fireEvent,
- render,
- waitFor,
- waitForElementToBeRemoved,
-} from '@testing-library/react';
+import { act, fireEvent, render, waitFor } from '@testing-library/react';
import ClipboardJS from 'clipboard';
import { axe } from 'jest-axe';
@@ -113,14 +107,12 @@ describe('packages/copyable', () => {
},
);
- await waitFor(() => expect(getByText('Copied!')).toBeVisible());
+ const tooltip = getByText('Copied!');
- // Tooltip should remain visible for a while
- await new Promise(resolve => setTimeout(resolve, 1000));
- expect(getByText('Copied!')).toBeVisible();
-
- // Tooltip should eventually disappear
- await waitForElementToBeRemoved(() => queryByText('Copied!'));
+ await waitFor(() => expect(tooltip).toBeVisible());
+ await waitFor(() => expect(tooltip).not.toBeVisible(), {
+ timeout: 2000,
+ });
},
);
});
diff --git a/packages/copyable/src/Copyable/Copyable.tsx b/packages/copyable/src/Copyable/Copyable.tsx
index ee5ff23422..81f9c8bb66 100644
--- a/packages/copyable/src/Copyable/Copyable.tsx
+++ b/packages/copyable/src/Copyable/Copyable.tsx
@@ -11,13 +11,19 @@ import {
usePopoverPortalContainer,
} from '@leafygreen-ui/leafygreen-provider';
import { BaseFontSize } from '@leafygreen-ui/tokens';
-import Tooltip, { Align, Justify, TriggerEvent } from '@leafygreen-ui/tooltip';
+import Tooltip, {
+ Align,
+ Justify,
+ RenderMode,
+ TriggerEvent,
+} from '@leafygreen-ui/tooltip';
import {
Description,
Label,
useUpdatedBaseFontSize,
} from '@leafygreen-ui/typography';
+import { TOOLTIP_VISIBLE_DURATION } from './constants';
import {
buttonContainerStyle,
buttonStyle,
@@ -45,7 +51,6 @@ export default function Copyable({
description,
label,
onCopy,
- shouldTooltipUsePortal = true,
size: SizeProp,
}: CopyableProps) {
const { theme, darkMode } = useDarkMode(darkModeProp);
@@ -97,7 +102,7 @@ export default function Copyable({
if (copied) {
const timeoutId = setTimeout(() => {
setCopied(false);
- }, 1500);
+ }, TOOLTIP_VISIBLE_DURATION);
return () => clearTimeout(timeoutId);
}
@@ -183,7 +188,7 @@ export default function Copyable({
}
triggerEvent={TriggerEvent.Click}
- usePortal={shouldTooltipUsePortal}
+ renderMode={RenderMode.TopLayer}
>
Copied!
@@ -204,5 +209,4 @@ Copyable.propTypes = {
description: PropTypes.string,
className: PropTypes.string,
copyable: PropTypes.bool,
- shouldTooltipUsePortal: PropTypes.bool,
};
diff --git a/packages/copyable/src/Copyable/Copyable.types.ts b/packages/copyable/src/Copyable/Copyable.types.ts
index f232fb2c9e..ea8f065238 100644
--- a/packages/copyable/src/Copyable/Copyable.types.ts
+++ b/packages/copyable/src/Copyable/Copyable.types.ts
@@ -35,10 +35,5 @@ export interface CopyableProps extends HTMLElementProps<'div'> {
size?: Size;
- /**
- * If `true`, the tooltip rendered as feedback when the user clicks the copy button will be rendered using a portal
- */
- shouldTooltipUsePortal?: boolean;
-
children: string;
}
diff --git a/packages/copyable/src/Copyable/constants.ts b/packages/copyable/src/Copyable/constants.ts
new file mode 100644
index 0000000000..921673e5bd
--- /dev/null
+++ b/packages/copyable/src/Copyable/constants.ts
@@ -0,0 +1 @@
+export const TOOLTIP_VISIBLE_DURATION = 1500;
diff --git a/packages/date-picker/README.md b/packages/date-picker/README.md
index 152a16480d..ca3cffe2ce 100644
--- a/packages/date-picker/README.md
+++ b/packages/date-picker/README.md
@@ -61,7 +61,7 @@ const [date, setDate] = useState();
## Popover Props
-Date Picker extends [Popover props](https://www.mongodb.design/component/popover/documentation/) but omits the following props: `usePortal`, `refEl`, `children`, `className`, `onClick`, and `active`.
+Date Picker extends [Popover props](https://www.mongodb.design/component/popover/documentation/) but omits the following props: `active`, `children`, `className`, `dismissMode`, `onClick`, `onToggle`, `popoverZIndex`, `portalClassName`, `portalContainer`, `portalRef`, `refEl`, `renderMode`, and `scrollContainer`.
## 🔎 Glossary
diff --git a/packages/date-picker/src/DatePicker/DatePicker.spec.tsx b/packages/date-picker/src/DatePicker/DatePicker.spec.tsx
index cec9dbdf64..71767988aa 100644
--- a/packages/date-picker/src/DatePicker/DatePicker.spec.tsx
+++ b/packages/date-picker/src/DatePicker/DatePicker.spec.tsx
@@ -1,4 +1,4 @@
-import React, { createRef } from 'react';
+import React from 'react';
import {
fireEvent,
render,
@@ -408,27 +408,6 @@ describe('packages/date-picker', () => {
expect(menuContainerEl).toBeInTheDocument();
});
- test('appends to the end of the DOM', async () => {
- const { findMenuElements, container } = renderDatePicker({
- initialOpen: true,
- });
- const { menuContainerEl } = await findMenuElements();
- expect(container).not.toContain(menuContainerEl);
- });
-
- test('accepts a portalRef', () => {
- const portalContainer = document.createElement('div');
- document.body.appendChild(portalContainer);
- const portalRef = createRef();
- renderDatePicker({
- initialOpen: true,
- portalContainer,
- portalRef,
- });
- expect(portalRef.current).toBeDefined();
- expect(portalRef.current).toBe(portalContainer);
- });
-
test('menu is initially closed when rendered with `initialOpen` and `disabled`', async () => {
const { findMenuElements } = renderDatePicker({
initialOpen: true,
@@ -3669,9 +3648,6 @@ describe('packages/date-picker', () => {
{/* @ts-expect-error - needs label/aria-label/aria-labelledby */}
- {/* @ts-expect-error - does not accept usePortal prop */}
-
-
@@ -3694,21 +3670,16 @@ describe('packages/date-picker', () => {
initialOpen={false}
autoComplete="off"
darkMode={false}
- portalClassName=""
- scrollContainer={{} as HTMLElement}
- portalContainer={{} as HTMLElement}
align="bottom"
justify="start"
spacing={10}
adjustOnMutation={true}
- popoverZIndex={1}
onEnter={() => {}}
onEntering={() => {}}
onEntered={() => {}}
onExit={() => {}}
onExiting={() => {}}
onExited={() => {}}
- contentClassName=""
/>
>;
});
diff --git a/packages/date-picker/src/DatePicker/DatePicker.tsx b/packages/date-picker/src/DatePicker/DatePicker.tsx
index 6917a3ffe8..43da9e94b0 100644
--- a/packages/date-picker/src/DatePicker/DatePicker.tsx
+++ b/packages/date-picker/src/DatePicker/DatePicker.tsx
@@ -96,22 +96,4 @@ DatePicker.propTypes = {
initialOpen: PropTypes.bool,
autoComplete: PropTypes.oneOf(Object.values(AutoComplete)),
darkMode: PropTypes.bool,
- // Popover Props
- popoverZIndex: PropTypes.number,
- portalContainer:
- typeof window !== 'undefined'
- ? PropTypes.instanceOf(Element)
- : PropTypes.any,
- /// @ts-expect-error Types of property '[nominalTypeHack]' are incompatible.
- portalRef: PropTypes.shape({
- current:
- typeof window !== 'undefined'
- ? PropTypes.instanceOf(Element)
- : PropTypes.any,
- }),
- scrollContainer:
- typeof window !== 'undefined'
- ? PropTypes.instanceOf(Element)
- : PropTypes.any,
- portalClassName: PropTypes.string,
};
diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.tsx b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.tsx
index d5a82e9ce2..b5533dafe0 100644
--- a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.tsx
+++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.tsx
@@ -301,7 +301,6 @@ export const DatePickerMenu = forwardRef(
spacing={spacing[1]}
data-today={today.toISOString()}
className={menuWrapperStyles}
- usePortal
onKeyDown={handleMenuKeyPress}
onTransitionEnd={handleMenuTransitionEntered}
onExited={handleMenuTransitionExited}
diff --git a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.types.ts b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.types.ts
index 1a21370038..d1bfed1f94 100644
--- a/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.types.ts
+++ b/packages/date-picker/src/DatePicker/DatePickerMenu/DatePickerMenu.types.ts
@@ -1,7 +1,16 @@
import { HTMLElementProps } from '@leafygreen-ui/lib';
import { PopoverProps } from '@leafygreen-ui/popover';
-import { PortalControlProps } from '@leafygreen-ui/popover';
-export type DatePickerMenuProps = PortalControlProps &
- Omit &
+export type DatePickerMenuProps = Omit<
+ PopoverProps,
+ | 'children'
+ | 'dismissMode'
+ | 'onToggle'
+ | 'popoverZIndex'
+ | 'portalClassName'
+ | 'portalContainer'
+ | 'portalRef'
+ | 'renderMode'
+ | 'scrollContainer'
+> &
HTMLElementProps<'div'>;
diff --git a/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.tsx b/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.tsx
index e7859e4cee..a87de27285 100644
--- a/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.tsx
+++ b/packages/date-picker/src/shared/components/MenuWrapper/MenuWrapper.tsx
@@ -6,11 +6,26 @@ import {
useDarkMode,
} from '@leafygreen-ui/leafygreen-provider';
import { HTMLElementProps } from '@leafygreen-ui/lib';
-import Popover, { PopoverProps } from '@leafygreen-ui/popover';
+import Popover, {
+ DismissMode,
+ PopoverProps,
+ RenderMode,
+} from '@leafygreen-ui/popover';
import { menuStyles } from './MenuWrapper.styles';
-export type MenuWrapperProps = PopoverProps & HTMLElementProps<'div'>;
+export type MenuWrapperProps = Omit<
+ PopoverProps,
+ | 'dismissMode'
+ | 'onToggle'
+ | 'popoverZIndex'
+ | 'portalClassName'
+ | 'portalContainer'
+ | 'portalRef'
+ | 'renderMode'
+ | 'scrollContainer'
+> &
+ HTMLElementProps<'div'>;
/**
* A simple styled popover component
@@ -24,8 +39,13 @@ export const MenuWrapper = forwardRef(
ref={fwdRef}
className={cx(menuStyles[theme], className)}
{...props}
+ dismissMode={DismissMode.Manual}
+ renderMode={RenderMode.TopLayer}
>
- {/* Prevents the opening and closing state of a select dropdown from propagating up to other PopoverProviders in parent components. E.g. Modal */}
+ {/*
+ * Prevents the opening and closing state of a select dropdown from propagating up
+ * to other PopoverProviders in parent components. E.g. Modal
+ */}
{children}
);
diff --git a/packages/date-picker/src/shared/constants.ts b/packages/date-picker/src/shared/constants.ts
index 96f78df967..9d63ac22e1 100644
--- a/packages/date-picker/src/shared/constants.ts
+++ b/packages/date-picker/src/shared/constants.ts
@@ -1,4 +1,5 @@
import { Month } from '@leafygreen-ui/date-utils';
+import { RenderMode } from '@leafygreen-ui/popover';
import { DropdownWidthBasis } from '@leafygreen-ui/select';
/**
@@ -78,7 +79,5 @@ export const selectElementProps = {
size: 'xsmall',
allowDeselect: false,
dropdownWidthBasis: DropdownWidthBasis.Option,
- // using no portal so the select menus are included in the backdrop "foreground"
- // there is currently no way to pass a ref into the Select portal to use in backdrop "foreground"
- usePortal: false,
+ renderMode: RenderMode.TopLayer,
} as const;
diff --git a/packages/date-picker/src/shared/context/SharedDatePickerContext.utils.ts b/packages/date-picker/src/shared/context/SharedDatePickerContext.utils.ts
index ce34aa137f..fe3ebff2ec 100644
--- a/packages/date-picker/src/shared/context/SharedDatePickerContext.utils.ts
+++ b/packages/date-picker/src/shared/context/SharedDatePickerContext.utils.ts
@@ -30,22 +30,16 @@ export type ContextPropKeys = keyof SharedDatePickerProviderProps &
* Prop names that are extended from popoverProps
* */
export const modifiedPopoverPropNames: Array = [
- 'scrollContainer',
- 'portalContainer',
- 'portalRef',
- 'portalClassName',
'align',
'justify',
'spacing',
'adjustOnMutation',
- 'popoverZIndex',
'onEnter',
'onEntering',
'onEntered',
'onExit',
'onExiting',
'onExited',
- 'contentClassName',
];
/**
diff --git a/packages/date-picker/src/shared/types/BaseDatePickerProps.types.ts b/packages/date-picker/src/shared/types/BaseDatePickerProps.types.ts
index adf2a821fb..47ee0057e7 100644
--- a/packages/date-picker/src/shared/types/BaseDatePickerProps.types.ts
+++ b/packages/date-picker/src/shared/types/BaseDatePickerProps.types.ts
@@ -8,7 +8,19 @@ import { AutoComplete, DatePickerState } from './types';
export type ModifiedPopoverProps = Omit<
PopoverProps,
- 'usePortal' | 'refEl' | 'children' | 'className' | 'active' | 'onClick'
+ | 'active'
+ | 'children'
+ | 'className'
+ | 'dismissMode'
+ | 'onClick'
+ | 'onToggle'
+ | 'popoverZIndex'
+ | 'portalClassName'
+ | 'portalContainer'
+ | 'portalRef'
+ | 'refEl'
+ | 'renderMode'
+ | 'scrollContainer'
>;
export type BaseDatePickerProps = {
diff --git a/packages/guide-cue/README.md b/packages/guide-cue/README.md
index d58ccd6949..49cdbb376d 100644
--- a/packages/guide-cue/README.md
+++ b/packages/guide-cue/README.md
@@ -96,8 +96,4 @@ The variant that is shown depends on the number of steps. If `numberOfSteps > 1`
| `tooltipAlign` | `'top'` \| `'bottom'` \| `'left'` \| `'right'` | Determines the alignment of the tooltip. | `top` |
| `tooltipJustify` | `'start'` \| `'middle'` \| `'end'` | Determines the justification of the tooltip. | `middle` |
| `beaconAlign` | `'top'` \| `'bottom'` \| `'left'` \| `'right'` \| `'center-horizontal'` \| `'center-vertical'` | Determines the alignment of the beacon(animated pulsing circle that appears on top of the trigger element). This only applies to the multi-step tooltip. | `center-horizontal` |
-| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same refrence to `scrollContainer` and `portalContainer`. | |
-| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. | |
-| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
-| `popoverZIndex` | `number` | Sets the z-index CSS property for the popover. | |
| ... | native `div` attributes | Any other props will be spread on the tooltip `div` element | |
diff --git a/packages/guide-cue/package.json b/packages/guide-cue/package.json
index da08661f40..539f4b2d01 100644
--- a/packages/guide-cue/package.json
+++ b/packages/guide-cue/package.json
@@ -30,9 +30,6 @@
"access": "public"
},
"dependencies": {
- "focus-trap": "6.9.4",
- "focus-trap-react": "9.0.2",
- "polished": "^4.2.2",
"@leafygreen-ui/a11y": "^1.4.13",
"@leafygreen-ui/button": "^21.2.0",
"@leafygreen-ui/emotion": "^4.0.8",
@@ -43,10 +40,12 @@
"@leafygreen-ui/palette": "^4.0.9",
"@leafygreen-ui/popover": "^11.4.0",
"@leafygreen-ui/tooltip": "^11.1.0",
- "@leafygreen-ui/typography": "^19.0.0"
+ "@leafygreen-ui/typography": "^19.0.0",
+ "focus-trap": "6.9.4",
+ "focus-trap-react": "9.0.2",
+ "polished": "^4.2.2"
},
"devDependencies": {
- "@leafygreen-ui/tokens": "^2.8.0",
"@lg-tools/storybook-utils": "^0.1.1"
},
"peerDependencies": {
diff --git a/packages/guide-cue/src/GuideCue.spec.tsx b/packages/guide-cue/src/GuideCue.spec.tsx
index 4edca4f8df..9542d2c0a4 100644
--- a/packages/guide-cue/src/GuideCue.spec.tsx
+++ b/packages/guide-cue/src/GuideCue.spec.tsx
@@ -158,14 +158,6 @@ describe('packages/guide-cue', () => {
expect(guideCue).not.toBeInTheDocument();
});
- test('content should render in a portal', () => {
- const { container, queryByTestId } = renderGuideCue({
- open: true,
- });
- const guideCue = queryByTestId(guideCueTestId);
- expect(container).not.toContainElement(guideCue);
- });
-
test('number of steps should not be visible', () => {
const { queryByText } = renderGuideCue({
open: true,
@@ -192,32 +184,6 @@ describe('packages/guide-cue', () => {
const body = getByText(guideCueChildren);
expect(body).toBeInTheDocument();
});
-
- test('will render inside portal and scroll container', async () => {
- const elem = document.createElement('div');
- document.body.appendChild(elem);
- renderGuideCue({
- open: true,
- portalContainer: elem,
- scrollContainer: elem,
- });
- await act(async () => {
- expect(elem.innerHTML.includes(guideCueTitle)).toBe(true);
- });
- });
-
- test('accepts a portalRef', async () => {
- const portalContainer = document.createElement('div');
- document.body.appendChild(portalContainer);
- const portalRef = createRef();
- renderGuideCue({
- open: true,
- portalContainer,
- portalRef,
- });
- expect(portalRef.current).toBeDefined();
- expect(portalRef.current).toBe(portalContainer);
- });
});
describe('Multi-step tooltip', () => {
@@ -334,17 +300,6 @@ describe('packages/guide-cue', () => {
expect(modal).not.toBeInTheDocument();
});
- test('content should render in a portal', async () => {
- const { container, findByTestId } = renderGuideCue({
- open: true,
- numberOfSteps: 2,
- currentStep: 1,
- });
-
- const guideCue = await findByTestId(guideCueTestId);
- expect(container).not.toContainElement(guideCue);
- });
-
test('number of steps should be visible', async () => {
const { getByText } = renderGuideCue({
open: true,
@@ -396,20 +351,5 @@ describe('packages/guide-cue', () => {
const numOfButtons = getAllByRole('button').length;
await waitFor(() => expect(numOfButtons).toEqual(2));
});
-
- test('will render inside portal and scroll container', async () => {
- const elem = document.createElement('div');
- document.body.appendChild(elem);
- const { findByText } = renderGuideCue({
- open: true,
- numberOfSteps: 2,
- currentStep: 1,
- portalContainer: elem,
- scrollContainer: elem,
- });
-
- const guideCue = await findByText(guideCueTitle);
- expect(elem).toContainElement(guideCue);
- });
});
});
diff --git a/packages/guide-cue/src/GuideCue.stories.tsx b/packages/guide-cue/src/GuideCue.stories.tsx
index 7455252466..a6fa8781a6 100644
--- a/packages/guide-cue/src/GuideCue.stories.tsx
+++ b/packages/guide-cue/src/GuideCue.stories.tsx
@@ -12,7 +12,6 @@ import Button from '@leafygreen-ui/button';
import { css } from '@leafygreen-ui/emotion';
import { palette } from '@leafygreen-ui/palette';
import { Align } from '@leafygreen-ui/popover';
-import { transitionDuration } from '@leafygreen-ui/tokens';
import { Body } from '@leafygreen-ui/typography';
import { GuideCue, GuideCueProps, TooltipAlign, TooltipJustify } from '.';
@@ -80,11 +79,11 @@ const meta: StoryMetaType = {
@@ -191,12 +190,11 @@ export const ScrollableContainer: StoryFn
= (
) => {
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
- const portalContainer = useRef(null);
const { children, darkMode } = args;
return (
-
+
<>
setOpen(o => !o)}
@@ -216,8 +214,6 @@ export const ScrollableContainer: StoryFn = (
open={open}
setOpen={setOpen}
refEl={triggerRef}
- portalContainer={portalContainer.current}
- scrollContainer={portalContainer.current}
>
{children}
diff --git a/packages/guide-cue/src/GuideCue.tsx b/packages/guide-cue/src/GuideCue.tsx
index e564db4e46..356de67ed2 100644
--- a/packages/guide-cue/src/GuideCue.tsx
+++ b/packages/guide-cue/src/GuideCue.tsx
@@ -5,7 +5,11 @@ import PropTypes from 'prop-types';
import { usePrefersReducedMotion } from '@leafygreen-ui/a11y';
import { useIsomorphicLayoutEffect } from '@leafygreen-ui/hooks';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
-import Popover, { Align } from '@leafygreen-ui/popover';
+import Popover, {
+ Align,
+ DismissMode,
+ RenderMode,
+} from '@leafygreen-ui/popover';
import { beaconStyles, timeout1, timeout2 } from './styles';
import TooltipContent from './TooltipContent';
@@ -23,15 +27,10 @@ function GuideCue({
onDismiss = () => {},
onPrimaryButtonClick = () => {},
tooltipClassName,
- portalClassName,
buttonText: buttonTextProp,
tooltipAlign = TooltipAlign.Top,
tooltipJustify = TooltipJustify.Middle,
beaconAlign = Align.CenterHorizontal,
- portalContainer,
- portalRef,
- scrollContainer,
- popoverZIndex,
...tooltipProps
}: GuideCueProps) {
const { darkMode, theme } = useDarkMode(darkModeProp);
@@ -105,13 +104,6 @@ function GuideCue({
*/
const onEscClose = isStandalone ? onPrimaryButtonClick : onDismiss;
- const sharedProps = {
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
- };
-
const tooltipContentProps = {
darkMode,
open,
@@ -119,7 +111,6 @@ function GuideCue({
tooltipJustify,
tooltipAlign,
refEl,
- popoverZIndex,
numberOfSteps,
currentStep,
theme,
@@ -146,9 +137,7 @@ function GuideCue({
{isStandalone ? (
// Standalone tooltip
// this is using the reference from the `refEl` prop to position itself against
-
- {children}
-
+ {children}
) : (
// Multistep tooltip
{/* The beacon is using the popover component to position itself */}
{children}
@@ -227,23 +215,6 @@ GuideCue.propTypes = {
tooltipAlign: PropTypes.oneOf(Object.values(TooltipAlign)),
tooltipJustify: PropTypes.oneOf(Object.values(TooltipJustify)),
beaconAlign: PropTypes.oneOf(Object.values(Align)),
- // Popover Props
- popoverZIndex: PropTypes.number,
- scrollContainer:
- typeof window !== 'undefined'
- ? PropTypes.instanceOf(Element)
- : PropTypes.any,
- portalContainer:
- typeof window !== 'undefined'
- ? PropTypes.instanceOf(Element)
- : PropTypes.any,
- portalClassName: PropTypes.string,
- portalRef: PropTypes.shape({
- current:
- typeof window !== 'undefined'
- ? PropTypes.instanceOf(Element)
- : PropTypes.any,
- }),
};
export default GuideCue;
diff --git a/packages/guide-cue/src/TooltipContent.tsx b/packages/guide-cue/src/TooltipContent.tsx
index 7c87d4cbce..070369fae5 100644
--- a/packages/guide-cue/src/TooltipContent.tsx
+++ b/packages/guide-cue/src/TooltipContent.tsx
@@ -8,7 +8,7 @@ import { useIdAllocator } from '@leafygreen-ui/hooks';
import XIcon from '@leafygreen-ui/icon/dist/X';
import IconButton from '@leafygreen-ui/icon-button';
import { Theme } from '@leafygreen-ui/lib';
-import Tooltip from '@leafygreen-ui/tooltip';
+import Tooltip, { RenderMode } from '@leafygreen-ui/tooltip';
import { Body, Disclaimer } from '@leafygreen-ui/typography';
import {
@@ -28,6 +28,24 @@ import { GuideCueProps } from './types';
const ariaLabelledby = 'guide-cue-label';
const ariaDescribedby = 'guide-cue-desc';
+const focusTrapOptions: Options = {
+ clickOutsideDeactivates: true,
+ checkCanFocusTrap: async trapContainers => {
+ const results = trapContainers.map(trapContainer => {
+ return new Promise
(resolve => {
+ const interval = setInterval(() => {
+ if (getComputedStyle(trapContainer).opacity !== '0') {
+ resolve();
+ clearInterval(interval);
+ }
+ }, 5);
+ });
+ });
+ // Return a promise that resolves when all the trap containers are able to receive focus
+ return Promise.all(results).then(() => undefined);
+ },
+};
+
type TooltipContentProps = Partial & {
theme: Theme;
title: string;
@@ -36,7 +54,6 @@ type TooltipContentProps = Partial & {
onEscClose: () => void;
handleButtonClick: () => void;
handleCloseClick: () => void;
- usePortal?: boolean;
};
function TooltipContent({
@@ -46,10 +63,6 @@ function TooltipContent({
tooltipJustify,
tooltipAlign,
refEl,
- portalClassName,
- portalContainer,
- scrollContainer,
- popoverZIndex,
theme,
title,
isStandalone,
@@ -61,15 +74,10 @@ function TooltipContent({
onEscClose,
handleButtonClick,
handleCloseClick,
- usePortal = true,
...tooltipProps
}: TooltipContentProps) {
const focusId = useIdAllocator({ prefix: 'guide-cue' });
- const focusTrapOptions: Options = {
- clickOutsideDeactivates: true,
- };
-
return (
<>
diff --git a/packages/guide-cue/src/types.ts b/packages/guide-cue/src/types.ts
index 9db6b55dd2..7664c1c9e7 100644
--- a/packages/guide-cue/src/types.ts
+++ b/packages/guide-cue/src/types.ts
@@ -23,21 +23,28 @@ export type TooltipJustify =
// Exclude these from tooltip (tooltip already extends popover props)
type ModifiedTooltipProps = Omit<
TooltipProps,
- | 'usePortal'
- | 'justify'
| 'align'
- | 'onClick'
- | 'trigger'
- | 'triggerEvent'
- | 'shouldClose'
- | 'className'
| 'children'
+ | 'className'
+ | 'darkMode'
+ | 'dismissMode'
+ | 'initialOpen'
+ | 'justify'
+ | 'onClick'
| 'onClose'
- | 'setOpen'
+ | 'onToggle'
| 'open'
+ | 'popoverZIndex'
+ | 'portalClassName'
+ | 'portalContainer'
+ | 'portalRef'
| 'refEl'
- | 'darkMode'
- | 'initialOpen'
+ | 'renderMode'
+ | 'scrollContainer'
+ | 'setOpen'
+ | 'shouldClose'
+ | 'trigger'
+ | 'triggerEvent'
>;
interface StandaloneProps {
diff --git a/packages/hooks/src/hooks.spec.tsx b/packages/hooks/src/hooks.spec.tsx
index b62484c712..0a66c6f440 100644
--- a/packages/hooks/src/hooks.spec.tsx
+++ b/packages/hooks/src/hooks.spec.tsx
@@ -5,6 +5,7 @@ import { act, renderHook, renderHookServer } from '@leafygreen-ui/testing-lib';
import {
useEventListener,
useIdAllocator,
+ useMergeRefs,
useObjectDependency,
usePoller,
usePrevious,
@@ -93,6 +94,41 @@ describe('packages/hooks', () => {
});
});
+ describe('useMergeRefs', () => {
+ test('should merge refs', () => {
+ const callbackRefMockFunc = jest.fn();
+ const callbackRef: React.SetStateAction = element =>
+ callbackRefMockFunc(element);
+ const mutableRef: React.MutableRefObject = {
+ current: null,
+ };
+
+ const {
+ result: { current: mergedCallbackRef },
+ } = renderHook(() => useMergeRefs([callbackRef, mutableRef]));
+
+ expect(mergedCallbackRef).toBeInstanceOf(Function);
+ expect(callbackRefMockFunc).not.toHaveBeenCalled();
+ expect(mutableRef.current).toBe(null);
+
+ const element = document.createElement('div');
+ mergedCallbackRef?.(element);
+
+ expect(callbackRefMockFunc).toHaveBeenCalledTimes(1);
+ expect(callbackRefMockFunc).toHaveBeenCalledWith(element);
+ expect(mutableRef.current).toBe(element);
+ });
+
+ test('should return null when all refs are null or undefined', () => {
+ const ref1 = null;
+ const ref2 = undefined;
+
+ const { result } = renderHook(() => useMergeRefs([ref1, ref2]));
+
+ expect(result.current).toBe(null);
+ });
+ });
+
// Difficult to test a hook that measures changes to the DOM without having access to the DOM
describe.skip('useMutationObserver', () => {}); //eslint-disable-line jest/no-disabled-tests
diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts
index b2f44c5094..21ef1669bb 100644
--- a/packages/hooks/src/index.ts
+++ b/packages/hooks/src/index.ts
@@ -9,6 +9,7 @@ export { useForceRerender } from './useForceRerender';
export { useForwardedRef, useObservedRef } from './useForwardedRef';
export { default as useIdAllocator } from './useIdAllocator';
export { default as useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
+export { useMergeRefs } from './useMergeRefs';
export { default as useMutationObserver } from './useMutationObserver';
export { default as useObjectDependency } from './useObjectDependency';
export { default as usePoller } from './usePoller';
diff --git a/packages/hooks/src/useMergeRefs.ts b/packages/hooks/src/useMergeRefs.ts
new file mode 100644
index 0000000000..86e817d117
--- /dev/null
+++ b/packages/hooks/src/useMergeRefs.ts
@@ -0,0 +1,25 @@
+import * as React from 'react';
+
+/**
+ * Merges an array of refs into a single memoized callback ref or `null`.
+ */
+export function useMergeRefs(
+ refs: Array | undefined>,
+): React.RefCallback | null {
+ return React.useMemo(() => {
+ if (refs.every(ref => ref == null)) {
+ return null;
+ }
+
+ return value => {
+ refs.forEach(ref => {
+ if (typeof ref === 'function') {
+ ref(value);
+ } else if (ref != null) {
+ (ref as React.MutableRefObject).current = value;
+ }
+ });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, refs);
+}
diff --git a/packages/info-sprinkle/README.md b/packages/info-sprinkle/README.md
index 51b67224ff..95676138de 100644
--- a/packages/info-sprinkle/README.md
+++ b/packages/info-sprinkle/README.md
@@ -35,26 +35,21 @@ import { InfoSprinkle } from `@leafygreen-ui/info-sprinkle`;
## Properties
-| Prop | Type | Description | Default |
-| ----------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
-| `children` | `React.ReactNode` | String that will be rendered inside of ` ` | |
-| `open` | `boolean` | Controls the component, and determines whether or not the ` ` will appear open or closed. | `false` |
-| `setOpen` | `function` | If controlling the component, pass state handling function to setOpen prop. This will keep the consuming application's state in-sync with LeafyGreen's state, while the ` ` component responds to events such as backdrop clicks and a user pressing the Escape key. | `(boolean) => boolean` |
-| `shouldClose` | `function` | Callback that should return a boolean that determines whether or not the ` ` should close when a user tries to close it. | `() => true` |
-| `align` | `'top'`, `'bottom'`, `'left'`, `'right'` | Determines the preferred alignment of the ` ` component relative to the element passed to the `trigger` prop. | `'top'` |
-| `justify` | `'start'`, `'middle'`, `'end'`, `'fit'` | Determines the preferred justification of the ` ` component (based on the alignment) relative to the element passed to the `trigger` prop. | `'start'` |
-| `darkMode` | `boolean` | Determines if the ` ` will appear in dark mode. | `false` |
-| `id` | `string` | `id` applied to ` ` component | |
-| `className` | `string` | Applies a className to Tooltip container | |
-| `triggerProps` | native `span` attributes | Props passed to the trigger | `{}` |
-| `enabled` | `boolean` | Enables Tooltip to trigger. | `true` |
-| `onClose` | `function` | Callback that is called when the tooltip is closed internally. E.g. on ESC press, on backdrop click, on blur.. | `() => {}` |
-| `usePortal` | `boolean` | Determines if the Tooltip will be rendered within a portal. | `true` |
-| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same refrence to `scrollContainer` and `portalContainer`. | |
-| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that lement to allow the portal to position properly. | |
-| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
-| `popoverZIndex` | `number` | Sets the z-index CSS property for the popover. | |
-| `baseFontSize` | `13` \| `16` | font-size applied to typography element | default to value set by LeafyGreen Provider |
-| ... | native `div` attributes | Any other props will be spread on the tooltip element | |
+| Prop | Type | Description | Default |
+| -------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
+| `children` | `React.ReactNode` | String that will be rendered inside of ` ` | |
+| `open` | `boolean` | Controls the component, and determines whether or not the ` ` will appear open or closed. | `false` |
+| `setOpen` | `function` | If controlling the component, pass state handling function to setOpen prop. This will keep the consuming application's state in-sync with LeafyGreen's state, while the ` ` component responds to events such as backdrop clicks and a user pressing the Escape key. | `(boolean) => boolean` |
+| `shouldClose` | `function` | Callback that should return a boolean that determines whether or not the ` ` should close when a user tries to close it. | `() => true` |
+| `align` | `'top'`, `'bottom'`, `'left'`, `'right'` | Determines the preferred alignment of the ` ` component relative to the element passed to the `trigger` prop. | `'top'` |
+| `justify` | `'start'`, `'middle'`, `'end'` | Determines the preferred justification of the ` ` component (based on the alignment) relative to the element passed to the `trigger` prop. | `'start'` |
+| `darkMode` | `boolean` | Determines if the ` ` will appear in dark mode. | `false` |
+| `id` | `string` | `id` applied to ` ` component | |
+| `className` | `string` | Applies a className to Tooltip container | |
+| `triggerProps` | native `span` attributes | Props passed to the trigger | `{}` |
+| `enabled` | `boolean` | Enables Tooltip to trigger. | `true` |
+| `onClose` | `function` | Callback that is called when the tooltip is closed internally. E.g. on ESC press, on backdrop click, on blur.. | `() => {}` |
+| `baseFontSize` | `13` \| `16` | font-size applied to typography element | default to value set by LeafyGreen Provider |
+| ... | native `div` attributes | Any other props will be spread on the tooltip element | |
**Note:** The `ref` of this component will be the trigger icon but all props will spread to the internal ` ` component.
diff --git a/packages/info-sprinkle/src/InfoSprinkle/InfoSprinkle.tsx b/packages/info-sprinkle/src/InfoSprinkle/InfoSprinkle.tsx
index b0c3bcf45f..21b5041718 100644
--- a/packages/info-sprinkle/src/InfoSprinkle/InfoSprinkle.tsx
+++ b/packages/info-sprinkle/src/InfoSprinkle/InfoSprinkle.tsx
@@ -1,10 +1,10 @@
-import React from 'react';
+import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { cx } from '@leafygreen-ui/emotion';
import InfoWithCircleIcon from '@leafygreen-ui/icon/dist/InfoWithCircle';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
-import Tooltip from '@leafygreen-ui/tooltip';
+import Tooltip, { RenderMode } from '@leafygreen-ui/tooltip';
import { iconBaseStyles, iconThemeStyles } from './InfoSprinkle.styles';
import { Align, InfoSprinkleProps, Justify } from './InfoSprinkle.types';
@@ -24,10 +24,20 @@ export const InfoSprinkle = React.forwardRef<
forwardRef,
) => {
const { darkMode, theme } = useDarkMode(darkModeProp);
+ const [tooltipOpen, setTooltipOpen] = useState(false);
+
+ const handleMouseEnter = (e: React.MouseEvent) => {
+ setTooltipOpen(true);
+ triggerProps?.onMouseEnter?.(e);
+ };
+
return (
@@ -65,6 +76,4 @@ InfoSprinkle.propTypes = {
setOpen: PropTypes.func,
id: PropTypes.string,
shouldClose: PropTypes.func,
- usePortal: PropTypes.bool,
- portalClassName: PropTypes.string,
};
diff --git a/packages/info-sprinkle/src/InfoSprinkle/InfoSprinkle.types.ts b/packages/info-sprinkle/src/InfoSprinkle/InfoSprinkle.types.ts
index e0d9816fce..e39bd373e8 100644
--- a/packages/info-sprinkle/src/InfoSprinkle/InfoSprinkle.types.ts
+++ b/packages/info-sprinkle/src/InfoSprinkle/InfoSprinkle.types.ts
@@ -6,7 +6,18 @@ export { Align, Justify };
type ModifiedTooltipProps = Omit<
TooltipProps,
- 'onClick' | 'trigger' | 'triggerEvent' | 'refEl' | 'spacing'
+ | 'dismissMode'
+ | 'onClick'
+ | 'popoverZIndex'
+ | 'portalClassName'
+ | 'portalContainer'
+ | 'portalRef'
+ | 'refEl'
+ | 'renderMode'
+ | 'scrollContainer'
+ | 'spacing'
+ | 'trigger'
+ | 'triggerEvent'
>;
export interface InfoSprinkleProps extends ModifiedTooltipProps {
diff --git a/packages/inline-definition/README.md b/packages/inline-definition/README.md
index 04bfd4419b..eb2f3b014b 100644
--- a/packages/inline-definition/README.md
+++ b/packages/inline-definition/README.md
@@ -58,6 +58,6 @@ npm install @leafygreen-ui/inline-definition
| `children` | `string` | Text that will appear underlined | |
| `className` | `string` | className will be applied to the trigger element | |
| `align` | `'top'`, `'bottom'`, `'left'`, `'right'` | Determines the preferred alignment of the tooltip relative to the component's children. | `'top'` |
-| `justify` | `'start'`, `'middle'`, `'end'`, `'fit'` | Determines the preferred justification of the tooltip (based on the alignment) relative to the element passed to the component's children. | `'start'` |
+| `justify` | `'start'`, `'middle'`, `'end'` | Determines the preferred justification of the tooltip (based on the alignment) relative to the element passed to the component's children. | `'start'` |
| `darkMode` | `boolean` | Determines if the component will appear in dark mode. | `false` |
| `tooltipClassName` | `string` | className to be applied to the tooltip element | |
diff --git a/packages/inline-definition/package.json b/packages/inline-definition/package.json
index 88d8d8b23b..276bf530d0 100644
--- a/packages/inline-definition/package.json
+++ b/packages/inline-definition/package.json
@@ -25,6 +25,7 @@
"@leafygreen-ui/emotion": "^4.0.8",
"@leafygreen-ui/lib": "^13.3.0",
"@leafygreen-ui/palette": "^4.0.9",
+ "@leafygreen-ui/tokens": "^2.11.0",
"@leafygreen-ui/tooltip": "^11.0.4"
},
"peerDependencies": {
diff --git a/packages/inline-definition/src/InlineDefinition.spec.tsx b/packages/inline-definition/src/InlineDefinition.spec.tsx
index 5ff598f6e8..a19102ceaf 100644
--- a/packages/inline-definition/src/InlineDefinition.spec.tsx
+++ b/packages/inline-definition/src/InlineDefinition.spec.tsx
@@ -1,12 +1,12 @@
import React from 'react';
import {
act,
- fireEvent,
render,
screen,
waitFor,
waitForElementToBeRemoved,
} from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { H2 } from '@leafygreen-ui/typography';
@@ -33,7 +33,7 @@ describe('packages/inline-definition', () => {
expect(results).toHaveNoViolations();
let newResults = null as any;
- act(() => void fireEvent.mouseEnter(getByText('Shard')));
+ act(() => void userEvent.hover(getByText('Shard')));
await act(async () => {
newResults = await axe(container);
});
@@ -54,18 +54,15 @@ describe('packages/inline-definition', () => {
renderInlineDefinition();
const children = screen.getByText('Shard');
- fireEvent.mouseEnter(children);
+ userEvent.hover(children);
- await waitFor(
- () => expect(screen.getByText(shardDefinition)).toBeVisible(),
- { timeout: 500 },
+ await waitFor(() =>
+ expect(screen.getByText(shardDefinition)).toBeVisible(),
);
- fireEvent.mouseLeave(children);
+ userEvent.unhover(children);
- await waitForElementToBeRemoved(screen.getByText(shardDefinition), {
- timeout: 500,
- });
+ await waitForElementToBeRemoved(screen.getByText(shardDefinition));
});
/* eslint-disable jest/no-disabled-tests, jest/expect-expect */
diff --git a/packages/inline-definition/src/InlineDefinition.stories.tsx b/packages/inline-definition/src/InlineDefinition.stories.tsx
index dabffb090f..32eb549dc0 100644
--- a/packages/inline-definition/src/InlineDefinition.stories.tsx
+++ b/packages/inline-definition/src/InlineDefinition.stories.tsx
@@ -24,6 +24,21 @@ const meta: StoryMetaType = {
darkMode: [false, true],
},
args: { open: true },
+ decorator: Instance => {
+ return (
+
+
+
+ );
+ },
},
},
args: {
diff --git a/packages/inline-definition/src/InlineDefinition.styles.ts b/packages/inline-definition/src/InlineDefinition.styles.ts
new file mode 100644
index 0000000000..841eb96162
--- /dev/null
+++ b/packages/inline-definition/src/InlineDefinition.styles.ts
@@ -0,0 +1,50 @@
+import { css, cx } from '@leafygreen-ui/emotion';
+import { Theme } from '@leafygreen-ui/lib';
+import { palette } from '@leafygreen-ui/palette';
+
+const triggerElementStyles = css`
+ border-radius: 2px;
+ text-decoration: underline dotted 2px;
+ text-underline-offset: 0.125em;
+
+ &:hover {
+ a > * {
+ // Remove the Link underline styles
+ &::after {
+ height: 0;
+ }
+ }
+ }
+
+ &:focus,
+ &:focus-visible {
+ outline-color: ${palette.blue.light1};
+ outline-offset: 3px;
+ outline-style: solid;
+ outline-width: 2px;
+ }
+`;
+
+const triggerElementModeStyles: Record = {
+ [Theme.Light]: css`
+ text-decoration-color: ${palette.black};
+
+ &:hover,
+ &:focus,
+ &:focus-visible {
+ text-decoration-color: ${palette.black};
+ }
+ `,
+ [Theme.Dark]: css`
+ text-decoration-color: ${palette.gray.light2};
+
+ &:hover,
+ &:focus,
+ &:focus-visible {
+ text-decoration-color: ${palette.gray.light2};
+ }
+ `,
+};
+
+export const getTriggerElementStyles = (theme: Theme, className?: string) =>
+ cx(triggerElementStyles, triggerElementModeStyles[theme], className);
diff --git a/packages/inline-definition/src/InlineDefinition.tsx b/packages/inline-definition/src/InlineDefinition.tsx
index aef15f167b..89fe93acf0 100644
--- a/packages/inline-definition/src/InlineDefinition.tsx
+++ b/packages/inline-definition/src/InlineDefinition.tsx
@@ -1,74 +1,11 @@
-import React from 'react';
+import React, { useState } from 'react';
import PropTypes from 'prop-types';
-import { css, cx } from '@leafygreen-ui/emotion';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
-import { Theme } from '@leafygreen-ui/lib';
-import { palette } from '@leafygreen-ui/palette';
-import Tooltip, { TooltipProps } from '@leafygreen-ui/tooltip';
+import Tooltip, { RenderMode } from '@leafygreen-ui/tooltip';
-const triggerElementStyles = css`
- border-radius: 2px;
- text-decoration: underline dotted 2px;
- text-underline-offset: 0.125em;
-
- &:hover {
- a > * {
- // Remove the Link underline styles
- &::after {
- height: 0;
- }
- }
- }
-
- &:focus,
- &:focus-visible {
- outline-color: ${palette.blue.light1};
- outline-offset: 3px;
- outline-style: solid;
- outline-width: 2px;
- }
-`;
-
-const triggerElementModeStyles: Record = {
- [Theme.Light]: css`
- text-decoration-color: ${palette.black};
-
- &:hover,
- &:focus,
- &:focus-visible {
- text-decoration-color: ${palette.black};
- }
- `,
- [Theme.Dark]: css`
- text-decoration-color: ${palette.gray.light2};
-
- &:hover,
- &:focus,
- &:focus-visible {
- text-decoration-color: ${palette.gray.light2};
- }
- `,
-};
-
-export interface InlineDefinitionProps extends Partial {
- /**
- * Trigger element for the definition tooltip
- * @required
- */
- children: TooltipProps['children'];
-
- /**
- * ReactNode rendered inside the tooltip
- * @required
- */
- definition: React.ReactNode;
-
- /**
- * `className` prop passed to the Tooltip component instance
- */
- tooltipClassName?: string;
-}
+import { getTriggerElementStyles } from './InlineDefinition.styles';
+import { InlineDefinitionProps } from './InlineDefinition.types';
/**
* Inline Definition
@@ -86,23 +23,28 @@ function InlineDefinition({
...tooltipProps
}: InlineDefinitionProps) {
const { theme, darkMode } = useDarkMode(darkModeProp);
+ const [tooltipOpen, setTooltipOpen] = useState(false);
+
+ const handleMouseEnter = () => {
+ setTooltipOpen(true);
+ };
return (
{children}
diff --git a/packages/inline-definition/src/InlineDefinition.types.ts b/packages/inline-definition/src/InlineDefinition.types.ts
new file mode 100644
index 0000000000..d7a18a635d
--- /dev/null
+++ b/packages/inline-definition/src/InlineDefinition.types.ts
@@ -0,0 +1,32 @@
+import { TooltipProps } from '@leafygreen-ui/tooltip';
+
+export interface InlineDefinitionProps
+ extends Partial<
+ Omit<
+ TooltipProps,
+ | 'dismissMode'
+ | 'popoverZIndex'
+ | 'portalClassName'
+ | 'portalContainer'
+ | 'portalRef'
+ | 'renderMode'
+ | 'scrollContainer'
+ >
+ > {
+ /**
+ * Trigger element for the definition tooltip
+ * @required
+ */
+ children: TooltipProps['children'];
+
+ /**
+ * ReactNode rendered inside the tooltip
+ * @required
+ */
+ definition: React.ReactNode;
+
+ /**
+ * `className` prop passed to the Tooltip component instance
+ */
+ tooltipClassName?: string;
+}
diff --git a/packages/inline-definition/src/index.ts b/packages/inline-definition/src/index.ts
index c724740fcf..fc3cb137a9 100644
--- a/packages/inline-definition/src/index.ts
+++ b/packages/inline-definition/src/index.ts
@@ -1 +1,2 @@
-export { default, type InlineDefinitionProps } from './InlineDefinition';
+export { default } from './InlineDefinition';
+export { type InlineDefinitionProps } from './InlineDefinition.types';
diff --git a/packages/leafygreen-provider/README.md b/packages/leafygreen-provider/README.md
index e2f1d196c8..99c4615392 100644
--- a/packages/leafygreen-provider/README.md
+++ b/packages/leafygreen-provider/README.md
@@ -44,11 +44,44 @@ import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider';
### Properties
-| Prop | Type | Description | Default |
-| -------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
-| `children` | `node` | Children passed to `LeafyGreenProvider` will be unmodified, aside from having access to its state. | |
-| `baseFontSize` | `14`, `16` | Describes the `font-size` that the application is using. ` ` and ` ` components use this value to determine the `font-size` and `line-height` applied to their content | `14` |
-| `darkMode` | `boolean` | Determines if LG components should be rendered in dark mode. | |
+| Prop | Type | Description | Default |
+| ------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
+| `children` | `node` | Children passed to `LeafyGreenProvider` will be unmodified, aside from having access to its state. | |
+| `baseFontSize` | `14`, `16` | Describes the `font-size` that the application is using. ` ` and ` ` components use this value to determine the `font-size` and `line-height` applied to their content | `14` |
+| `darkMode` | `boolean` | Determines if LG components should be rendered in dark mode. | |
+| `forceUseTopLayer` | `boolean` | Determines globally if popover elements using `Popover` component from `@leafygreen-ui/popover` package should render in top layer | `false` |
+
+## PopoverPropsProvider
+
+The `PopoverPropsProvider` can be used to pass props to a deeply nested popover element.
+
+### Example
+
+```js
+import { PopoverPropsProvider } from '@leafygreen-ui/leafygreen-provider';
+
+const ParentComponentWithNestedPopover = ({ ...popoverProps }) => {
+ return (
+
+
+
+ );
+};
+```
+
+### Properties
+
+| Prop | Type | Description | Default |
+| ------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
+| `dismissMode` | `'auto'` \| `'manual'` | Options to control how the popover element is dismissed. This will only apply when `renderMode` is `'top-layer'` \* `'auto'` will automatically handle dismissal on backdrop click or esc key press, ensuring only one popover is visible at a time \* `'manual'` will require that the consumer handle dismissal manually | `'auto'` |
+| `onToggle` | `(e: ToggleEvent) => void;` | Function that is called when the popover is toggled. This will only apply when `renderMode` is `'top-layer'` | |
+| `popoverZIndex` (deprecated) | `number` | Sets the z-index CSS property for the popover. This will only apply if `usePortal` is defined and `renderMode` is not `'top-layer'` | |
+| `portalClassName` (deprecated) | `string` | Passes the given className to the popover's portal container if the default portal container is being used. This will only apply when `renderMode` is `'portal'` | |
+| `portalContainer` (deprecated) | `HTMLElement` \| `null` | Sets the container used for the popover's portal. This will only apply when `renderMode` is `'portal'`. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same reference to `scrollContainer` and `portalContainer`. | |
+| `portalRef` (deprecated) | `string` | Passes a ref to forward to the portal element. This will only apply when `renderMode` is `'portal'` | |
+| `renderMode` | `'inline'` \| `'portal'` \| `'top-layer'` | Options to render the popover element \* [deprecated] `'inline'` will render the popover element inline in the DOM where it's written \* [deprecated] `'portal'` will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer` \* `'top-layer'` will render the popover element in the top layer | `'top-layer'` |
+| `scrollContainer` (deprecated) | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. This will only apply when `renderMode` is `'portal'` | |
+| `spacing` | `number` | Specifies the amount of spacing (in pixels) between the trigger element and the content element. | `4` |
## useUsingKeyboardContext
@@ -111,6 +144,8 @@ function InlineCode({ children, className }: InlineCodeProps) {
This hook is meant for internal use. It allows components to read the value of the dark mode prop from the LeafyGreen provider and overwrite the value locally if necessary.
+### Example
+
```js
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
diff --git a/packages/leafygreen-provider/package.json b/packages/leafygreen-provider/package.json
index 1f0f444fd2..b773826a4d 100644
--- a/packages/leafygreen-provider/package.json
+++ b/packages/leafygreen-provider/package.json
@@ -22,8 +22,9 @@
"access": "public"
},
"dependencies": {
+ "@leafygreen-ui/hooks": "^8.1.3",
"@leafygreen-ui/lib": "^13.3.0",
- "@leafygreen-ui/hooks": "^8.1.3"
+ "react-transition-group": "^4.4.5"
},
"gitHead": "dd71a2d404218ccec2e657df9c0263dc1c15b9e0",
"homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/leafygreen-provider",
diff --git a/packages/leafygreen-provider/src/LeafyGreenContext.spec.tsx b/packages/leafygreen-provider/src/LeafyGreenContext.spec.tsx
index d9550b0fc5..808ad467e2 100644
--- a/packages/leafygreen-provider/src/LeafyGreenContext.spec.tsx
+++ b/packages/leafygreen-provider/src/LeafyGreenContext.spec.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { cleanup, render } from '@testing-library/react';
-import { LeafyGreenProviderProps } from './LeafyGreenContext';
+import { LeafyGreenProviderProps } from './LeafyGreenContext.types';
import LeafyGreenProvider, {
useBaseFontSize,
useDarkMode,
@@ -53,6 +53,7 @@ describe('packages/leafygreen-provider/LeafyGreenProvider', () => {
baseFontSize = 16,
portalId = 'portal',
scrollId = 'scroll',
+ forceUseTopLayer = false,
) => {
const portalContainer = document.createElement('div');
portalContainer.setAttribute('id', portalId);
@@ -66,6 +67,7 @@ describe('packages/leafygreen-provider/LeafyGreenProvider', () => {
portalContainer,
scrollContainer,
},
+ forceUseTopLayer,
} as Required;
};
diff --git a/packages/leafygreen-provider/src/LeafyGreenContext.tsx b/packages/leafygreen-provider/src/LeafyGreenContext.tsx
index 9bdb852fb0..57dca90d6c 100644
--- a/packages/leafygreen-provider/src/LeafyGreenContext.tsx
+++ b/packages/leafygreen-provider/src/LeafyGreenContext.tsx
@@ -1,35 +1,26 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
-import { DarkModeProps } from '@leafygreen-ui/lib';
-
import DarkModeProvider, { useDarkModeContext } from './DarkModeContext';
-import PortalContextProvider, {
- PortalContextValues,
+import { LeafyGreenProviderProps } from './LeafyGreenContext.types';
+import { MigrationProvider, useMigrationContext } from './MigrationContext';
+import {
+ PortalContextProvider,
usePopoverPortalContainer,
} from './PortalContext';
-import TypographyProvider, {
- TypographyProviderProps,
- useBaseFontSize,
-} from './TypographyContext';
+import TypographyProvider, { useBaseFontSize } from './TypographyContext';
import UsingKeyboardProvider from './UsingKeyboardContext';
-export type LeafyGreenProviderProps = {
- /**
- * Define a container HTMLElement for components that utilize the `Portal` component
- */
- popoverPortalContainer?: PortalContextValues['popover'];
-} & TypographyProviderProps &
- DarkModeProps;
-
function LeafyGreenProvider({
children,
baseFontSize: fontSizeProp,
popoverPortalContainer: popoverPortalContainerProp,
darkMode: darkModeProp,
+ forceUseTopLayer: forceUseTopLayerProp = false,
}: PropsWithChildren) {
- // if the prop is set, we use that
- // if the prop is not set, we use outer context
+ /**
+ * If `darkMode` prop is provided, use that. Otherwise, use context value
+ */
const { contextDarkMode: inheritedDarkMode } = useDarkModeContext();
const [darkModeState, setDarkMode] = useState(
darkModeProp ?? inheritedDarkMode,
@@ -39,14 +30,26 @@ function LeafyGreenProvider({
setDarkMode(darkModeProp ?? inheritedDarkMode);
}, [darkModeProp, inheritedDarkMode]);
- // Similarly with base font size
+ /**
+ * If `baseFontSize` prop is provided, use that. Otherwise, use context value
+ */
const inheritedFontSize = useBaseFontSize();
const baseFontSize = fontSizeProp ?? inheritedFontSize;
- // and popover portal container
+
+ /**
+ * If `popoverPortalContainer` prop is provided, use that. Otherwise, use context value
+ */
const inheritedContainer = usePopoverPortalContainer();
const popoverPortalContainer =
popoverPortalContainerProp ?? inheritedContainer;
+ /**
+ * If `forceUseTopLayerProp` is true, it will globally apply to all children
+ */
+ const migrationContext = useMigrationContext();
+ const forceUseTopLayer =
+ forceUseTopLayerProp || migrationContext.forceUseTopLayer;
+
return (
@@ -55,7 +58,9 @@ function LeafyGreenProvider({
contextDarkMode={darkModeState}
setDarkMode={setDarkMode}
>
- {children}
+
+ {children}
+
diff --git a/packages/leafygreen-provider/src/LeafyGreenContext.types.ts b/packages/leafygreen-provider/src/LeafyGreenContext.types.ts
new file mode 100644
index 0000000000..a076357097
--- /dev/null
+++ b/packages/leafygreen-provider/src/LeafyGreenContext.types.ts
@@ -0,0 +1,14 @@
+import { DarkModeProps } from '@leafygreen-ui/lib';
+
+import { MigrationContextType } from './MigrationContext';
+import { PortalContextValues } from './PortalContext';
+import { TypographyProviderProps } from './TypographyContext';
+
+export type LeafyGreenProviderProps = {
+ /**
+ * Define a container HTMLElement for components that utilize the `Portal` component
+ */
+ popoverPortalContainer?: PortalContextValues['popover'];
+} & TypographyProviderProps &
+ DarkModeProps &
+ MigrationContextType;
diff --git a/packages/leafygreen-provider/src/MigrationContext/MigrationContext.spec.tsx b/packages/leafygreen-provider/src/MigrationContext/MigrationContext.spec.tsx
new file mode 100644
index 0000000000..0b5dd69b20
--- /dev/null
+++ b/packages/leafygreen-provider/src/MigrationContext/MigrationContext.spec.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { act, render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import { renderHook } from '@leafygreen-ui/testing-lib';
+
+import { MigrationProvider, useMigrationContext } from './MigrationContext';
+
+const childTestId = 'test-child';
+
+describe('packages/leafygreen-provider/MigrationContext', () => {
+ test('only renders children in the DOM', () => {
+ const { container, getByTestId } = render(
+
+ Child element
+ ,
+ );
+ const testChild = getByTestId(childTestId);
+
+ expect(container.firstChild).toBe(testChild);
+ });
+});
+
+describe('useMigrationContext', () => {
+ test('passes provider props correctly', () => {
+ const customProps = { forceUseTopLayer: true };
+ const { result } = renderHook(useMigrationContext, {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ });
+
+ expect(result.current).toHaveProperty('forceUseTopLayer', true);
+ });
+});
diff --git a/packages/leafygreen-provider/src/MigrationContext/MigrationContext.tsx b/packages/leafygreen-provider/src/MigrationContext/MigrationContext.tsx
new file mode 100644
index 0000000000..784bfe1b16
--- /dev/null
+++ b/packages/leafygreen-provider/src/MigrationContext/MigrationContext.tsx
@@ -0,0 +1,34 @@
+import React, { createContext, PropsWithChildren, useContext } from 'react';
+import PropTypes from 'prop-types';
+
+import { MigrationContextType } from './MigrationContext.types';
+
+export const MigrationContext = createContext({
+ forceUseTopLayer: false,
+});
+
+/**
+ * Access the modal popover context
+ */
+export const useMigrationContext = (): MigrationContextType => {
+ return useContext(MigrationContext);
+};
+
+/**
+ * Creates a global context for migration purposes.
+ * Call `useMigrationContext` to access the migration context
+ */
+export const MigrationProvider = ({
+ children,
+ ...props
+}: PropsWithChildren) => {
+ return (
+
+ {children}
+
+ );
+};
+
+MigrationProvider.displayName = 'MigrationProvider';
+
+MigrationProvider.propTypes = { children: PropTypes.node };
diff --git a/packages/leafygreen-provider/src/MigrationContext/MigrationContext.types.ts b/packages/leafygreen-provider/src/MigrationContext/MigrationContext.types.ts
new file mode 100644
index 0000000000..1574906596
--- /dev/null
+++ b/packages/leafygreen-provider/src/MigrationContext/MigrationContext.types.ts
@@ -0,0 +1,7 @@
+export interface MigrationContextType {
+ /**
+ * Determines globally if popover elements using `Popover` component from `@leafygreen-ui/popover` package should render in top layer
+ * @internal
+ */
+ forceUseTopLayer?: boolean;
+}
diff --git a/packages/leafygreen-provider/src/MigrationContext/index.ts b/packages/leafygreen-provider/src/MigrationContext/index.ts
new file mode 100644
index 0000000000..c9a0d2892f
--- /dev/null
+++ b/packages/leafygreen-provider/src/MigrationContext/index.ts
@@ -0,0 +1,6 @@
+export {
+ MigrationContext,
+ MigrationProvider,
+ useMigrationContext,
+} from './MigrationContext';
+export { type MigrationContextType } from './MigrationContext.types';
diff --git a/packages/leafygreen-provider/src/PopoverContext/PopoverContext.spec.tsx b/packages/leafygreen-provider/src/PopoverContext/PopoverContext.spec.tsx
index 00b3d8dd18..f499c4e247 100644
--- a/packages/leafygreen-provider/src/PopoverContext/PopoverContext.spec.tsx
+++ b/packages/leafygreen-provider/src/PopoverContext/PopoverContext.spec.tsx
@@ -1,11 +1,12 @@
-import React, { PropsWithChildren } from 'react';
-import { act, fireEvent, render, waitFor } from '@testing-library/react';
+import React from 'react';
+import { act, render } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { renderHook } from '@leafygreen-ui/testing-lib';
-import { PopoverProvider, type PopoverState, usePopoverContext } from '.';
+import { PopoverProvider, usePopoverContext } from './PopoverContext';
-const childTestID = 'popover-provider';
+const childTestID = 'modal-popover-provider';
const buttonTestId = 'test-button';
function TestContextComponent() {
@@ -39,41 +40,23 @@ describe('packages/leafygreen-provider/PopoverContext', () => {
const { container, testChild } = renderProvider();
expect(container.firstChild).toBe(testChild);
});
-
- test('isPopoverOpen is initialized as false', () => {
- const { testChild } = renderProvider();
- expect(testChild.textContent).toBe('false');
- });
-
- test('when passed true, setIsPopoverOpen sets isPopoverOpen to true', () => {
- const { testChild, getByTestId } = renderProvider();
-
- // The button's click handler fires setIsPopoverOpen(true)
- fireEvent.click(getByTestId(buttonTestId));
-
- expect(testChild.textContent).toBe('true');
- });
});
describe('usePopoverContext', () => {
- test('is `false` by default', () => {
+ test('`isPopoverOpen` is `false` by default', () => {
const { result } = renderHook(usePopoverContext);
expect(result.current.isPopoverOpen).toBeFalsy();
});
- test('setter updates the value', async () => {
- const { result, rerender } = renderHook<
- PropsWithChildren<{}>,
- PopoverState
- >(usePopoverContext, {
+ test('`setIsPopoverOpen` updates the value of `isPopoverOpen`', async () => {
+ const { result, rerender } = renderHook(usePopoverContext, {
wrapper: ({ children }) => {children} ,
});
act(() => result.current.setIsPopoverOpen(true));
rerender();
- await waitFor(() => {
- expect(result.current.isPopoverOpen).toBe(true);
- });
+
+ expect(result.current.isPopoverOpen).toBe(true);
});
describe('with test component', () => {
@@ -92,7 +75,7 @@ describe('usePopoverContext', () => {
const { testChild, getByTestId } = renderTestComponent();
// The button's click handler fires setIsPopoverOpen(true)
- fireEvent.click(getByTestId(buttonTestId));
+ userEvent.click(getByTestId(buttonTestId));
expect(testChild.textContent).toBe('false');
});
diff --git a/packages/leafygreen-provider/src/PopoverContext/PopoverContext.tsx b/packages/leafygreen-provider/src/PopoverContext/PopoverContext.tsx
index 125cab98fb..ccdb20472b 100644
--- a/packages/leafygreen-provider/src/PopoverContext/PopoverContext.tsx
+++ b/packages/leafygreen-provider/src/PopoverContext/PopoverContext.tsx
@@ -1,40 +1,34 @@
-import React, { createContext, useContext, useMemo, useState } from 'react';
+import React, {
+ createContext,
+ PropsWithChildren,
+ useContext,
+ useMemo,
+ useState,
+} from 'react';
import PropTypes from 'prop-types';
-export interface PopoverState {
- /**
- * Whether the most immediate popover ancestor is open
- */
- isPopoverOpen: boolean;
- /**
- * Sets the internal state
- * @internal
- */
- setIsPopoverOpen: React.Dispatch>;
-}
-
-export const PopoverContext = createContext({
+import { PopoverContextType } from './PopoverContext.types';
+
+export const PopoverContext = createContext({
isPopoverOpen: false,
setIsPopoverOpen: () => {},
});
/**
- * Access the popover state
- * @returns `isPopoverOpen: boolean`
+ * Access the popover context to read and write if a popover element is open in a modal
*/
-export function usePopoverContext(): PopoverState {
+export const usePopoverContext = (): PopoverContextType => {
return useContext(PopoverContext);
-}
-
-interface PopoverProviderProps {
- children?: React.ReactNode;
-}
+};
/**
- * Creates a Popover context.
+ * Creates a Popover context to read and write if a popover element is open in a modal
* Call `usePopoverContext` to access the popover state
+ * This is defined separately from `PopoverPropsContext` to avoid incorrectly resetting `isPopoverOpen` value
+ * We avoid renaming this provider because it will trigger major changes in all packages because
+ * `@leafygreen-ui/leafygreen-provider` is a peer dependency to all LG packages
*/
-export function PopoverProvider({ children }: PopoverProviderProps) {
+export const PopoverProvider = ({ children }: PropsWithChildren<{}>) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const providerValue = useMemo(
@@ -50,7 +44,7 @@ export function PopoverProvider({ children }: PopoverProviderProps) {
{children}
);
-}
+};
PopoverProvider.displayName = 'PopoverProvider';
diff --git a/packages/leafygreen-provider/src/PopoverContext/PopoverContext.types.ts b/packages/leafygreen-provider/src/PopoverContext/PopoverContext.types.ts
new file mode 100644
index 0000000000..862b12862b
--- /dev/null
+++ b/packages/leafygreen-provider/src/PopoverContext/PopoverContext.types.ts
@@ -0,0 +1,12 @@
+export interface PopoverContextType {
+ /**
+ * Whether a popover element is open in a modal
+ */
+ isPopoverOpen: boolean;
+
+ /**
+ * Called when a popover element opens or closes in a modal
+ * @internal
+ */
+ setIsPopoverOpen: React.Dispatch>;
+}
diff --git a/packages/leafygreen-provider/src/PopoverContext/index.ts b/packages/leafygreen-provider/src/PopoverContext/index.ts
index d31edeb0aa..14ccf31e39 100644
--- a/packages/leafygreen-provider/src/PopoverContext/index.ts
+++ b/packages/leafygreen-provider/src/PopoverContext/index.ts
@@ -1,6 +1,5 @@
export {
PopoverContext,
PopoverProvider,
- type PopoverState,
usePopoverContext,
} from './PopoverContext';
diff --git a/packages/leafygreen-provider/src/PopoverPropsContext/PopoverPropsContext.spec.tsx b/packages/leafygreen-provider/src/PopoverPropsContext/PopoverPropsContext.spec.tsx
new file mode 100644
index 0000000000..baae2139f8
--- /dev/null
+++ b/packages/leafygreen-provider/src/PopoverPropsContext/PopoverPropsContext.spec.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+
+import { renderHook } from '@leafygreen-ui/testing-lib';
+
+import {
+ PopoverPropsProvider,
+ usePopoverPropsContext,
+} from './PopoverPropsContext';
+
+const childTestId = 'test-child';
+
+describe('packages/leafygreen-provider/PopoverPropsContext', () => {
+ test('only renders children in the DOM', () => {
+ const { container, getByTestId } = render(
+
+ Child element
+ ,
+ );
+ const testChild = getByTestId(childTestId);
+
+ expect(container.firstChild).toBe(testChild);
+ });
+});
+
+describe('usePopoverPropsContext', () => {
+ test('passes provider props correctly', () => {
+ const mockOnEnter = jest.fn();
+ const customProps = {
+ onEnter: mockOnEnter,
+ popoverZIndex: 2,
+ usePortal: true,
+ };
+ const { result } = renderHook(usePopoverPropsContext, {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ });
+
+ expect(result.current).toHaveProperty('onEnter', mockOnEnter);
+ expect(result.current).toHaveProperty('popoverZIndex', 2);
+ expect(result.current).toHaveProperty('usePortal', true);
+ });
+});
diff --git a/packages/leafygreen-provider/src/PopoverPropsContext/PopoverPropsContext.tsx b/packages/leafygreen-provider/src/PopoverPropsContext/PopoverPropsContext.tsx
new file mode 100644
index 0000000000..e85907feda
--- /dev/null
+++ b/packages/leafygreen-provider/src/PopoverPropsContext/PopoverPropsContext.tsx
@@ -0,0 +1,48 @@
+import React, { createContext, PropsWithChildren, useContext } from 'react';
+import PropTypes from 'prop-types';
+
+import {
+ PortalContextProvider,
+ usePopoverPortalContainer,
+} from '../PortalContext';
+
+import { PopoverPropsProviderProps } from './PopoverPropsContext.types';
+
+export const PopoverPropsContext = createContext({});
+
+/**
+ * Access the popover props context to read props passed to nested popover component instances
+ */
+export const usePopoverPropsContext = (): PopoverPropsProviderProps => {
+ return useContext(PopoverPropsContext);
+};
+
+/**
+ * Creates a PopoverProps context to pass props to a deeply nested popover element
+ * Call `usePopoverPropsContext` to access the popover state
+ * This is defined separately from `PopoverContext` to avoid incorrectly resetting `isPopoverOpen` value
+ */
+export const PopoverPropsProvider = ({
+ children,
+ ...props
+}: PropsWithChildren) => {
+ const popoverPortalContext = usePopoverPortalContainer();
+ const popover = {
+ portalContainer:
+ props.portalContainer || popoverPortalContext.portalContainer,
+ scrollContainer:
+ props.scrollContainer || popoverPortalContext.scrollContainer,
+ };
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+PopoverPropsProvider.displayName = 'PopoverPropsProvider';
+
+PopoverPropsProvider.propTypes = { children: PropTypes.node };
diff --git a/packages/leafygreen-provider/src/PopoverPropsContext/PopoverPropsContext.types.ts b/packages/leafygreen-provider/src/PopoverPropsContext/PopoverPropsContext.types.ts
new file mode 100644
index 0000000000..546b029e4a
--- /dev/null
+++ b/packages/leafygreen-provider/src/PopoverPropsContext/PopoverPropsContext.types.ts
@@ -0,0 +1,207 @@
+import { Transition } from 'react-transition-group';
+
+/**
+ * These types are duplicated in `@leafygreen-ui/popover`: https://github.com/mongodb/leafygreen-ui/blob/02e1d77e5ed7d55f9b8402299eae0c6d540c53f8/packages/popover/src/Popover.types.ts
+ *
+ * We cannot import `PopoverProps` into `@leafygreen-ui/leafygreen-provider` without introducing a circular dependency.
+ */
+
+type TransitionProps = React.ComponentProps>;
+
+type TransitionLifecycleCallbacks = Pick<
+ TransitionProps,
+ 'onEnter' | 'onEntering' | 'onEntered' | 'onExit' | 'onExiting' | 'onExited'
+>;
+
+/**
+ * Options to render the popover element
+ * @param Inline will render the popover element inline in the DOM where it's written
+ * @param Portal will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer`
+ * @param TopLayer will render the popover element in the top layer
+ */
+export const RenderMode = {
+ Inline: 'inline',
+ Portal: 'portal',
+ TopLayer: 'top-layer',
+} as const;
+export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode];
+
+/**
+ * Options to control how the popover element is dismissed. This should not be altered
+ * because it is intended to have parity with the web-native {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/popover popover attribute}
+ * @param Auto will automatically handle dismissal on backdrop click or esc key press, ensuring only one popover is visible at a time
+ * @param Manual will require that the consumer handle dismissal manually
+ */
+const DismissMode = {
+ Auto: 'auto',
+ Manual: 'manual',
+} as const;
+type DismissMode = (typeof DismissMode)[keyof typeof DismissMode];
+
+/** Local implementation of web-native `ToggleEvent` until we use typescript v5 */
+interface ToggleEvent extends Event {
+ type: 'toggle';
+ newState: 'open' | 'closed';
+ oldState: 'open' | 'closed';
+}
+
+export interface RenderInlineProps {
+ /**
+ * Options to render the popover element
+ * @defaultValue 'top-layer'
+ * @param Inline will render the popover element inline in the DOM where it's written. This option is deprecated and will be removed in the future.
+ * @param Portal will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer`. This option is deprecated and will be removed in the future.
+ * @param TopLayer will render the popover element in the top layer
+ */
+ renderMode: 'inline';
+
+ /**
+ * When `renderMode="top-layer"`, these options can control how a popover element is dismissed
+ * - `'auto'` will automatically handle dismissal on backdrop click or key press, ensuring only one popover is visible at a time
+ * - `'manual'` will require that the consumer handle dismissal manually
+ */
+ dismissMode?: never;
+
+ /**
+ * A callback function that is called when the visibility of a popover element rendered in the top layer is toggled
+ */
+ onToggle?: never;
+
+ /**
+ * When `renderMode="portal"`, it specifies a class name to apply to the portal element
+ * @deprecated
+ */
+ portalClassName?: never;
+
+ /**
+ * When `renderMode="portal"`, it specifies an element to portal within. If not provided, a div is generated at the end of the body
+ * @deprecated
+ */
+ portalContainer?: never;
+
+ /**
+ * When `renderMode="portal"`, it passes a ref to forward to the portal element
+ * @deprecated
+ */
+ portalRef?: never;
+
+ /**
+ * When `renderMode="portal"`, it specifies the scrollable element to position relative to
+ * @deprecated
+ */
+ scrollContainer?: never;
+}
+
+export interface RenderPortalProps {
+ /**
+ * Options to render the popover element
+ * @defaultValue 'top-layer'
+ * @param Inline will render the popover element inline in the DOM where it's written. This option is deprecated and will be removed in the future.
+ * @param Portal will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer`. This option is deprecated and will be removed in the future.
+ * @param TopLayer will render the popover element in the top layer
+ */
+ renderMode: 'portal';
+
+ /**
+ * When `renderMode="top-layer"`, these options can control how a popover element is dismissed
+ * - `'auto'` will automatically handle dismissal on backdrop click or key press, ensuring only one popover is visible at a time
+ * - `'manual'` will require that the consumer handle dismissal manually
+ */
+ dismissMode?: never;
+
+ /**
+ * When `renderMode="top-layer"`, this callback function is called when the visibility of a popover element is toggled
+ */
+ onToggle?: never;
+
+ /**
+ * When `renderMode="portal"`, it specifies a class name to apply to the portal element
+ * @deprecated
+ */
+ portalClassName?: string;
+
+ /**
+ * When `renderMode="portal"`, it specifies an element to portal within. If not provided, a div is generated at the end of the body
+ * @deprecated
+ */
+ portalContainer?: HTMLElement | null;
+
+ /**
+ * When `renderMode="portal"`, it passes a ref to forward to the portal element
+ * @deprecated
+ */
+ portalRef?: React.MutableRefObject;
+
+ /**
+ * When `renderMode="portal"`, it specifies the scrollable element to position relative to
+ * @deprecated
+ */
+ scrollContainer?: HTMLElement | null;
+}
+
+export interface RenderTopLayerProps {
+ /**
+ * Options to render the popover element
+ * @defaultValue 'top-layer'
+ * @param Inline will render the popover element inline in the DOM where it's written. This option is deprecated and will be removed in the future.
+ * @param Portal will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer`. This option is deprecated and will be removed in the future.
+ * @param TopLayer will render the popover element in the top layer
+ */
+ renderMode?: 'top-layer';
+
+ /**
+ * When `renderMode="top-layer"`, these options can control how a popover element is dismissed
+ * - `'auto'` will automatically handle dismissal on backdrop click or key press, ensuring only one popover is visible at a time
+ * - `'manual'` will require that the consumer handle dismissal manually
+ */
+ dismissMode?: DismissMode;
+
+ /**
+ * A callback function that is called when the visibility of a popover element rendered in the top layer is toggled
+ */
+ onToggle?: (e: ToggleEvent) => void;
+
+ /**
+ * When `renderMode="portal"`, it specifies a class name to apply to the portal element
+ * @deprecated
+ */
+ portalClassName?: never;
+
+ /**
+ * When `renderMode="portal"`, it specifies an element to portal within. If not provided, a div is generated at the end of the body
+ * @deprecated
+ */
+ portalContainer?: never;
+
+ /**
+ * When `renderMode="portal"`, it passes a ref to forward to the portal element
+ * @deprecated
+ */
+ portalRef?: never;
+
+ /**
+ * When `renderMode="portal"`, it specifies the scrollable element to position relative to
+ * @deprecated
+ */
+ scrollContainer?: never;
+}
+
+type PopoverRenderModeProps =
+ | RenderPortalProps
+ | RenderInlineProps
+ | RenderTopLayerProps;
+
+export type PopoverPropsProviderProps = {
+ /**
+ * Specifies the amount of spacing (in pixels) between the trigger element and the Popover content.
+ *
+ * default: `10`
+ */
+ spacing?: number;
+
+ /**
+ * Number that controls the z-index of the popover element directly.
+ */
+ popoverZIndex?: number;
+} & PopoverRenderModeProps &
+ TransitionLifecycleCallbacks;
diff --git a/packages/leafygreen-provider/src/PopoverPropsContext/index.ts b/packages/leafygreen-provider/src/PopoverPropsContext/index.ts
new file mode 100644
index 0000000000..26162fc2e5
--- /dev/null
+++ b/packages/leafygreen-provider/src/PopoverPropsContext/index.ts
@@ -0,0 +1,9 @@
+export {
+ PopoverPropsContext,
+ PopoverPropsProvider,
+ usePopoverPropsContext,
+} from './PopoverPropsContext';
+export {
+ type PopoverPropsProviderProps,
+ RenderMode,
+} from './PopoverPropsContext.types';
diff --git a/packages/leafygreen-provider/src/PortalContext.tsx b/packages/leafygreen-provider/src/PortalContext.tsx
index a1794b3a4b..dc2930f069 100644
--- a/packages/leafygreen-provider/src/PortalContext.tsx
+++ b/packages/leafygreen-provider/src/PortalContext.tsx
@@ -29,7 +29,7 @@ interface PortalContext {
children: React.ReactNode;
}
-export default function PortalContextProvider({
+export function PortalContextProvider({
popover = defaultPortalContextValues.popover,
children,
}: PortalContext) {
diff --git a/packages/leafygreen-provider/src/index.ts b/packages/leafygreen-provider/src/index.ts
index 55cb6400dd..688fdd0468 100644
--- a/packages/leafygreen-provider/src/index.ts
+++ b/packages/leafygreen-provider/src/index.ts
@@ -1,12 +1,21 @@
export { useDarkMode, useDarkModeContext } from './DarkModeContext';
-export { default, type LeafyGreenProviderProps } from './LeafyGreenContext';
+export { default } from './LeafyGreenContext';
+export { type LeafyGreenProviderProps } from './LeafyGreenContext.types';
+export { useMigrationContext } from './MigrationContext';
export {
PopoverContext,
PopoverProvider,
usePopoverContext,
} from './PopoverContext';
export {
- default as PortalContextProvider,
+ PopoverPropsContext,
+ PopoverPropsProvider,
+ type PopoverPropsProviderProps,
+ RenderMode,
+ usePopoverPropsContext,
+} from './PopoverPropsContext';
+export {
+ PortalContextProvider,
usePopoverPortalContainer,
} from './PortalContext';
export { useBaseFontSize } from './TypographyContext';
diff --git a/packages/menu/README.md b/packages/menu/README.md
index 9efda94fa9..43d819e88a 100644
--- a/packages/menu/README.md
+++ b/packages/menu/README.md
@@ -207,24 +207,23 @@ would render, but without the correct styles.
## Properties
-| Prop | Type | Description | Default |
-| ------------------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
-| `open` | `boolean` | Determines whether or not the ` ` will appear as open or closed | `false` |
-| `setOpen` | `function` | When controlling the component, use `setOpen` to keep track of the ` ` component's state so that clicks on the document's backdrop as well as a user pressing the Escape Key will close the Menu and update the consuming application's local state accordingly. | |
-| `initialOpen` | `boolean` | Passes an initial value for "open" to an uncontrolled menu | `false` |
-| `shouldClose` | `function` | Determines if the `Menu` should close when the backdrop or Escape keys are clicked. Defaults to true. | `() => true` |
-| `align` | `'top'`, `'bottom'`, `'left'`, `'right'` | Determines the alignment of the ` ` component relative to a reference element, or the element's nearest parent | `'bottom'` |
-| `justify` | `'start'`, `'middle'`, `'end'` | Determines the justification of the `Menu` component (based on the alignment) relative to a reference element or the element's nearest parent | `'end'` |
-| `refEl` | `HTMLElement` | Pass a reference to an element that the `Menu` component should be positioned against | |
-| `trigger` | `function`, `React.ReactNode` | A `ReactNode` against which the Menu will be positioned. The trigger prop can also support being passed a function. To work as expected, the function must accept an argument of `children`, which should be rendered inside of the function passed to trigger. | |
-| `usePortal` | `boolean` | Will position Menu's children relative to its parent without using a Portal if `usePortal` is set to false. NOTE: The parent element should be CSS position relative, fixed, or absolute if using this option. | `true` |
-| `adjustOnMutation` | `boolean` | Determines whether or not the ` ` should reposition itself based on changes to `trigger` or reference element position. | `false` |
-| `usePortal` | `boolean` | Determines if the Menu will be rendered within a portal. | `true` |
-| `portalContainer` | `HTMLElement`, `null` | Sets the container used for the popover's portal. | |
-| `scrollContainer` | `HTMLElement`, `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that lement to allow the portal to position properly. | |
-| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
-| `popoverZIndex` | `number` | Sets the z-index CSS property for the popover. | |
-| `darkMode` | `boolean` | Determines whether or not the component will be rendered in dark theme. | |
+| Prop | Type | Description | Default |
+| ------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
+| `open` | `boolean` | Determines whether or not the ` ` will appear as open or closed | `false` |
+| `setOpen` | `function` | When controlling the component, use `setOpen` to keep track of the ` ` component's state so that clicks on the document's backdrop as well as a user pressing the Escape Key will close the Menu and update the consuming application's local state accordingly. | |
+| `initialOpen` | `boolean` | Passes an initial value for "open" to an uncontrolled menu | `false` |
+| `shouldClose` | `function` | Determines if the `Menu` should close when the backdrop or Escape keys are clicked. Defaults to true. | `() => true` |
+| `align` | `'top'`, `'bottom'`, `'left'`, `'right'` | Determines the alignment of the ` ` component relative to a reference element, or the element's nearest parent | `'bottom'` |
+| `justify` | `'start'`, `'middle'`, `'end'` | Determines the justification of the `Menu` component (based on the alignment) relative to a reference element or the element's nearest parent | `'end'` |
+| `refEl` | `HTMLElement` | Pass a reference to an element that the `Menu` component should be positioned against | |
+| `trigger` | `function`, `React.ReactNode` | A `ReactNode` against which the Menu will be positioned. The trigger prop can also support being passed a function. To work as expected, the function must accept an argument of `children`, which should be rendered inside of the function passed to trigger. | |
+| `adjustOnMutation` | `boolean` | Determines whether or not the ` ` should reposition itself based on changes to `trigger` or reference element position. | `false` |
+| `renderMode` | `'inline'` \| `'portal'` \| `'top-layer'` | Options to render the popover element \* [deprecated] `'inline'` will render the popover element inline in the DOM where it's written \* [deprecated] `'portal'` will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer` \* `'top-layer'` will render the popover element in the top layer | `'top-layer'` |
+| `portalContainer` (deprecated) | `HTMLElement`, `null` | Sets the container used for the popover's portal. | |
+| `scrollContainer` (deprecated) | `HTMLElement`, `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that lement to allow the portal to position properly. | |
+| `portalClassName` (deprecated) | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
+| `popoverZIndex` (deprecated) | `number` | Sets the z-index CSS property for the popover. | |
+| `darkMode` | `boolean` | Determines whether or not the component will be rendered in dark theme. | |
_Any other properties will be spread on the Menu `ul` container_
diff --git a/packages/menu/src/Menu.spec.tsx b/packages/menu/src/Menu.spec.tsx
index 0067ecb326..cab9246189 100644
--- a/packages/menu/src/Menu.spec.tsx
+++ b/packages/menu/src/Menu.spec.tsx
@@ -10,6 +10,7 @@ import {
import userEvent from '@testing-library/user-event';
import { Optional } from '@leafygreen-ui/lib';
+import { RenderMode } from '@leafygreen-ui/popover';
import { waitForTransition } from '@leafygreen-ui/testing-lib';
import { LGIDs } from './constants';
@@ -255,9 +256,9 @@ describe('packages/menu', () => {
expect(menuEl).not.toBeInTheDocument();
});
- test('Returns focus to trigger {usePortal: true}', async () => {
+ test(`Returns focus to trigger when renderMode=${RenderMode.TopLayer}`, async () => {
const { openMenu, triggerEl } = renderMenu({
- usePortal: true,
+ renderMode: RenderMode.TopLayer,
});
const { menuEl } = await openMenu();
@@ -266,9 +267,20 @@ describe('packages/menu', () => {
expect(triggerEl).toHaveFocus();
});
- test('Returns focus to trigger {usePortal: false}', async () => {
+ test(`Returns focus to trigger when renderMode=${RenderMode.Portal}`, async () => {
const { openMenu, triggerEl } = renderMenu({
- usePortal: false,
+ renderMode: RenderMode.Portal,
+ });
+ const { menuEl } = await openMenu();
+
+ userEventInteraction(menuEl!, key);
+ await waitForElementToBeRemoved(menuEl);
+ expect(triggerEl).toHaveFocus();
+ });
+
+ test(`Returns focus to trigger when renderMode=${RenderMode.Inline}`, async () => {
+ const { openMenu, triggerEl } = renderMenu({
+ renderMode: RenderMode.Inline,
});
const { menuEl } = await openMenu();
@@ -282,10 +294,8 @@ describe('packages/menu', () => {
describe('Down arrow', () => {
test('highlights the next option in the menu', async () => {
const { openMenu } = renderMenu({});
- const { menuItemElements } = await openMenu();
-
- userEvent.keyboard('{arrowdown}');
- expect(menuItemElements[0]).toHaveFocus();
+ const { menuItemElements } = await openMenu({ withKeyboard: true });
+ await waitFor(() => expect(menuItemElements[0]).toHaveFocus());
userEvent.keyboard('{arrowdown}');
expect(menuItemElements[1]).toHaveFocus();
@@ -293,9 +303,10 @@ describe('packages/menu', () => {
test('cycles highlight to the top', async () => {
const { openMenu } = renderMenu({});
- const { menuItemElements } = await openMenu();
+ const { menuItemElements } = await openMenu({ withKeyboard: true });
+ await waitFor(() => expect(menuItemElements[0]).toHaveFocus());
- for (let i = 0; i <= menuItemElements.length; i++) {
+ for (let i = 0; i < menuItemElements.length; i++) {
userEvent.keyboard('{arrowdown}');
}
@@ -317,7 +328,7 @@ describe('packages/menu', () => {
const { menuItemElements } = await openMenu({ withKeyboard: true });
expect(menuItemElements).toHaveLength(3);
- expect(queryByTestId('submenu')).toHaveFocus();
+ await waitFor(() => expect(queryByTestId('submenu')).toHaveFocus());
userEvent.keyboard('{arrowdown}');
expect(queryByTestId('item-a')).toHaveFocus();
});
@@ -337,7 +348,7 @@ describe('packages/menu', () => {
const { menuItemElements } = await openMenu({ withKeyboard: true });
expect(menuItemElements).toHaveLength(2);
- expect(queryByTestId('submenu')).toHaveFocus();
+ await waitFor(() => expect(queryByTestId('submenu')).toHaveFocus());
userEvent.keyboard('{arrowdown}');
expect(queryByTestId('item-c')).toHaveFocus();
});
@@ -347,9 +358,9 @@ describe('packages/menu', () => {
describe('Up arrow', () => {
test('highlights the previous option in the menu', async () => {
const { openMenu } = renderMenu({});
- const { menuItemElements } = await openMenu();
+ const { menuItemElements } = await openMenu({ withKeyboard: true });
+ await waitFor(() => expect(menuItemElements[0]).toHaveFocus());
- userEvent.keyboard('{arrowdown}');
userEvent.keyboard('{arrowdown}');
expect(menuItemElements[1]).toHaveFocus();
@@ -359,7 +370,8 @@ describe('packages/menu', () => {
test('cycles highlight to the bottom', async () => {
const { openMenu } = renderMenu({});
- const { menuItemElements } = await openMenu();
+ const { menuItemElements } = await openMenu({ withKeyboard: true });
+ await waitFor(() => expect(menuItemElements[0]).toHaveFocus());
const lastOption = menuItemElements[menuItemElements.length - 1];
userEvent.keyboard('{arrowup}');
@@ -378,7 +390,7 @@ describe('packages/menu', () => {
const firstItem = menuItemElements[0];
- expect(firstItem).toHaveFocus();
+ await waitFor(() => expect(firstItem).toHaveFocus());
userEvent.keyboard('[Enter]');
@@ -396,7 +408,7 @@ describe('packages/menu', () => {
const firstItem = menuItemElements[0];
- expect(firstItem).toHaveFocus();
+ await waitFor(() => expect(firstItem).toHaveFocus());
userEvent.keyboard('[Space]');
@@ -427,7 +439,8 @@ describe('packages/menu', () => {
});
await openMenu({ withKeyboard: true });
- expect(queryByTestId('submenu')).toHaveFocus();
+ await waitFor(() => expect(queryByTestId('submenu')).toHaveFocus());
+
userEvent.click(getByTestId(LGIDs.submenuToggle)!);
await waitForTransition();
await waitFor(() => {
@@ -451,7 +464,8 @@ describe('packages/menu', () => {
});
await openMenu({ withKeyboard: true });
- expect(queryByTestId('submenu')).toHaveFocus();
+ await waitFor(() => expect(queryByTestId('submenu')).toHaveFocus());
+
userEvent.keyboard('{arrowright}');
userEvent.keyboard('{arrowdown}');
expect(queryByTestId('item-a')).toHaveFocus();
@@ -484,7 +498,8 @@ describe('packages/menu', () => {
),
});
await openMenu({ withKeyboard: true });
- expect(queryByTestId('submenu')).toHaveFocus();
+ await waitFor(() => expect(queryByTestId('submenu')).toHaveFocus());
+
userEvent.keyboard('{arrowup}');
expect(queryByTestId('item-c')).toHaveFocus();
@@ -513,7 +528,8 @@ describe('packages/menu', () => {
),
});
await openMenu({ withKeyboard: true });
- expect(queryByTestId('submenu')).toHaveFocus();
+ await waitFor(() => expect(queryByTestId('submenu')).toHaveFocus());
+
userEvent.keyboard('{arrowright}'); // open the submenu
userEvent.keyboard('{arrowup}');
expect(queryByTestId('item-c')).toHaveFocus();
diff --git a/packages/menu/src/Menu.stories.tsx b/packages/menu/src/Menu.stories.tsx
index 67614b59ec..81bec1bd2f 100644
--- a/packages/menu/src/Menu.stories.tsx
+++ b/packages/menu/src/Menu.stories.tsx
@@ -15,7 +15,7 @@ import Icon from '@leafygreen-ui/icon';
import CaretDown from '@leafygreen-ui/icon/dist/CaretDown';
import CloudIcon from '@leafygreen-ui/icon/dist/Cloud';
import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider';
-import { Align, Justify } from '@leafygreen-ui/popover';
+import { Align, Justify, RenderMode } from '@leafygreen-ui/popover';
import { TestUtils } from '@leafygreen-ui/popover';
const { getAlign, getJustify } = TestUtils;
@@ -47,7 +47,13 @@ export default {
decorators: [
(StoryFn, _ctx) => (
-
+
+
+
),
],
@@ -63,13 +69,39 @@ export default {
'setOpen',
'size',
'trigger',
- 'usePortal',
],
},
+ generate: {
+ storyNames: [
+ 'LightModeTopAlign',
+ 'DarkModeTopAlign',
+ 'LightModeBottomAlign',
+ 'DarkModeBottomAlign',
+ 'LightModeLeftAlign',
+ 'DarkModeLeftAlign',
+ 'LightModeRightAlign',
+ 'DarkModeRightAlign',
+ ],
+ combineArgs: {
+ justify: Object.values(Justify),
+ },
+ excludeCombinations: [
+ {
+ align: [Align.CenterHorizontal, Align.CenterVertical],
+ },
+ ],
+ decorator: (Instance, ctx) => (
+
+
+
+
+
+ ),
+ },
},
args: {
- align: 'bottom',
- usePortal: true,
+ align: Align.Bottom,
+ renderMode: RenderMode.TopLayer,
darkMode: false,
renderDarkMenu: false,
},
@@ -196,7 +228,7 @@ export const Controlled = {
'setOpen',
'as',
'portalRef',
- 'usePortal',
+ 'renderMode',
'align',
'darkMode',
'justify',
@@ -210,58 +242,101 @@ export const Controlled = {
},
} satisfies StoryObj;
-export const Generated = {
- render: () => <>>,
+const sharedGeneratedStoryArgs = {
+ open: true,
+ maxHeight: 200,
+ children: (
+ <>
+ Lorem
+ }
+ active={true}
+ >
+ Apple
+ Banana
+ Carrot
+ Dragonfruit
+ Eggplant
+ Fig
+
+ >
+ ),
+ trigger: trigger ,
+};
+
+export const LightModeTopAlign = {
+ render: <>>,
args: {
- open: true,
- maxHeight: 200,
- children: (
- <>
- Lorem
- }
- active={true}
- >
- Apple
- Banana
- Carrot
- Dragonfruit
- Eggplant
- Fig
-
- >
- ),
- trigger: trigger ,
+ ...sharedGeneratedStoryArgs,
+ darkMode: false,
+ align: Align.Top,
},
- parameters: {
- generate: {
- combineArgs: {
- darkMode: [false, true],
- align: Object.values(Align),
- justify: Object.values(Justify),
- },
+};
- excludeCombinations: [
- {
- align: [Align.CenterHorizontal, Align.CenterVertical],
- },
- {
- justify: Justify.Fit,
- align: [Align.Left, Align.Right],
- },
- ],
- decorator: (Instance, ctx) => (
-
-
-
-
-
- ),
- },
+export const DarkModeTopAlign = {
+ render: <>>,
+ args: {
+ ...sharedGeneratedStoryArgs,
+ darkMode: true,
+ align: Align.Top,
},
-} satisfies StoryObj;
+};
+
+export const LightModeBottomAlign = {
+ render: <>>,
+ args: {
+ ...sharedGeneratedStoryArgs,
+ darkMode: false,
+ align: Align.Bottom,
+ },
+};
+
+export const DarkModeBottomAlign = {
+ render: <>>,
+ args: {
+ ...sharedGeneratedStoryArgs,
+ darkMode: true,
+ align: Align.Bottom,
+ },
+};
+
+export const LightModeLeftAlign = {
+ render: <>>,
+ args: {
+ ...sharedGeneratedStoryArgs,
+ darkMode: false,
+ align: Align.Left,
+ },
+};
+
+export const DarkModeLeftAlign = {
+ render: <>>,
+ args: {
+ ...sharedGeneratedStoryArgs,
+ darkMode: true,
+ align: Align.Left,
+ },
+};
+
+export const LightModeRightAlign = {
+ render: <>>,
+ args: {
+ ...sharedGeneratedStoryArgs,
+ darkMode: false,
+ align: Align.Right,
+ },
+};
+
+export const DarkModeRightAlign = {
+ render: <>>,
+ args: {
+ ...sharedGeneratedStoryArgs,
+ darkMode: true,
+ align: Align.Right,
+ },
+};
export const InitialLongMenuOpen = {
render: () => {
diff --git a/packages/menu/src/Menu/Menu.tsx b/packages/menu/src/Menu/Menu.tsx
index 6d138267cd..c2d3577482 100644
--- a/packages/menu/src/Menu/Menu.tsx
+++ b/packages/menu/src/Menu/Menu.tsx
@@ -9,7 +9,13 @@ import { css, cx } from '@leafygreen-ui/emotion';
import { useBackdropClick, useEventListener } from '@leafygreen-ui/hooks';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
import { isDefined, keyMap, Theme } from '@leafygreen-ui/lib';
-import Popover, { Align, Justify } from '@leafygreen-ui/popover';
+import Popover, {
+ Align,
+ DismissMode,
+ getPopoverRenderModeProps,
+ Justify,
+ RenderMode,
+} from '@leafygreen-ui/popover';
import { LGIDs } from '../constants';
import { useHighlightReducer } from '../HighlightReducer';
@@ -40,7 +46,7 @@ import { MenuProps } from './Menu.types';
* @param props.align Alignment of Menu relative to another element: `top`, `bottom`, `left`, `right`.
* @param props.justify Justification of Menu relative to another element: `start`, `middle`, `end`.
* @param props.refEl Reference element that Menu should be positioned against.
- * @param props.usePortal Boolean to describe if content should be portaled to end of DOM, or appear in DOM tree.
+ * @param props.renderMode Options to render the popover element: `inline`, `portal`, `top-layer`.
* @param props.trigger Trigger element can be ReactNode or function, and, if present, internally manages active state of Menu.
* @param props.darkMode Determines whether or not the component will be rendered in dark theme.
*/
@@ -52,7 +58,6 @@ export const Menu = React.forwardRef(function Menu(
shouldClose = () => true,
spacing = 6,
maxHeight = 344,
- usePortal = true,
initialOpen = false,
open: controlledOpen,
setOpen: controlledSetOpen,
@@ -62,6 +67,7 @@ export const Menu = React.forwardRef(function Menu(
className,
refEl,
trigger,
+ renderMode = RenderMode.TopLayer,
portalClassName,
portalContainer,
portalRef,
@@ -74,7 +80,8 @@ export const Menu = React.forwardRef(function Menu(
const { theme, darkMode } = useDarkMode(darkModeProp);
const popoverRef = useRef(null);
- const triggerRef = useRef(null);
+ const defaultTriggerRef = useRef(null);
+ const triggerRef = refEl ?? defaultTriggerRef;
const keyboardUsedRef = useRef(false);
const [uncontrolledOpen, uncontrolledSetOpen] = useState(initialOpen);
@@ -91,7 +98,7 @@ export const Menu = React.forwardRef(function Menu(
}, [setOpen, shouldClose]);
const maxMenuHeightValue = useMenuHeight({
- refEl: refEl || triggerRef,
+ refEl: triggerRef,
spacing,
maxHeight,
});
@@ -137,14 +144,14 @@ export const Menu = React.forwardRef(function Menu(
break;
case keyMap.Tab:
- e.preventDefault(); // Prevent tabbing outside of portal and outside of the DOM when `usePortal={true}`
+ e.preventDefault(); // Prevent tabbing outside of portal and outside of the DOM when `renderMode="portal"`
handleClose();
- (refEl || triggerRef)?.current?.focus(); // Focus the trigger on close
+ triggerRef?.current?.focus(); // Focus the trigger on close
break;
case keyMap.Escape:
handleClose();
- (refEl || triggerRef)?.current?.focus(); // Focus the trigger on close
+ triggerRef?.current?.focus(); // Focus the trigger on close
break;
case keyMap.Space:
@@ -160,17 +167,16 @@ export const Menu = React.forwardRef(function Menu(
const popoverProps = {
popoverZIndex,
- ...(usePortal
- ? {
- spacing,
- usePortal,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
- }
- : { spacing, usePortal }),
- };
+ spacing,
+ ...getPopoverRenderModeProps({
+ dismissMode: DismissMode.Manual,
+ portalClassName,
+ portalContainer,
+ portalRef,
+ renderMode,
+ scrollContainer,
+ }),
+ } as const;
const popoverContent = (
@@ -189,7 +195,7 @@ export const Menu = React.forwardRef(function Menu(
active={open}
align={align}
justify={justify}
- refEl={refEl}
+ refEl={triggerRef}
adjustOnMutation={adjustOnMutation}
onEntered={handlePopoverOpen}
data-testid={LGIDs.root}
@@ -252,29 +258,32 @@ export const Menu = React.forwardRef(function Menu(
};
if (typeof trigger === 'function') {
- return trigger({
- onClick: triggerClickHandler,
- ref: triggerRef,
- children: popoverContent,
- ['aria-expanded']: open,
- ['aria-haspopup']: true,
- });
+ return (
+ <>
+ {trigger({
+ onClick: triggerClickHandler,
+ ref: triggerRef,
+ ['aria-expanded']: open,
+ ['aria-haspopup']: true,
+ })}
+ {popoverContent}
+ >
+ );
}
const renderedTrigger = React.cloneElement(trigger, {
ref: triggerRef,
onClick: triggerClickHandler,
- children: (
- <>
- {trigger.props.children}
- {popoverContent}
- >
- ),
['aria-expanded']: open,
['aria-haspopup']: true,
});
- return renderedTrigger;
+ return (
+ <>
+ {renderedTrigger}
+ {popoverContent}
+ >
+ );
}
return popoverContent;
@@ -299,7 +308,7 @@ Menu.propTypes = {
? PropTypes.instanceOf(Element)
: PropTypes.any,
}),
- usePortal: PropTypes.bool,
+ renderMode: PropTypes.oneOf(Object.values(RenderMode)),
trigger: PropTypes.oneOfType([PropTypes.node, PropTypes.func]),
open: PropTypes.bool,
setOpen: PropTypes.func,
diff --git a/packages/menu/src/Menu/Menu.types.ts b/packages/menu/src/Menu/Menu.types.ts
index 0faaacb6b8..48399be7df 100644
--- a/packages/menu/src/Menu/Menu.types.ts
+++ b/packages/menu/src/Menu/Menu.types.ts
@@ -12,7 +12,8 @@ export type SubMenuType = ReactElement<
InferredPolymorphicPropsWithRef
>;
-export interface MenuProps extends Omit {
+export interface MenuProps
+ extends Omit {
/**
* The menu items, or submenus
* @type ` ` | ` ` | ` ` | ` `
diff --git a/packages/modal/package.json b/packages/modal/package.json
index 66551cb6ea..dfab28b25b 100644
--- a/packages/modal/package.json
+++ b/packages/modal/package.json
@@ -38,11 +38,11 @@
},
"devDependencies": {
"@faker-js/faker": "8.0.2",
+ "@leafygreen-ui/button": "^21.2.0",
+ "@leafygreen-ui/code": "^14.3.3",
+ "@leafygreen-ui/copyable": "^8.0.25",
"@leafygreen-ui/select": "^12.1.0",
"@leafygreen-ui/typography": "^19.1.0",
- "@leafygreen-ui/copyable": "^8.0.25",
- "@leafygreen-ui/code": "^14.3.3",
- "@leafygreen-ui/button": "^21.2.0",
"@lg-tools/storybook-utils": "^0.1.1"
},
"peerDependencies": {
diff --git a/packages/modal/src/Modal.stories.tsx b/packages/modal/src/Modal.stories.tsx
index f3868558d9..d5794209a7 100644
--- a/packages/modal/src/Modal.stories.tsx
+++ b/packages/modal/src/Modal.stories.tsx
@@ -156,7 +156,6 @@ export const DefaultSelect = (args: ModalProps) => {
name="pets"
value={value}
onChange={setValue}
- usePortal={true}
>
Dog
diff --git a/packages/modal/src/Modal/Modal.spec.tsx b/packages/modal/src/Modal/Modal.spec.tsx
index b04c027e1a..ceb24e2a4d 100644
--- a/packages/modal/src/Modal/Modal.spec.tsx
+++ b/packages/modal/src/Modal/Modal.spec.tsx
@@ -139,7 +139,6 @@ describe('packages/modal', () => {
size="small"
placeholder="animals"
name="pets"
- usePortal={true}
data-testid="modal-select-test-id"
>
diff --git a/packages/number-input/README.md b/packages/number-input/README.md
index f6fccb5062..fd80f1928c 100644
--- a/packages/number-input/README.md
+++ b/packages/number-input/README.md
@@ -62,29 +62,25 @@ or
## Properties
-| Prop | Type | Description | Default |
-| ----------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------- |
-| `id` | `string` | id associated with the number input. | |
-| `label` | `string` | Label shown above the number input. | |
-| `description` | `string` | Text shown above the number input but below the label; gives more details about the requirements for the input. | |
-| `value` | `string` | The controlled value of the number input. | |
-| `disabled` | `boolean` | Disables the component. | `false` |
-| `placeholder` | `string` | The placeholder text shown in the input field before the user begins typing. | |
-| `size` | `'xsmall'` \| `'small'` \| `'default'` \| `'large'` | Determines the size of the input. | `default` |
-| `state` | `'none'`\| `'error'` | Describes the state of the TextInput element before and after the input has been validated | `'none'` |
-| `errorMessage` | `string` | Error message that appears below the input. Renders only if `state='error'`. | `'This input needs your attention'` |
-| `successMessage` | `string` | Success message that appears below the input. Renders only if `state='valid'`. | `'Success'` |
-| `unit` | `string` | The string unit that appears to the right of the input if using a single unit. Required if using `unitOptions`. When using `unitOptions` this value becomes the controlled value of the select input. | `default` |
-| `unitOptions` | `Array<{displayName: string; value: string}>` | The options that appear in the select element attached to the right of the input. | `default` |
-| `onChange` | `(e: React.ChangeEvent) => void` | The event handler function for the 'onchange' event. Accepts the change event object as its argument and returns nothing. |
-| `onBlur` | `(e: React.ChangeEvent) => void` | The event handler function for the 'onblur' event. Accepts the focus event object as its argument and returns nothing. | |
-| `onSelectChange` | `(unit: {displayName: string; value: string}) => void` | A change handler triggered when the unit is changed. |
-| `className` | `string` | ClassName for the component. | |
-| `inputClassName` | `string` | ClassName for the input component. | |
-| `selectClassName` | `string` | ClassName for the select component. | |
-| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same reference to `scrollContainer` and `portalContainer`. | |
-| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. | |
-| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
-| `popoverZIndex` | `number` | Sets the z-index CSS property for the popover. | |
-| `darkMode` | `boolean` | Render the component in dark mode. | `false` |
-| ... | native `input` attributes | Any other props will be spread on the root `input` element | |
+| Prop | Type | Description | Default |
+| ----------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
+| `id` | `string` | id associated with the number input. | |
+| `label` | `string` | Label shown above the number input. | |
+| `description` | `string` | Text shown above the number input but below the label; gives more details about the requirements for the input. | |
+| `value` | `string` | The controlled value of the number input. | |
+| `disabled` | `boolean` | Disables the component. | `false` |
+| `placeholder` | `string` | The placeholder text shown in the input field before the user begins typing. | |
+| `size` | `'xsmall'` \| `'small'` \| `'default'` \| `'large'` | Determines the size of the input. | `default` |
+| `state` | `'none'`\| `'error'` | Describes the state of the TextInput element before and after the input has been validated | `'none'` |
+| `errorMessage` | `string` | Error message that appears below the input. Renders only if `state='error'`. | `'This input needs your attention'` |
+| `successMessage` | `string` | Success message that appears below the input. Renders only if `state='valid'`. | `'Success'` |
+| `unit` | `string` | The string unit that appears to the right of the input if using a single unit. Required if using `unitOptions`. When using `unitOptions` this value becomes the controlled value of the select input. | `default` |
+| `unitOptions` | `Array<{displayName: string; value: string}>` | The options that appear in the select element attached to the right of the input. | `default` |
+| `onChange` | `(e: React.ChangeEvent) => void` | The event handler function for the 'onchange' event. Accepts the change event object as its argument and returns nothing. |
+| `onBlur` | `(e: React.ChangeEvent) => void` | The event handler function for the 'onblur' event. Accepts the focus event object as its argument and returns nothing. | |
+| `onSelectChange` | `(unit: {displayName: string; value: string}) => void` | A change handler triggered when the unit is changed. |
+| `className` | `string` | ClassName for the component. | |
+| `inputClassName` | `string` | ClassName for the input component. | |
+| `selectClassName` | `string` | ClassName for the select component. | |
+| `darkMode` | `boolean` | Render the component in dark mode. | `false` |
+| ... | native `input` attributes | Any other props will be spread on the root `input` element | |
diff --git a/packages/number-input/src/NumberInput.stories.tsx b/packages/number-input/src/NumberInput.stories.tsx
index 544ecd94eb..632149b961 100644
--- a/packages/number-input/src/NumberInput.stories.tsx
+++ b/packages/number-input/src/NumberInput.stories.tsx
@@ -33,6 +33,17 @@ const unitOptions = [
const meta: StoryMetaType = {
title: 'Components/NumberInput',
component: NumberInput,
+ decorators: [
+ StoryFn => (
+
+
+
+ ),
+ ],
parameters: {
default: 'LiveExample',
controls: {
diff --git a/packages/number-input/src/NumberInput/NumberInput.spec.tsx b/packages/number-input/src/NumberInput/NumberInput.spec.tsx
index 4801bc447a..f65526e539 100644
--- a/packages/number-input/src/NumberInput/NumberInput.spec.tsx
+++ b/packages/number-input/src/NumberInput/NumberInput.spec.tsx
@@ -1,4 +1,4 @@
-import React, { createRef } from 'react';
+import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
@@ -351,21 +351,6 @@ describe('packages/number-input', () => {
value: selectProps.unitOptions[1].value,
});
});
-
- test('accepts a portalRef', () => {
- const portalContainer = document.createElement('div');
- document.body.appendChild(portalContainer);
- const portalRef = createRef();
- const { getByRole } = renderNumberInput({
- ...selectProps,
- portalContainer,
- portalRef,
- });
- const trigger = getByRole('button', { name: unitProps.unit });
- fireEvent.click(trigger);
- expect(portalRef.current).toBeDefined();
- expect(portalRef.current).toBe(portalContainer);
- });
});
/* eslint-disable jest/no-disabled-tests */
@@ -418,54 +403,6 @@ describe('packages/number-input', () => {
id="1"
size={Size.Default}
/>
-
- {/* @ts-expect-error - portalClassName should be undefined */}
- {}}
- label={label}
- usePortal={false}
- portalClassName="classname"
- />
-
- {/* @ts-expect-error - scrollContainer should be undefined */}
- {}}
- label={label}
- usePortal={false}
- scrollContainer={{} as HTMLElement}
- />
-
- {/* @ts-expect-error - portalContainer should be undefined */}
- {}}
- label={label}
- usePortal={false}
- portalContainer={{} as HTMLElement}
- />
-
- {}}
- label={label}
- usePortal={false}
- />
-
- {}}
- label={label}
- portalContainer={{} as HTMLElement}
- scrollContainer={{} as HTMLElement}
- portalClassName="classname"
- />
>;
});
});
diff --git a/packages/number-input/src/NumberInput/NumberInput.tsx b/packages/number-input/src/NumberInput/NumberInput.tsx
index 6bb5702b5c..c9550eab3e 100644
--- a/packages/number-input/src/NumberInput/NumberInput.tsx
+++ b/packages/number-input/src/NumberInput/NumberInput.tsx
@@ -46,12 +46,6 @@ export const NumberInput = React.forwardRef(
errorMessage = DEFAULT_MESSAGES.error,
successMessage = DEFAULT_MESSAGES.success,
onChange,
- popoverZIndex,
- usePortal = true,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
...rest
}: NumberInputProps,
forwardedRef,
@@ -70,19 +64,6 @@ export const NumberInput = React.forwardRef(
const renderUnitOnly = hasUnit && !hasSelectOptions;
const renderSelectOnly = hasUnit && hasSelectOptions && !!isUnitInOptions;
- const popoverProps = {
- popoverZIndex,
- ...(usePortal
- ? {
- usePortal,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
- }
- : { usePortal }),
- } as const;
-
const formFieldFeedbackProps = {
disabled,
errorMessage,
@@ -152,7 +133,6 @@ export const NumberInput = React.forwardRef(
onChange={onSelectChange}
size={size}
className={selectClassName}
- {...popoverProps}
/>
)}
@@ -190,21 +170,4 @@ NumberInput.propTypes = {
value: PropTypes.string.isRequired,
}),
),
- // Popover Props
- popoverZIndex: PropTypes.number,
- scrollContainer:
- typeof window !== 'undefined'
- ? PropTypes.instanceOf(Element)
- : PropTypes.any,
- portalContainer:
- typeof window !== 'undefined'
- ? PropTypes.instanceOf(Element)
- : PropTypes.any,
- portalRef: PropTypes.shape({
- current:
- typeof window !== 'undefined'
- ? PropTypes.instanceOf(Element)
- : PropTypes.any,
- }),
- portalClassName: PropTypes.string,
} as any;
diff --git a/packages/number-input/src/NumberInput/NumberInput.types.ts b/packages/number-input/src/NumberInput/NumberInput.types.ts
index 4e3a3861d8..4bb531343b 100644
--- a/packages/number-input/src/NumberInput/NumberInput.types.ts
+++ b/packages/number-input/src/NumberInput/NumberInput.types.ts
@@ -8,8 +8,6 @@ import { AriaLabelPropsWithLabel } from '@leafygreen-ui/a11y';
import { FormFieldState } from '@leafygreen-ui/form-field';
import { DarkModeProps } from '@leafygreen-ui/lib';
-import { PopoverProps } from '../UnitSelect/UnitSelect.types';
-
export const Direction = {
Increment: 'increment',
Decrement: 'decrement',
@@ -154,5 +152,4 @@ export interface BaseNumberInputProps
export type NumberInputProps = BaseNumberInputProps &
ConditionalUnitSelectProps &
- PopoverProps &
AriaLabelPropsWithLabel;
diff --git a/packages/number-input/src/UnitSelect/UnitSelect.tsx b/packages/number-input/src/UnitSelect/UnitSelect.tsx
index 2dd4ed0c86..f561f00b0f 100644
--- a/packages/number-input/src/UnitSelect/UnitSelect.tsx
+++ b/packages/number-input/src/UnitSelect/UnitSelect.tsx
@@ -2,7 +2,12 @@ import React from 'react';
import { cx } from '@leafygreen-ui/emotion';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
-import { DropdownWidthBasis, Option, Select } from '@leafygreen-ui/select';
+import {
+ DropdownWidthBasis,
+ Option,
+ RenderMode,
+ Select,
+} from '@leafygreen-ui/select';
import { UnitOption } from '../NumberInput/NumberInput.types';
import { UnitSelectButton } from '../UnitSelectButton';
@@ -24,26 +29,11 @@ export function UnitSelect({
unitOptions,
onChange,
disabled,
- usePortal,
size,
className,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
- popoverZIndex,
}: UnitSelectProps) {
const { theme } = useDarkMode();
- const popoverProps = {
- popoverZIndex,
- usePortal,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
- } as const;
-
/**
* Gets the current unit option using the unit string
*/
@@ -78,7 +68,7 @@ export function UnitSelect({
disabled={disabled}
size={size}
data-testid={dataTestId}
- {...popoverProps}
+ renderMode={RenderMode.TopLayer}
__INTERNAL__menuButtonSlot__={UnitSelectButton}
__INTERNAL__menuButtonSlotProps__={{
disabled,
diff --git a/packages/number-input/src/UnitSelect/UnitSelect.types.ts b/packages/number-input/src/UnitSelect/UnitSelect.types.ts
index 439bf3362a..414c60eceb 100644
--- a/packages/number-input/src/UnitSelect/UnitSelect.types.ts
+++ b/packages/number-input/src/UnitSelect/UnitSelect.types.ts
@@ -1,14 +1,6 @@
-import {
- PopoverProps as ImportedPopoverProps,
- PortalControlProps,
-} from '@leafygreen-ui/popover';
-
import { Size, UnitOption } from '../NumberInput/NumberInput.types';
-export type PopoverProps = PortalControlProps &
- Pick;
-
-export type UnitSelectProps = {
+export interface UnitSelectProps {
/**
* Id for the select component.
*/
@@ -51,4 +43,4 @@ export type UnitSelectProps = {
* @internal
*/
['data-testid']?: string;
-} & PopoverProps;
+}
diff --git a/packages/number-input/src/UnitSelectButton/UnitSelectButton.tsx b/packages/number-input/src/UnitSelectButton/UnitSelectButton.tsx
index cae356ee07..64ca2cdff4 100644
--- a/packages/number-input/src/UnitSelectButton/UnitSelectButton.tsx
+++ b/packages/number-input/src/UnitSelectButton/UnitSelectButton.tsx
@@ -10,7 +10,7 @@ import {
popoverClassName,
} from '@leafygreen-ui/select';
import { Size } from '@leafygreen-ui/tokens';
-import Tooltip from '@leafygreen-ui/tooltip';
+import Tooltip, { Align, Justify, RenderMode } from '@leafygreen-ui/tooltip';
import {
baseStyles,
@@ -34,31 +34,17 @@ export const UnitSelectButton = React.forwardRef(
children,
disabled,
displayName,
- popoverZIndex,
- usePortal,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
...props
}: UnitSelectButtonProps,
forwardedRef,
) => {
- const [open, setOpen] = useState(false);
+ const [tooltipOpen, setTooltipOpen] = useState(false);
const buttonRef: React.MutableRefObject =
useForwardedRef(
forwardedRef,
null,
) as React.MutableRefObject;
const { theme } = useDarkMode();
- const popoverProps = {
- popoverZIndex,
- usePortal,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
- } as const;
/**
* Gets the text node for the selected option.
@@ -86,25 +72,26 @@ export const UnitSelectButton = React.forwardRef(
if (!popoverParent) {
// React 18 automatically batches all updates which appears to break the opening transition. flushSync prevents this state update from automically batching. Instead updates are made synchronously.
flushSync(() => {
- setOpen(true);
+ setTooltipOpen(true);
});
}
}
};
- const handleMouseLeave = () => setOpen(false);
- const handleOnFocus = () => setOpen(true);
- const handleOnBlur = () => setOpen(false);
+ const handleMouseLeave = () => setTooltipOpen(false);
+ const handleOnFocus = () => setTooltipOpen(true);
+ const handleOnBlur = () => setTooltipOpen(false);
return (
{displayName}
diff --git a/packages/number-input/src/UnitSelectButton/UnitSelectButton.types.ts b/packages/number-input/src/UnitSelectButton/UnitSelectButton.types.ts
index 1babce97d9..3f68de4186 100644
--- a/packages/number-input/src/UnitSelectButton/UnitSelectButton.types.ts
+++ b/packages/number-input/src/UnitSelectButton/UnitSelectButton.types.ts
@@ -1,6 +1,6 @@
import { ButtonProps } from '@leafygreen-ui/button';
-import { PopoverProps, UnitSelectProps } from '../UnitSelect/UnitSelect.types';
+import { UnitSelectProps } from '../UnitSelect/UnitSelect.types';
export type UnitSelectButtonProps = {
/**
@@ -12,5 +12,4 @@ export type UnitSelectButtonProps = {
* The select option that is shown in the select menu button.
*/
displayName?: string;
-} & ButtonProps &
- PopoverProps;
+} & ButtonProps;
diff --git a/packages/pagination/src/Pagination/Pagination.tsx b/packages/pagination/src/Pagination/Pagination.tsx
index 439ec822f0..9dc40f0a0e 100644
--- a/packages/pagination/src/Pagination/Pagination.tsx
+++ b/packages/pagination/src/Pagination/Pagination.tsx
@@ -9,7 +9,12 @@ import ChevronRight from '@leafygreen-ui/icon/dist/ChevronRight';
import IconButton from '@leafygreen-ui/icon-button';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider';
-import { DropdownWidthBasis, Option, Select } from '@leafygreen-ui/select';
+import {
+ DropdownWidthBasis,
+ Option,
+ RenderMode,
+ Select,
+} from '@leafygreen-ui/select';
import { Body } from '@leafygreen-ui/typography';
import { baseStyles, flexSectionStyles } from './Pagination.styles';
@@ -92,6 +97,7 @@ function Pagination
({
allowDeselect={false}
size="xsmall"
dropdownWidthBasis={DropdownWidthBasis.Option}
+ renderMode={RenderMode.TopLayer}
>
{itemsPerPageOptions.map((option: number) => (
@@ -119,6 +125,7 @@ function Pagination({
size="xsmall"
data-testid="lg-pagination-page-select"
dropdownWidthBasis={DropdownWidthBasis.Option}
+ renderMode={RenderMode.TopLayer}
>
{range(
1,
diff --git a/packages/pipeline/src/Pipeline.tsx b/packages/pipeline/src/Pipeline.tsx
index 38c9cb9b5c..2730615031 100644
--- a/packages/pipeline/src/Pipeline.tsx
+++ b/packages/pipeline/src/Pipeline.tsx
@@ -12,7 +12,7 @@ import { cx } from '@leafygreen-ui/emotion';
import { useMutationObserver } from '@leafygreen-ui/hooks';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
import { isComponentType } from '@leafygreen-ui/lib';
-import Tooltip from '@leafygreen-ui/tooltip';
+import Tooltip, { Align, Justify, RenderMode } from '@leafygreen-ui/tooltip';
import Counter from './Counter';
import { PipelineContext } from './PipelineContext';
@@ -60,6 +60,7 @@ const Pipeline = forwardRef(
// State
const [pipelineNode, setPipelineNode] = useState(null);
const [hiddenStages, setHiddenStages] = useState>([]);
+ const [tooltipOpen, setTooltipOpen] = useState(false);
const providerData = useMemo(() => {
return {
@@ -101,6 +102,13 @@ const Pipeline = forwardRef(
}
};
+ /**
+ * Callback to handle mouse enter event on the Counter component to immediately open tooltip on hover.
+ */
+ const handleMouseEnter = () => {
+ setTooltipOpen(true);
+ };
+
// Effects
useMutationObserver(
pipelineNode,
@@ -147,18 +155,22 @@ const Pipeline = forwardRef(
{/* Removing the component was causing an unmounted error so instead we're hiding it with css */}
}
triggerEvent="hover"
- darkMode={darkMode}
- className={tooltipStyles}
>
diff --git a/packages/pipeline/src/types.ts b/packages/pipeline/src/types.ts
index 04306c8f10..9ed43fb164 100644
--- a/packages/pipeline/src/types.ts
+++ b/packages/pipeline/src/types.ts
@@ -59,7 +59,7 @@ export interface StageProps extends HTMLElementProps<'li', never> {
threshold?: number | Array;
}
-export interface CounterProps {
+export interface CounterProps extends HTMLElementProps<'div', never> {
/**
* Content that will appear inside of the Counter component.
*/
diff --git a/packages/popover/README.md b/packages/popover/README.md
index 41377efb3f..99f62e4daa 100644
--- a/packages/popover/README.md
+++ b/packages/popover/README.md
@@ -21,63 +21,106 @@ npm install @leafygreen-ui/popover
## Example
```js
-import Popover from '@leafygreen-ui/popover';
-
- this.setState({ active: !this.state.active })}
->
- Popover
+import Popover, {
+ Align,
+ DismissMode,
+ Justify,
+ RenderMode,
+ ToggleEvent,
+} from '@leafygreen-ui/popover';
+
+const [open, setOpen] = useState(false);
+const buttonRef = (useRef < HTMLButtonElement) | (null > null);
+
+const handleClick = () => {
+ setOpen(open => !open);
+};
+
+const handleToggle = (e: ToggleEvent) => {
+ const newOpen = e.newState === 'open';
+ setOpen(newOpen);
+};
+
+<>
+
+ Open Popover
+
- Popover content
+ Popover content
- ;
+>;
```
## Output HTML
```html
-
- Popover
-
-
-
-
+
+
+ Open Popover
+
+
+ Popover content ::backdrop
+
+
+
+#top-layer > div > ::backdrop
```
-## Simple Use Case
+## Render mode
+
+### v12+
+
+In v12+ versions, a popover should now render in the [top layer](https://developer.mozilla.org/en-US/docs/Glossary/Top_layer), which "appear[s] on top of all other content on the page."
+
+The `usePortal` prop is available as an escape hatch to override the `renderMode` prop. `usePortal` can be used to render a popover positioned `'inline'` relative to the nearest ancestor or in a `'portal'`. `RenderMode.Inline` and `RenderMode.Portal` are marked deprecated and will eventually lose support. All overlay elements should migrate to using the top layer.
+
+### Pre-v12
-The popover component will be automatically positioned relative to its nearest parent. If `usePortal` is set to `false`, then it will be positioned relative to its nearest ancestor with the CSS property: `position: absolute | relative | fixed`.
+In pre-v12 versions, a popover can be rendered in 2 ways using the `usePortal` prop. By default, `usePortal={true}`, and it is rendered in a portal. If `usePortal={false}`, it is rendered inline in the DOM.
## Properties
-| Prop | Type | Description | Default |
-| ------------------ | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
-| `active` | `boolean` | Determines whether the Popover is active or inactive | `false` |
-| `align` | `'top'` \| `'bottom'` \| `'left'` \| `'right'` \| `'center-horizontal'` \| `'center-vertical'` | A string that determines the alignment of the popover relative to the `refEl`. | `'bottom'` |
-| `justify` | `'start'` \| `'middle'` \| `'end'` \| `'fit'` | A string that determines the justification of the popover relative to the `refEl`. Justification will be defined relative to the `align` prop | `'start'` |
-| `children` | `node` | Content that will appear inside of the ` ` component | |
-| `spacing` | `number` | Specifies the amount of spacing (in pixels) between the trigger element and the content element. | `10` |
-| `className` | `string` | Classname to apply to popover-content container | |
-| `adjustOnMutation` | `boolean` | Should the Popover auto adjust its content when the DOM changes (using MutationObserver). | `false` |
-| `onClick` | `function` | Function that will be called when popover content is clicked. | |
-| `usePortal` | `boolean` | Will position Popover's children relative to its parent without using a Portal, if `usePortal` is set to false. NOTE: The parent element should be CSS position `relative`, `fixed`, or `absolute` if using this option. | `true` |
-| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same reference to `scrollContainer` and `portalContainer`. | |
-| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. | |
-| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
-| `popoverZIndex` | `number` | Sets the z-index CSS property for the popover. | |
-| ... | native attributes of Portal or Fragment | Any other properties will be spread on the popover-content container | |
-
-## Advanced Use Case
-
-| Prop | Type | Description | Default |
-| ------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
-| `refEl` | `HTMLElement` | You can supply a `refEl` prop, if you do not want the popover to be positioned relative to it's nearest parent. Ref to the element to which the popover component should be positioned relative to. | `null` |
+| Prop | Type | Description | Default |
+| ---------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
+| `active` | `boolean` | Determines whether the Popover is active or inactive | `false` |
+| `adjustOnMutation` | `boolean` | Should the Popover auto adjust its content when the DOM changes (using MutationObserver). | `false` |
+| `align` | `'top'` \| `'bottom'` \| `'left'` \| `'right'` \| `'center-horizontal'` \| `'center-vertical'` | A string that determines the alignment of the popover relative to the `refEl`. | `'bottom'` |
+| `children` | `node` | Content that will appear inside of the ` ` component | |
+| `className` | `string` | Classname to apply to popover-content container | |
+| `justify` | `'start'` \| `'middle'` \| `'end'` | A string that determines the justification of the popover relative to the `refEl`. Justification will be defined relative to the `align` prop | `'start'` |
+| `onClick` | `function` | Function that will be called when popover content is clicked. | |
+| `popoverZIndex` (deprecated) | `number` | Sets the z-index CSS property for the popover. This will only apply if `usePortal` is defined and `renderMode` is not `'top-layer'` | |
+| `refEl` | `React.RefObject` | You can supply a `refEl` prop, if you do not want the popover to be positioned relative to it's nearest parent. Ref to the element to which the popover component should be positioned relative to. | `null` |
+| `spacing` | `number` | Specifies the amount of spacing (in pixels) between the trigger element and the content element. | `4` |
+| ... | native attributes of Portal or Fragment | Any other properties will be spread on the popover-content container | |
+
+### v12+
+
+| Prop | Type | Description | Default |
+| ------------------------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
+| `dismissMode` | `'auto'` \| `'manual'` | Options to control how the popover element is dismissed. This will only apply when `renderMode` is `'top-layer'` \* `'auto'` will automatically handle dismissal on backdrop click or esc key press, ensuring only one popover is visible at a time \* `'manual'` will require that the consumer handle dismissal manually | `'auto'` |
+| `onToggle` | `(e: ToggleEvent) => void;` | Function that is called when the popover is toggled. This will only apply when `renderMode` is `'top-layer'` | |
+| `portalClassName` (deprecated) | `string` | Passes the given className to the popover's portal container if the default portal container is being used. This will only apply when `renderMode` is `'portal'` | |
+| `portalContainer` (deprecated) | `HTMLElement` \| `null` | Sets the container used for the popover's portal. This will only apply when `renderMode` is `'portal'`. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same reference to `scrollContainer` and `portalContainer`. | |
+| `portalRef` (deprecated) | `string` | Passes a ref to forward to the portal element. This will only apply when `renderMode` is `'portal'` | |
+| `scrollContainer` (deprecated) | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. This will only apply when `renderMode` is `'portal'` | |
+| `renderMode` | `'inline'` \| `'portal'` \| `'top-layer'` | Options to render the popover element \* [deprecated] `'inline'` will render the popover element inline in the DOM where it's written \* [deprecated] `'portal'` will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer` \* `'top-layer'` will render the popover element in the top layer | `'top-layer'` |
+
+### Pre-v12
+
+| Prop | Type | Description | Default |
+| ----------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
+| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. This will only apply when `usePortal` is `true` | |
+| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. This will only apply when `usePortal` is `true`. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same reference to `scrollContainer` and `portalContainer`. | |
+| `portalRef` | `string` | Passes a ref to forward to the portal element. This will only apply when `usePortal` is `true` | |
+| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. This will only apply when `usePortal` is `true` | |
+| `usePortal` | `boolean` | Option to render popover element in a portal. When `true`, the popover element will portal into the provided `portalContainer` or a new div appended to the end of the `` When `false`, the popover element will render inline in the DOM | `true` |
diff --git a/packages/popover/package.json b/packages/popover/package.json
index 40cd678cbd..a0564af1c8 100644
--- a/packages/popover/package.json
+++ b/packages/popover/package.json
@@ -22,17 +22,18 @@
"access": "public"
},
"dependencies": {
+ "@floating-ui/react": "^0.26.28",
"@leafygreen-ui/emotion": "^4.0.8",
"@leafygreen-ui/hooks": "^8.1.3",
"@leafygreen-ui/lib": "^13.5.0",
"@leafygreen-ui/portal": "^5.1.1",
"@leafygreen-ui/tokens": "^2.8.0",
- "react-transition-group": "^4.4.5",
- "@types/react-transition-group": "^4.4.5"
+ "@types/react-transition-group": "^4.4.5",
+ "react-transition-group": "^4.4.5"
},
"devDependencies": {
- "@leafygreen-ui/palette": "^4.0.9",
"@leafygreen-ui/button": "^21.1.0",
+ "@leafygreen-ui/palette": "^4.0.9",
"@lg-tools/storybook-utils": "^0.1.0"
},
"peerDependencies": {
diff --git a/packages/popover/src/Popover.stories.tsx b/packages/popover/src/Popover.stories.tsx
index 148d732cc4..57f0142830 100644
--- a/packages/popover/src/Popover.stories.tsx
+++ b/packages/popover/src/Popover.stories.tsx
@@ -3,13 +3,23 @@ import {
storybookExcludedControlParams,
StoryMetaType,
} from '@lg-tools/storybook-utils';
-import { StoryFn } from '@storybook/react';
+import { StoryFn, StoryObj } from '@storybook/react';
import Button from '@leafygreen-ui/button';
import { css, cx } from '@leafygreen-ui/emotion';
import { palette } from '@leafygreen-ui/palette';
+import { color } from '@leafygreen-ui/tokens';
-import Popover, { Align, Justify, PopoverProps } from '.';
+import { getPopoverRenderModeProps } from './utils/getPopoverRenderModeProps';
+import {
+ Align,
+ DismissMode,
+ Justify,
+ Popover,
+ PopoverProps,
+ RenderMode,
+ ToggleEvent,
+} from './Popover';
const popoverStyle = css`
border: 1px solid ${palette.gray.light1};
@@ -24,7 +34,7 @@ const popoverStyle = css`
background-color: initial;
`;
-const regularStyles = css`
+const containerStyles = css`
position: relative;
width: 100%;
height: 100%;
@@ -33,7 +43,7 @@ const regularStyles = css`
justify-content: center;
`;
-const scrollableStyle = css`
+const scrollableOuterStyles = css`
width: 500px;
height: 90vh;
background-color: ${palette.gray.light2};
@@ -41,39 +51,23 @@ const scrollableStyle = css`
position: relative;
`;
-const scrollableInnerStyle = css`
+const scrollableInnerStyles = css`
position: relative;
- height: 130vh;
+ height: 160vh;
+ width: 80vw;
display: flex;
align-items: center;
justify-content: center;
`;
-const buttonStyles = css`
- position: relative;
-`;
-
-const referenceElPositions: { [key: string]: string } = {
- centered: css`
- position: relative;
- `,
- top: css`
- top: 0;
- position: absolute;
- `,
- right: css`
- right: 0;
- position: absolute;
- `,
- bottom: css`
- bottom: 0;
- position: absolute;
- `,
- left: css`
- left: 0;
- position: absolute;
- `,
-};
+const defaultExcludedControls = [
+ ...storybookExcludedControlParams,
+ 'active',
+ 'children',
+ 'portalClassName',
+ 'refButtonPosition',
+ 'refEl',
+];
const meta: StoryMetaType = {
title: 'Components/Popover',
@@ -81,19 +75,18 @@ const meta: StoryMetaType = {
parameters: {
default: 'LiveExample',
controls: {
- exclude: [
- ...storybookExcludedControlParams,
- 'children',
- 'active',
- 'refEl',
- 'portalClassName',
- 'refButtonPosition',
- 'usePortal',
- ],
+ exclude: defaultExcludedControls,
},
generate: {
+ storyNames: [
+ 'Top',
+ 'Right',
+ 'Bottom',
+ 'Left',
+ 'CenterHorizontal',
+ 'CenterVertical',
+ ],
combineArgs: {
- align: Object.values(Align),
justify: Object.values(Justify),
},
args: {
@@ -102,46 +95,62 @@ const meta: StoryMetaType = {
},
// eslint-disable-next-line react/display-name
decorator: Instance => {
- // eslint-disable-next-line react-hooks/rules-of-hooks
- const ref = useRef(null);
-
return (
- refEl
-
+
+ Button Text
+
+
);
},
},
},
args: {
- align: Align.Top,
- justify: Justify.Start,
- spacing: 10,
adjustOnMutation: false,
+ align: Align.Top,
buttonText: 'Button Text',
+ dismissMode: DismissMode.Auto,
+ justify: Justify.Start,
+ renderMode: RenderMode.TopLayer,
+ spacing: 4,
},
argTypes: {
+ align: {
+ options: Object.values(Align),
+ control: { type: 'radio' },
+ },
buttonText: {
type: 'string',
description:
'Storybook only prop. Used to change the reference button text',
},
- refButtonPosition: {
- options: ['centered', 'top', 'right', 'bottom', 'left'],
- control: { type: 'select' },
- description:
- 'Storybook only prop. Used to change position of the reference button',
- defaultValue: 'centered',
+ dismissMode: {
+ options: Object.values(DismissMode),
+ control: { type: 'radio' },
+ },
+ justify: {
+ options: Object.values(Justify),
+ control: { type: 'radio' },
+ },
+ renderMode: {
+ options: Object.values(RenderMode),
+ control: { type: 'radio' },
},
},
};
@@ -149,29 +158,57 @@ export default meta;
type PopoverStoryProps = PopoverProps & {
buttonText: string;
- refButtonPosition: string;
};
-
export const LiveExample: StoryFn = ({
- refButtonPosition,
buttonText,
- ...args
+ ...props
}: PopoverStoryProps) => {
+ const {
+ portalClassName,
+ portalContainer,
+ portalRef,
+ scrollContainer,
+ dismissMode,
+ renderMode = RenderMode.TopLayer,
+ onToggle,
+ ...rest
+ } = props;
+ const buttonRef = useRef(null);
const [active, setActive] = useState(false);
- const position = referenceElPositions[refButtonPosition];
+ const handleClick = () => {
+ setActive(active => !active);
+ };
+
+ const handleToggle = (e: ToggleEvent) => {
+ onToggle?.(e);
+ const newActive = e.newState === 'open';
+ setActive(newActive);
+ };
+
+ const popoverProps = {
+ active,
+ refEl: buttonRef,
+ ...getPopoverRenderModeProps({
+ dismissMode,
+ onToggle: handleToggle,
+ portalClassName,
+ portalContainer,
+ portalRef,
+ renderMode,
+ scrollContainer,
+ }),
+ ...rest,
+ };
return (
-
-
setActive(active => !active)}
- >
+
+
{buttonText}
-
- Popover content
-
+
+ Popover content
+
);
};
@@ -181,32 +218,27 @@ LiveExample.parameters = {
},
};
-export const ScrollableContainer: StoryFn = ({
- refButtonPosition,
+const PortalPopoverInScrollableContainer = ({
buttonText,
- ...args
+ ...props
}: PopoverStoryProps) => {
+ const { dismissMode, onToggle, renderMode, ...rest } = props;
const [active, setActive] = useState(false);
const portalRef = useRef(null);
- const portalContainer = useRef(null);
-
- const position = referenceElPositions[refButtonPosition];
+ const scrollContainer = useRef(null);
return (
-
-
-
setActive(active => !active)}
- className={position}
- >
+
+
+
setActive(active => !active)}>
{buttonText}
- {/* @ts-expect-error */}
Popover content
@@ -215,20 +247,159 @@ export const ScrollableContainer: StoryFn = ({
);
};
-ScrollableContainer.parameters = {
- chromatic: {
- disableSnapshot: true,
+export const RenderModePortalInScrollableContainer = {
+ render: PortalPopoverInScrollableContainer,
+ parameters: {
+ chromatic: {
+ disableSnapshot: true,
+ },
+ controls: {
+ exclude: [...defaultExcludedControls, 'dismissMode', 'renderMode'],
+ },
+ },
+ argTypes: {
+ renderMode: { control: 'none' },
+ portalClassName: { control: 'none' },
+ refEl: { control: 'none' },
+ className: { control: 'none' },
+ active: { control: 'none' },
+ },
+};
+
+const InlinePopover = ({ buttonText, ...props }: PopoverStoryProps) => {
+ const {
+ dismissMode,
+ onToggle,
+ renderMode,
+ portalClassName,
+ portalContainer,
+ portalRef,
+ scrollContainer,
+ ...rest
+ } = props;
+ const buttonRef = useRef
(null);
+ const [active, setActive] = useState(false);
+
+ return (
+
+
setActive(active => !active)} ref={buttonRef}>
+ {buttonText}
+
+
+ Popover content
+
+
+ );
+};
+export const RenderModeInline = {
+ render: InlinePopover,
+ parameters: {
+ chromatic: {
+ disableSnapshot: true,
+ },
+ controls: {
+ exclude: [...defaultExcludedControls, 'dismissMode', 'renderMode'],
+ },
+ },
+ argTypes: {
+ renderMode: { control: 'none' },
+ portalClassName: { control: 'none' },
+ refEl: { control: 'none' },
+ className: { control: 'none' },
+ active: { control: 'none' },
},
};
-ScrollableContainer.args = {
- usePortal: true,
+
+const generatedStoryExcludedControlParams = [
+ ...storybookExcludedControlParams,
+ 'active',
+ 'adjustOnMutation',
+ 'align',
+ 'buttonText',
+ 'children',
+ 'dismissMode',
+ 'justify',
+ 'portalClassName',
+ 'refButtonPosition',
+ 'refEl',
+ 'renderMode',
+ 'spacing',
+ 'usePortal',
+];
+
+export const Top = {
+ render: LiveExample.bind({}),
+ args: {
+ align: Align.Top,
+ },
+ parameters: {
+ controls: {
+ exclude: generatedStoryExcludedControlParams,
+ },
+ },
+};
+
+export const Bottom = {
+ render: LiveExample.bind({}),
+ args: {
+ align: Align.Bottom,
+ },
+ parameters: {
+ controls: {
+ exclude: generatedStoryExcludedControlParams,
+ },
+ },
};
-ScrollableContainer.argTypes = {
- usePortal: { control: 'none' },
- portalClassName: { control: 'none' },
- refEl: { control: 'none' },
- className: { control: 'none' },
- active: { control: 'none' },
+
+export const Left = {
+ render: LiveExample.bind({}),
+ args: {
+ align: Align.Left,
+ },
+ parameters: {
+ controls: {
+ exclude: generatedStoryExcludedControlParams,
+ },
+ },
};
-export const Generated = () => {};
+export const Right = {
+ render: LiveExample.bind({}),
+ args: {
+ align: Align.Right,
+ },
+ parameters: {
+ controls: {
+ exclude: generatedStoryExcludedControlParams,
+ },
+ },
+};
+
+export const CenterHorizontal = {
+ render: LiveExample.bind({}),
+ args: {
+ align: Align.CenterHorizontal,
+ },
+ parameters: {
+ controls: {
+ exclude: generatedStoryExcludedControlParams,
+ },
+ },
+};
+
+export const CenterVertical: StoryObj = {
+ render: LiveExample.bind({}),
+ args: {
+ align: Align.CenterVertical,
+ },
+ parameters: {
+ controls: {
+ exclude: generatedStoryExcludedControlParams,
+ },
+ },
+};
diff --git a/packages/popover/src/Popover.types.ts b/packages/popover/src/Popover.types.ts
deleted file mode 100644
index 6f8389d492..0000000000
--- a/packages/popover/src/Popover.types.ts
+++ /dev/null
@@ -1,204 +0,0 @@
-import React from 'react';
-import { Transition } from 'react-transition-group';
-
-import { HTMLElementProps } from '@leafygreen-ui/lib';
-
-type TransitionProps = React.ComponentProps>;
-
-type TransitionLifecycleCallbacks = Pick<
- TransitionProps,
- 'onEnter' | 'onEntering' | 'onEntered' | 'onExit' | 'onExiting' | 'onExited'
->;
-
-/**
- * Options to determine the alignment of the popover relative to
- * the other component
- * @param Top will align content above other element
- * @param Bottom will align content below other element
- * @param Left will align content to the left of other element
- * @param Right will align content to the right of other element
- */
-const Align = {
- Top: 'top',
- Bottom: 'bottom',
- Left: 'left',
- Right: 'right',
- CenterVertical: 'center-vertical',
- CenterHorizontal: 'center-horizontal',
-} as const;
-
-type Align = (typeof Align)[keyof typeof Align];
-
-export { Align };
-
-/**
- * Options to determine the justification of the popover relative to
- * the other component
- * @param Start will justify content against the start of other element
- * @param Middle will justify content against the middle of other element
- * @param End will justify content against the end of other element
- * @param Fit will justify content against both the start and the end of the other element
- */
-const Justify = {
- Start: 'start',
- Middle: 'middle',
- End: 'end',
- Fit: 'fit',
-} as const;
-
-type Justify = (typeof Justify)[keyof typeof Justify];
-
-export { Justify };
-
-export interface ElementPosition {
- top: number;
- bottom: number;
- left: number;
- right: number;
- height: number;
- width: number;
-}
-
-export interface ChildrenFunctionParameters {
- align: Align;
- justify: Justify;
- referenceElPos: ElementPosition;
-}
-
-export type PortalControlProps =
- | {
- /**
- * Specifies that the popover content should be rendered at the end of the DOM,
- * rather than in the DOM tree.
- *
- * default: `true`
- */
- usePortal?: true;
-
- /**
- * When usePortal is `true`, specifies a class name to apply to the root element of the portal.
- */
- portalClassName?: string;
-
- /**
- * When usePortal is `true`, specifies an element to portal within. The default behavior is to generate a div at the end of the document to render within.
- */
- portalContainer?: HTMLElement | null;
-
- /**
- * A ref for the portal element
- */
- portalRef?: React.MutableRefObject;
-
- /**
- * When usePortal is `true`, specifies the scrollable element to position relative to.
- */
- scrollContainer?: HTMLElement | null;
- }
- | {
- /**
- * Specifies that the popover content should be rendered at the end of the DOM,
- * rather than in the DOM tree.
- *
- * default: `true`
- */
- usePortal: false;
-
- /**
- * When usePortal is `true`, specifies a class name to apply to the root element of the portal.
- */
- portalClassName?: undefined;
-
- /**
- * When usePortal is `true`, specifies an element to portal within. The default behavior is to generate a div at the end of the document to render within.
- */
- portalContainer?: null;
-
- /**
- * A ref for the portal element
- */
- portalRef?: undefined;
-
- /**
- * When usePortal is `true`, specifies the scrollable element to position relative to.
- */
- scrollContainer?: null;
- };
-
-/**
- * Base popover props.
- * Use these props to extend popover behavior
- */
-export type PopoverProps = {
- /**
- * Content that will appear inside of the popover component.
- */
- children:
- | React.ReactNode
- | ((Options: ChildrenFunctionParameters) => React.ReactNode);
-
- /**
- * Determines the active state of the popover component
- *
- * default: `false`
- */
- active?: boolean;
-
- /**
- * Class name applied to popover container.
- */
- className?: string;
-
- /**
- * Class name applied to the popover content container
- */
- contentClassName?: string;
-
- /**
- * Determines the alignment of the popover content relative to the trigger element
- *
- * default: `bottom`
- */
- align?: Align;
-
- /**
- * Determines the justification of the popover content relative to the trigger element
- *
- * default: `start`
- */
- justify?: Justify;
-
- /**
- * A reference to the element against which the popover component will be positioned.
- */
- refEl?: React.RefObject;
-
- /**
- * Specifies the amount of spacing (in pixels) between the trigger element and the Popover content.
- *
- * default: `10`
- */
- spacing?: number;
-
- /**
- * Should the Popover auto adjust its content when the DOM changes (using MutationObserver).
- *
- * default: false
- */
- adjustOnMutation?: boolean;
-
- /**
- * Click event handler passed to the root div element within the portal container.
- */
- onClick?: React.MouseEventHandler;
-
- /**
- * Number that controls the z-index of the popover element directly.
- */
- popoverZIndex?: number;
-} & PortalControlProps &
- TransitionLifecycleCallbacks;
-
-/** Props used by the popover component */
-export type PopoverComponentProps = Omit, 'children'> &
- PopoverProps;
diff --git a/packages/popover/src/Popover/Popover.hooks.tsx b/packages/popover/src/Popover/Popover.hooks.tsx
new file mode 100644
index 0000000000..18a9327466
--- /dev/null
+++ b/packages/popover/src/Popover/Popover.hooks.tsx
@@ -0,0 +1,174 @@
+import React, { useMemo, useRef, useState } from 'react';
+
+import {
+ useIsomorphicLayoutEffect,
+ useObjectDependency,
+} from '@leafygreen-ui/hooks';
+import {
+ useMigrationContext,
+ usePopoverPortalContainer,
+ usePopoverPropsContext,
+} from '@leafygreen-ui/leafygreen-provider';
+
+import { getElementDocumentPosition } from '../utils/positionUtils';
+
+import {
+ PopoverProps,
+ RenderMode,
+ UseContentNodeReturnObj,
+ UseReferenceElementReturnObj,
+} from './Popover.types';
+
+/**
+ * This hook handles logic for determining what prop values are used for the `Popover`
+ * component. If a prop is not provided, the value from the `PopoverContext` will be used.
+ */
+export function usePopoverProps({
+ renderMode: renderModeProp,
+ dismissMode,
+ onToggle,
+ portalClassName,
+ portalContainer,
+ portalRef,
+ scrollContainer,
+ onEnter,
+ onEntering,
+ onEntered,
+ onExit,
+ onExiting,
+ onExited,
+ popoverZIndex: popoverZIndexProp,
+ spacing,
+ ...rest
+}: Partial<
+ Omit<
+ PopoverProps,
+ | 'active'
+ | 'adjustOnMutation'
+ | 'align'
+ | 'children'
+ | 'className'
+ | 'justify'
+ | 'refEl'
+ >
+>) {
+ const { forceUseTopLayer } = useMigrationContext();
+ const context = usePopoverPropsContext();
+ const popoverPortalContext = usePopoverPortalContainer();
+
+ const renderMode = forceUseTopLayer
+ ? RenderMode.TopLayer
+ : renderModeProp || context.renderMode;
+ const usePortal = renderMode === RenderMode.Portal;
+ const useTopLayer = renderMode === RenderMode.TopLayer;
+
+ const topLayerProps = useTopLayer
+ ? {
+ dismissMode: dismissMode || context.dismissMode,
+ onToggle: onToggle || context.onToggle,
+ }
+ : {};
+
+ const portalProps = usePortal
+ ? {
+ portalClassName: portalClassName || context.portalClassName,
+ portalContainer:
+ portalContainer ||
+ context.portalContainer ||
+ popoverPortalContext.portalContainer,
+ portalRef: portalRef || context.portalRef,
+ scrollContainer:
+ scrollContainer ||
+ context.scrollContainer ||
+ popoverPortalContext.scrollContainer,
+ }
+ : {};
+
+ const reactTransitionGroupProps = {
+ onEnter: onEnter || context.onEnter,
+ onEntering: onEntering || context.onEntering,
+ onEntered: onEntered || context.onEntered,
+ onExit: onExit || context.onExit,
+ onExiting: onExiting || context.onExiting,
+ onExited: onExited || context.onExited,
+ };
+
+ const styleProps = {
+ popoverZIndex: useTopLayer
+ ? undefined
+ : popoverZIndexProp || context.popoverZIndex,
+ spacing: spacing || context.spacing,
+ };
+
+ return {
+ renderMode,
+ usePortal,
+ ...topLayerProps,
+ ...portalProps,
+ ...reactTransitionGroupProps,
+ ...styleProps,
+ ...rest,
+ };
+}
+
+/**
+ * This hook handles logic for determining the reference element for the popover element.
+ * 1. If a `refEl` is provided, the ref value is used as the reference element.
+ * 2. As a fallback, a hidden placeholder element is rendered, and the parent element of the
+ * placeholder is used as the reference element.
+ *
+ * Additionally, this hook calculates the document position of the reference element.
+ */
+export function useReferenceElement(
+ refEl?: PopoverProps['refEl'],
+ scrollContainer?: PopoverProps['scrollContainer'],
+): UseReferenceElementReturnObj {
+ const [placeholderElement, setPlaceholderElement] =
+ useState(null);
+ const [referenceElement, setReferenceElement] = useState(
+ null,
+ );
+
+ useIsomorphicLayoutEffect(() => {
+ if (refEl && refEl.current) {
+ setReferenceElement(refEl.current);
+ return;
+ }
+
+ const maybeParentEl =
+ placeholderElement !== null && placeholderElement.parentNode;
+
+ if (maybeParentEl && maybeParentEl instanceof HTMLElement) {
+ setReferenceElement(maybeParentEl);
+ return;
+ }
+ }, [placeholderElement, refEl]);
+
+ const referenceElDocumentPos = useObjectDependency(
+ useMemo(
+ () => getElementDocumentPosition(referenceElement, scrollContainer, true),
+ [referenceElement, scrollContainer],
+ ),
+ );
+
+ return {
+ referenceElement,
+ referenceElDocumentPos,
+ setPlaceholderElement,
+ };
+}
+
+export function useContentNode(): UseContentNodeReturnObj {
+ const [contentNode, setContentNode] = React.useState(
+ null,
+ );
+
+ const contentNodeRef = useRef(contentNode);
+ contentNodeRef.current = contentNode;
+
+ return {
+ contentNode,
+ contentNodeRef,
+ setContentNode,
+ };
+}
diff --git a/packages/popover/src/Popover/Popover.spec.tsx b/packages/popover/src/Popover/Popover.spec.tsx
index aedd5e9441..20133c1695 100644
--- a/packages/popover/src/Popover/Popover.spec.tsx
+++ b/packages/popover/src/Popover/Popover.spec.tsx
@@ -1,120 +1,397 @@
-import React, { createRef, PropsWithChildren } from 'react';
+import React, { createRef, PropsWithChildren, useRef, useState } from 'react';
import { act, fireEvent, render, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
+import Button from '@leafygreen-ui/button';
import { PopoverContext } from '@leafygreen-ui/leafygreen-provider';
-import { PopoverProps } from '../Popover.types';
-
import { Popover } from './Popover';
+import { DismissMode, PopoverProps, RenderMode } from './Popover.types';
+
+type RTLInlinePopoverProps = Partial<
+ Omit<
+ PopoverProps,
+ | 'dismissMode'
+ | 'onToggle'
+ | 'portalClassName'
+ | 'portalContainer'
+ | 'portalRef'
+ | 'renderMode'
+ | 'scrollContainer'
+ | 'usePortal'
+ >
+>;
+
+type RTLPortalPopoverProps = Partial<
+ Omit
+>;
+
+type RTLTopLayerPopoverProps = Partial<
+ Omit<
+ PopoverProps,
+ | 'portalClassName'
+ | 'portalContainer'
+ | 'portalRef'
+ | 'renderMode'
+ | 'scrollContainer'
+ | 'usePortal'
+ >
+>;
+
+function TopLayerPopoverWithReference(props?: RTLTopLayerPopoverProps) {
+ const buttonRef = useRef(null);
+ const [active, setActive] = useState(props?.active ?? false);
+
+ return (
+ <>
+ setActive(active => !active)}
+ ref={buttonRef}
+ >
+ Open Popover
+
+
+ Popover Content
+
+ >
+ );
+}
-function renderPopover(props?: Partial) {
+function renderTopLayerPopover(props?: RTLTopLayerPopoverProps) {
const result = render(
-
- Popover Content
- ,
+ <>
+
+ >,
);
- const rerenderPopover = (newProps?: Partial) => {
+ const button = result.getByTestId('popover-reference-element');
+
+ const rerenderPopover = (newProps?: RTLTopLayerPopoverProps) => {
const allProps = { ...props, ...newProps };
result.rerender(
-
+
Popover Content
,
);
};
- return { ...result, rerenderPopover };
+ return { button, ...result, rerenderPopover };
}
describe('packages/popover', () => {
- describe('a11y', () => {
- test('does not have basic accessibility issues', async () => {
- const { container, rerenderPopover } = renderPopover();
- const results = await axe(container);
- expect(results).toHaveNoViolations();
-
- type AxeResult = Awaited>;
- let newResults: AxeResult = {} as AxeResult;
- rerenderPopover({ active: true });
- await act(async () => {
- newResults = await axe(container);
+ describe(`renderMode=${RenderMode.Inline}`, () => {
+ function renderInlinePopover(props?: RTLInlinePopoverProps) {
+ const result = render(
+
+ Popover Content
+ ,
+ );
+
+ const rerenderPopover = (newProps?: RTLInlinePopoverProps) => {
+ const allProps = { ...props, ...newProps };
+ result.rerender(
+
+ Popover Content
+ ,
+ );
+ };
+
+ return { ...result, rerenderPopover };
+ }
+
+ describe('a11y', () => {
+ test('does not have basic accessibility issues', async () => {
+ const { container, rerenderPopover } = renderInlinePopover();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+
+ type AxeResult = Awaited>;
+ let newResults: AxeResult = {} as AxeResult;
+ rerenderPopover({ active: true });
+ await act(async () => {
+ newResults = await axe(container);
+ });
+ expect(newResults).toHaveNoViolations();
});
- expect(newResults).toHaveNoViolations();
});
- });
- test('accepts a ref', () => {
- const ref = createRef();
- render(
-
- Popover Content
- ,
- );
+ test('displays popover inline when the `active` prop is `true`', () => {
+ const { container, getByTestId } = renderInlinePopover({ active: true });
+ expect(getByTestId('popover-test-id')).toBeInTheDocument();
+ expect(container.innerHTML.includes('popover-test-id')).toBeTruthy();
+ });
- expect(ref.current).toBeDefined();
+ test('does NOT display popover when the `active` prop is `false`', () => {
+ const { queryByTestId } = renderInlinePopover({ active: false });
+ expect(queryByTestId('popover-test-id')).toBeNull();
+ });
});
- test('accepts a portalRef', async () => {
- const portalRef = createRef();
- waitFor(() => {
- render(
-
+ describe(`renderMode=${RenderMode.Portal}`, () => {
+ function renderPortalPopover(props?: RTLPortalPopoverProps) {
+ const result = render(
+
Popover Content
,
);
- expect(portalRef.current).toBeDefined();
- expect(portalRef.current).toBeInTheDocument();
+ const rerenderPopover = (newProps?: RTLPortalPopoverProps) => {
+ const allProps = { ...props, ...newProps };
+ result.rerender(
+
+ Popover Content
+ ,
+ );
+ };
+
+ return { ...result, rerenderPopover };
+ }
+
+ describe('a11y', () => {
+ test('does not have basic accessibility issues', async () => {
+ const { container, rerenderPopover } = renderPortalPopover();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+
+ type AxeResult = Awaited>;
+ let newResults: AxeResult = {} as AxeResult;
+ rerenderPopover({ active: true });
+ await act(async () => {
+ newResults = await axe(container);
+ });
+ expect(newResults).toHaveNoViolations();
+ });
});
- });
- test('displays popover when the "active" prop is set', () => {
- const { getByTestId } = renderPopover({ active: true });
- expect(getByTestId('popover-test-id')).toBeInTheDocument();
- });
+ test('displays popover when the `active` prop is `true`', () => {
+ const { getByTestId } = renderPortalPopover({ active: true });
+ expect(getByTestId('popover-test-id')).toBeInTheDocument();
+ });
- test('does not display popover when "active" prop is not set', () => {
- const { container } = renderPopover();
- expect(container.innerHTML.includes('popover-test-id')).toBe(false);
- });
+ test('portals popover content to end of DOM by default', () => {
+ const { container, getByTestId } = renderPortalPopover({ active: true });
+ expect(container).not.toContain(getByTestId('popover-test-id'));
+ });
- test('onClick handler is called when popover contents is clicked', () => {
- const clickSpy = jest.fn();
- const { getByText } = renderPopover({ active: true, onClick: clickSpy });
+ test('does NOT display popover when the `active` prop is `false`', () => {
+ const { queryByTestId } = renderPortalPopover({ active: false });
+ expect(queryByTestId('popover-test-id')).toBeNull();
+ });
- expect(clickSpy).not.toHaveBeenCalled();
- fireEvent.click(getByText('Popover Content'));
- expect(clickSpy).toHaveBeenCalledTimes(1);
- });
+ test('accepts a `portalRef`', async () => {
+ const portalRef = createRef();
+ renderPortalPopover({ active: true, portalRef });
+
+ waitFor(() => {
+ expect(portalRef.current).toBeDefined();
+ expect(portalRef.current).toBeInTheDocument();
+ });
+ });
- test('portals popover content to end of DOM by default', () => {
- const { container, getByTestId } = renderPopover({ active: true });
- expect(container).not.toContain(getByTestId('popover-test-id'));
+ test('applies `portalClassName` to portal element', () => {
+ const { getByTestId } = renderPortalPopover({
+ active: true,
+ portalClassName: 'test-classname',
+ });
+ expect(getByTestId('popover-test-id').parentElement?.className).toBe(
+ 'test-classname',
+ );
+ });
});
- test('does not portal popover content to end of DOM when "usePortal" is false', () => {
- const { container } = renderPopover({
- active: true,
- usePortal: false,
+ describe(`renderMode=${RenderMode.TopLayer}`, () => {
+ describe('a11y', () => {
+ test('does not have basic accessibility issues', async () => {
+ const { container, rerenderPopover } = renderTopLayerPopover();
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+
+ type AxeResult = Awaited>;
+ let newResults: AxeResult = {} as AxeResult;
+ rerenderPopover({ active: true });
+ await act(async () => {
+ newResults = await axe(container);
+ });
+ expect(newResults).toHaveNoViolations();
+ });
});
- expect(container.innerHTML.includes('popover-test-id')).toBe(true);
+ describe(`when dismissMode=${DismissMode.Auto}`, () => {
+ // skip until JSDOM supports Popover API
+ // eslint-disable-next-line jest/no-disabled-tests
+ test.skip('dismisses popover when outside of popover is clicked', async () => {
+ const { getByTestId } = renderTopLayerPopover({
+ active: true,
+ dismissMode: DismissMode.Auto,
+ });
+ const popover = getByTestId('popover-test-id');
+
+ await waitFor(() => expect(popover).toBeVisible());
+
+ userEvent.click(document.body);
+
+ await waitFor(() => expect(popover).not.toBeVisible());
+ });
+
+ // skip until JSDOM supports Popover API
+ // eslint-disable-next-line jest/no-disabled-tests
+ test.skip('dismisses popover when `Escape` key is pressed', async () => {
+ const { getByTestId } = renderTopLayerPopover({
+ active: true,
+ dismissMode: DismissMode.Auto,
+ });
+ const popover = getByTestId('popover-test-id');
+
+ await waitFor(() => expect(popover).toBeVisible());
+
+ userEvent.keyboard('{escape}');
+
+ await waitFor(() => expect(popover).not.toBeVisible());
+ });
+ });
+
+ describe(`when dismissMode=${DismissMode.Manual}`, () => {
+ // skip until JSDOM supports Popover API
+ // eslint-disable-next-line jest/no-disabled-tests
+ test.skip('does not dismiss popover when outside of popover is clicked', async () => {
+ const { getByTestId } = renderTopLayerPopover({
+ active: true,
+ dismissMode: DismissMode.Manual,
+ });
+ const popover = getByTestId('popover-test-id');
+
+ await waitFor(() => expect(popover).toBeVisible());
+
+ userEvent.click(document.body);
+
+ await waitFor(() => expect(popover).toBeVisible());
+ });
+
+ // skip until JSDOM supports Popover API
+ // eslint-disable-next-line jest/no-disabled-tests
+ test.skip('does not dismiss popover when `Escape` key is pressed', async () => {
+ const { getByTestId } = renderTopLayerPopover({
+ active: true,
+ dismissMode: DismissMode.Manual,
+ });
+ const popover = getByTestId('popover-test-id');
+
+ await waitFor(() => expect(popover).toBeVisible());
+
+ userEvent.keyboard('{escape}');
+
+ await waitFor(() => expect(popover).toBeVisible());
+ });
+ });
+
+ test('displays popover in top layer when the `active` prop is `true`', async () => {
+ const { getByTestId } = renderTopLayerPopover({
+ active: true,
+ });
+ const popover = getByTestId('popover-test-id');
+
+ expect(popover).toBeInTheDocument();
+ await waitFor(() => expect(popover).toBeVisible());
+ });
+
+ test('does NOT display popover when the `active` prop is `false`', () => {
+ const { queryByTestId } = renderTopLayerPopover({ active: false });
+ expect(queryByTestId('popover-test-id')).toBeNull();
+ });
+
+ describe('onToggle', () => {
+ const toggleEvent = new Event('toggle');
+ test('is called when popover is opened', () => {
+ const onToggleSpy = jest.fn();
+ const { button, getByTestId } = renderTopLayerPopover({
+ active: false,
+ dismissMode: DismissMode.Auto,
+ onToggle: onToggleSpy,
+ });
+
+ userEvent.click(button);
+
+ const popover = getByTestId('popover-test-id');
+ popover.dispatchEvent(toggleEvent);
+
+ expect(onToggleSpy).toHaveBeenCalledTimes(1);
+ });
+
+ test('is called when popover is closed', () => {
+ const onToggleSpy = jest.fn();
+ const { button, getByTestId } = renderTopLayerPopover({
+ active: true,
+ onToggle: onToggleSpy,
+ });
+
+ expect(onToggleSpy).not.toHaveBeenCalled();
+
+ const popover = getByTestId('popover-test-id');
+ popover.dispatchEvent(toggleEvent);
+ userEvent.click(button);
+
+ expect(onToggleSpy).toHaveBeenCalledTimes(1);
+ });
+ });
});
- test('applies "portalClassName" to root of portal', () => {
- const { getByTestId } = renderPopover({
+ test('accepts a ref', () => {
+ const ref = createRef();
+ render(
+
+ Popover Content
+ ,
+ );
+
+ expect(ref.current).toBeDefined();
+ });
+
+ test('onClick handler is called when popover contents is clicked', () => {
+ const clickSpy = jest.fn();
+ const { getByText } = renderTopLayerPopover({
active: true,
- portalClassName: 'test-classname',
+ onClick: clickSpy,
});
- expect(getByTestId('popover-test-id').parentElement?.className).toBe(
- 'test-classname',
- );
+ expect(clickSpy).not.toHaveBeenCalled();
+ fireEvent.click(getByText('Popover Content'));
+ expect(clickSpy).toHaveBeenCalledTimes(1);
});
test('removes Popover instance on unmount', () => {
- const { container, unmount } = renderPopover();
+ const { container, unmount } = renderTopLayerPopover();
unmount();
expect(container.innerHTML).toBe('');
});
@@ -128,7 +405,7 @@ describe('packages/popover', () => {
onExiting: jest.fn(),
onExited: jest.fn(),
};
- const { rerenderPopover } = renderPopover({
+ const { button } = renderTopLayerPopover({
...callbacks,
});
@@ -138,7 +415,7 @@ describe('packages/popover', () => {
}
// Calls enter callbacks when active is toggled to true
- rerenderPopover({ active: true });
+ userEvent.click(button);
expect(callbacks.onEnter).toHaveBeenCalledTimes(1);
expect(callbacks.onEntering).toHaveBeenCalledTimes(1);
@@ -149,7 +426,7 @@ describe('packages/popover', () => {
expect(callbacks.onExited).not.toHaveBeenCalled();
// Calls exit callbacks when active is toggled to false
- rerenderPopover({ active: false });
+ userEvent.click(button);
// Expect the `onEnter*` callbacks to _only_ have been called once (from the previous render)
expect(callbacks.onEnter).toHaveBeenCalledTimes(1);
@@ -164,7 +441,7 @@ describe('packages/popover', () => {
describe('within context', () => {
const setIsPopoverOpenMock = jest.fn();
- function renderPopoverInContext(props?: Partial) {
+ function renderPopoverInContext(props?: RTLTopLayerPopoverProps) {
const MockPopoverProvider = ({ children }: PropsWithChildren<{}>) => {
return (
{
const result = render(
-
- Popover Content
-
+
,
);
- const rerenderPopover = (newProps?: Partial) => {
- const allProps = { ...props, ...newProps };
- result.rerender(
-
-
- Popover Content
-
- ,
- );
- };
+ const button = result.getByTestId('popover-reference-element');
- return { ...result, rerenderPopover };
+ return { button, ...result };
}
afterEach(() => {
@@ -205,19 +471,20 @@ describe('packages/popover', () => {
});
test('toggling `active` calls setIsPopoverOpen', async () => {
- const { rerenderPopover } = renderPopoverInContext();
+ const { button } = renderPopoverInContext();
expect(setIsPopoverOpenMock).not.toHaveBeenCalled();
- rerenderPopover({ active: true });
- await waitFor(() =>
- expect(setIsPopoverOpenMock).toHaveBeenCalledWith(true),
- );
+ userEvent.click(button);
+ await waitFor(() => {
+ expect(setIsPopoverOpenMock).toHaveBeenCalledWith(true);
+ expect(setIsPopoverOpenMock).toHaveBeenCalledTimes(1);
+ });
- rerenderPopover({ active: false });
- expect(setIsPopoverOpenMock).not.toHaveBeenCalledWith(false);
- await waitFor(() =>
- expect(setIsPopoverOpenMock).toHaveBeenCalledWith(false),
- );
+ userEvent.click(button);
+ await waitFor(() => {
+ expect(setIsPopoverOpenMock).toHaveBeenCalledWith(false);
+ expect(setIsPopoverOpenMock).toHaveBeenCalledTimes(2);
+ });
});
});
@@ -228,13 +495,72 @@ describe('packages/popover', () => {
;
});
- test('Requires only children', () => {
+ test('only requires children', () => {
Popover Content ;
});
- test('does not allow specifying "portalClassName", when "usePortal" is false', () => {
+ test(`does not allow specifying portal props, when renderMode is not ${RenderMode.Portal}`, () => {
+ const scrollContainer = document.createElement('div');
+
+ // @ts-expect-error
+
+ Popover Content
+ ;
+
+ //@ts-expect-error
+
+ Popover Content
+ ;
+
// @ts-expect-error
-
+
+ Popover Content
+ ;
+
+ //@ts-expect-error
+
+ Popover Content
+ ;
+ });
+
+ test(`does not allow specifying dismissMode or onToggle, when renderMode is not ${RenderMode.TopLayer}`, () => {
+ // @ts-expect-error
+ {}}
+ >
+ Popover Content
+ ;
+
+ // @ts-expect-error
+ {}}
+ >
Popover Content
;
});
diff --git a/packages/popover/src/Popover/Popover.styles.ts b/packages/popover/src/Popover/Popover.styles.ts
new file mode 100644
index 0000000000..08bc4564e3
--- /dev/null
+++ b/packages/popover/src/Popover/Popover.styles.ts
@@ -0,0 +1,245 @@
+import { TransitionStatus } from 'react-transition-group';
+
+import { css, cx } from '@leafygreen-ui/emotion';
+import { createUniqueClassName } from '@leafygreen-ui/lib';
+import { transitionDuration } from '@leafygreen-ui/tokens';
+
+import { ExtendedPlacement, TransformAlign } from './Popover.types';
+
+const TRANSFORM_INITIAL_SCALE = 0.8;
+export const TRANSITION_DURATION = transitionDuration.default;
+
+export const contentClassName = createUniqueClassName('popover-content');
+
+export const hiddenPlaceholderStyle = css`
+ display: none;
+`;
+
+const basePopoverStyles = css`
+ margin: 0;
+ border: none;
+ padding: 0;
+ overflow: visible;
+ background-color: transparent;
+ width: max-content;
+
+ transition-property: opacity, transform, overlay, display;
+ transition-duration: ${TRANSITION_DURATION}ms;
+ transition-timing-function: ease-in-out;
+ transition-behavior: allow-discrete;
+
+ opacity: 0;
+ transform: scale(${TRANSFORM_INITIAL_SCALE});
+
+ &::backdrop {
+ transition-property: background, overlay, display;
+ transition-duration: ${TRANSITION_DURATION}ms;
+ transition-timing-function: ease-in-out;
+ transition-behavior: allow-discrete;
+ }
+
+ @starting-style {
+ :popover-open {
+ opacity: 0;
+ transform: scale(${TRANSFORM_INITIAL_SCALE});
+ }
+ }
+`;
+
+const getPositionStyles = ({
+ left,
+ position,
+ top,
+}: {
+ left: number;
+ position: 'absolute' | 'fixed';
+ top: number;
+}) => css`
+ left: ${left}px;
+ position: ${position};
+ top: ${top}px;
+`;
+
+const transformOriginStyles: Record = {
+ top: css`
+ transform-origin: bottom;
+ `,
+ 'top-start': css`
+ transform-origin: bottom left;
+ `,
+ 'top-end': css`
+ transform-origin: bottom right;
+ `,
+ bottom: css`
+ transform-origin: top;
+ `,
+ 'bottom-start': css`
+ transform-origin: top left;
+ `,
+ 'bottom-end': css`
+ transform-origin: top right;
+ `,
+ left: css`
+ transform-origin: right;
+ `,
+ 'left-start': css`
+ transform-origin: right top;
+ `,
+ 'left-end': css`
+ transform-origin: right bottom;
+ `,
+ right: css`
+ transform-origin: left;
+ `,
+ 'right-start': css`
+ transform-origin: left top;
+ `,
+ 'right-end': css`
+ transform-origin: left bottom;
+ `,
+ center: css`
+ transform-origin: center;
+ `,
+ 'center-start': css`
+ transform-origin: top;
+ `,
+ 'center-end': css`
+ transform-origin: bottom;
+ `,
+};
+
+const baseClosedStyles = css`
+ opacity: 0;
+`;
+
+const getClosedStyles = (spacing: number, transformAlign: TransformAlign) => {
+ switch (transformAlign) {
+ case TransformAlign.Top:
+ return cx(
+ baseClosedStyles,
+ css`
+ transform: translate3d(0, ${spacing}px, 0)
+ scale(${TRANSFORM_INITIAL_SCALE});
+ `,
+ );
+ case TransformAlign.Bottom:
+ return cx(
+ baseClosedStyles,
+ css`
+ transform: translate3d(0, -${spacing}px, 0)
+ scale(${TRANSFORM_INITIAL_SCALE});
+ `,
+ );
+ case TransformAlign.Left:
+ return cx(
+ baseClosedStyles,
+ css`
+ transform: translate3d(${spacing}px, 0, 0)
+ scale(${TRANSFORM_INITIAL_SCALE});
+ `,
+ );
+ case TransformAlign.Right:
+ return cx(
+ baseClosedStyles,
+ css`
+ transform: translate3d(-${spacing}px, 0, 0)
+ scale(${TRANSFORM_INITIAL_SCALE});
+ `,
+ );
+ case TransformAlign.Center:
+ default:
+ return cx(
+ baseClosedStyles,
+ css`
+ transform: scale(${TRANSFORM_INITIAL_SCALE});
+ `,
+ );
+ }
+};
+
+const baseOpenStyles = css`
+ opacity: 1;
+ pointer-events: initial;
+
+ &:popover-open {
+ opacity: 1;
+
+ pointer-events: initial;
+ }
+`;
+
+const getOpenStyles = (transformAlign: TransformAlign) => {
+ switch (transformAlign) {
+ case TransformAlign.Top:
+ case TransformAlign.Bottom:
+ return cx(
+ baseOpenStyles,
+ css`
+ transform: translateY(0) scale(1);
+
+ &:popover-open {
+ transform: translateY(0) scale(1);
+ }
+ `,
+ );
+ case TransformAlign.Left:
+ case TransformAlign.Right:
+ return cx(
+ baseOpenStyles,
+ css`
+ transform: translateX(0) scale(1);
+
+ &:popover-open {
+ transform: translateX(0) scale(1);
+ }
+ `,
+ );
+ case TransformAlign.Center:
+ default:
+ return cx(
+ baseOpenStyles,
+ css`
+ transform: scale(1);
+
+ &:popover-open {
+ transform: scale(1);
+ }
+ `,
+ );
+ }
+};
+
+export const getPopoverStyles = ({
+ className,
+ left,
+ placement,
+ popoverZIndex,
+ position,
+ spacing,
+ state,
+ top,
+ transformAlign,
+}: {
+ className?: string;
+ left: number;
+ placement: ExtendedPlacement;
+ popoverZIndex?: number;
+ position: 'absolute' | 'fixed';
+ spacing: number;
+ state: TransitionStatus;
+ top: number;
+ transformAlign: TransformAlign;
+}) =>
+ cx(
+ basePopoverStyles,
+ getPositionStyles({ left, position, top }),
+ transformOriginStyles[placement],
+ {
+ [getClosedStyles(spacing, transformAlign)]: state !== 'entered',
+ [getOpenStyles(transformAlign)]: state === 'entered',
+ [css`
+ z-index: ${popoverZIndex};
+ `]: typeof popoverZIndex === 'number',
+ },
+ className,
+ );
diff --git a/packages/popover/src/Popover.testutils.tsx b/packages/popover/src/Popover/Popover.testutils.tsx
similarity index 94%
rename from packages/popover/src/Popover.testutils.tsx
rename to packages/popover/src/Popover/Popover.testutils.tsx
index e179dbf778..65595e38b3 100644
--- a/packages/popover/src/Popover.testutils.tsx
+++ b/packages/popover/src/Popover/Popover.testutils.tsx
@@ -17,7 +17,6 @@ export const getJustify = (a: Align, j: Justify): string => {
default:
switch (j) {
case 'middle':
- case 'fit':
return 'center';
default:
@@ -38,7 +37,6 @@ export const getAlign = (a: Align, j: Justify) => {
default:
switch (j) {
case 'middle':
- case 'fit':
return 'center';
default:
diff --git a/packages/popover/src/Popover/Popover.tsx b/packages/popover/src/Popover/Popover.tsx
index 9d71966454..4b3c8937c2 100644
--- a/packages/popover/src/Popover/Popover.tsx
+++ b/packages/popover/src/Popover/Popover.tsx
@@ -1,54 +1,40 @@
-import React, { forwardRef, Fragment, useMemo, useState } from 'react';
+import React, { forwardRef, Fragment } from 'react';
import { Transition } from 'react-transition-group';
+import { autoUpdate, flip, offset, useFloating } from '@floating-ui/react';
import PropTypes from 'prop-types';
-import { css, cx } from '@leafygreen-ui/emotion';
-import {
- useIsomorphicLayoutEffect,
- useMutationObserver,
- useObjectDependency,
- usePrevious,
- useViewportSize,
-} from '@leafygreen-ui/hooks';
-import {
- usePopoverContext,
- usePopoverPortalContainer,
-} from '@leafygreen-ui/leafygreen-provider';
-import { consoleOnce, createUniqueClassName } from '@leafygreen-ui/lib';
+import { useMergeRefs } from '@leafygreen-ui/hooks';
+import { usePopoverContext } from '@leafygreen-ui/leafygreen-provider';
+import { consoleOnce } from '@leafygreen-ui/lib';
import Portal from '@leafygreen-ui/portal';
-import { transitionDuration } from '@leafygreen-ui/tokens';
+import { spacing as spacingToken } from '@leafygreen-ui/tokens';
+
+import {
+ getExtendedPlacementValues,
+ getFloatingPlacement,
+ getOffsetValue,
+ getWindowSafePlacementValues,
+} from '../utils/positionUtils';
+import {
+ useContentNode,
+ usePopoverProps,
+ useReferenceElement,
+} from './Popover.hooks';
+import {
+ contentClassName,
+ getPopoverStyles,
+ hiddenPlaceholderStyle,
+ TRANSITION_DURATION,
+} from './Popover.styles';
import {
Align,
+ DismissMode,
Justify,
PopoverComponentProps,
PopoverProps,
-} from '../Popover.types';
-import {
- calculatePosition,
- getElementDocumentPosition,
- getElementViewportPosition,
-} from '../utils/positionUtils';
-
-const rootPopoverStyle = css`
- position: absolute;
- transition: transform ${transitionDuration.default}ms ease-in-out,
- opacity ${transitionDuration.default}ms ease-in-out;
- opacity: 0;
-`;
-
-const mutationOptions = {
- // If attributes changes, such as className which affects layout
- attributes: true,
- // Watch if text changes in the node
- characterData: true,
- // Watch for any immediate children are modified
- childList: true,
- // Extend watching to entire sub tree to make sure we catch any modifications
- subtree: true,
-};
-
-export const contentClassName = createUniqueClassName('popover-content');
+ RenderMode,
+} from './Popover.types';
/**
*
@@ -62,13 +48,13 @@ export const contentClassName = createUniqueClassName('popover-content');
* @param props.active Boolean to describe whether or not Popover is active.
* @param props.spacing The spacing (in pixels) between the reference element, and the popover.
* @param props.align Alignment of Popover component relative to another element: `top`, `bottom`, `left`, `right`, `center-horizontal`, `center-vertical`.
- * @param props.justify Justification of Popover component relative to another element: `start`, `middle`, `end`, `fit`.
+ * @param props.justify Justification of Popover component relative to another element: `start`, `middle`, `end`.
* @param props.adjustOnMutation Should the Popover auto adjust its content when the DOM changes (using MutationObserver).
* @param props.children Content to appear inside of Popover container.
* @param props.className Classname applied to Popover container.
* @param props.popoverZIndex Number that controls the z-index of the popover element directly.
* @param props.refEl Reference element that Popover component should be positioned against.
- * @param props.usePortal Boolean to describe if content should be portaled to end of DOM, or appear in DOM tree.
+ * @param props.renderMode Options to render the popover element: `inline`, `portal`, `top-layer`.
* @param props.portalClassName Classname applied to root element of the portal.
* @param props.portalContainer HTML element that the popover is portaled within.
* @param props.portalRef A ref for the Portal element.
@@ -78,46 +64,48 @@ export const Popover = forwardRef(
(
{
active = false,
- spacing = 10,
- align = Align.Bottom,
- justify = Justify.Start,
adjustOnMutation = false,
+ align = Align.Bottom,
children,
className,
- popoverZIndex,
+ justify = Justify.Start,
refEl,
- usePortal = true,
+ ...rest
+ }: PopoverProps,
+ fwdRef,
+ ) => {
+ const {
+ renderMode = RenderMode.TopLayer,
+ /** top layer props */
+ dismissMode = DismissMode.Auto,
+ onToggle,
+ /** portal props */
+ usePortal,
portalClassName,
- portalContainer: portalContainerProp,
+ portalContainer,
portalRef,
- scrollContainer: scrollContainerProp,
+ scrollContainer,
+ /** react-transition-group props */
onEnter,
onEntering,
onEntered,
onExit,
onExiting,
onExited,
- ...rest
- }: PopoverProps,
- fwdRef,
- ) => {
- const [placeholderNode, setPlaceholderNode] = useState(
- null,
- );
- const [contentNode, setContentNode] = useState(null);
- const [forceUpdateCounter, setForceUpdateCounter] = useState(0);
-
+ /** style props */
+ popoverZIndex,
+ spacing = spacingToken[100],
+ ...restProps
+ } = usePopoverProps(rest);
const { setIsPopoverOpen } = usePopoverContext();
- let { portalContainer, scrollContainer } = usePopoverPortalContainer();
-
- portalContainer = portalContainerProp || portalContainer;
- scrollContainer = scrollContainerProp || scrollContainer;
-
- // When usePortal is true and a scrollContainer is passed in
- // show a warning if the portalContainer is not inside of the scrollContainer.
- // Note: If no portalContainer is passed the portalContainer will be undefined and this warning will show up.
- // By default if no portalContainer is passed the component will create a div and append it to the body.
+ /**
+ * When `usePortal` is true and a `scrollContainer` is defined,
+ * log a warning if the `portalContainer` is not inside of the `scrollContainer`.
+ *
+ * Note: If no `portalContainer` is provided,
+ * the `Portal` component will create a `div` and append it to the body.
+ */
if (usePortal && scrollContainer) {
if (!scrollContainer.contains(portalContainer as HTMLElement)) {
consoleOnce.warn(
@@ -126,230 +114,151 @@ export const Popover = forwardRef(
}
}
- // To remove StrictMode warnings produced by react-transition-group we need
- // to pass in a useRef object to the component.
- // To do so we're shadowing the contentNode onto this nodeRef as
- // only accepts useRef objects.
- const contentNodeRef = React.useRef(contentNode);
- contentNodeRef.current = contentNode;
-
- let referenceElement: HTMLElement | null = null;
-
- if (refEl && refEl.current) {
- referenceElement = refEl.current;
- } else if (placeholderNode) {
- const parent = placeholderNode.parentNode;
-
- if (parent && parent instanceof HTMLElement) {
- referenceElement = parent;
- }
- }
-
- const viewportSize = useViewportSize();
-
- // We calculate the position of the popover when it becomes active,
- // so it's safe for us to only enable the mutation observers once the popover is active.
- const observeMutations = adjustOnMutation && active;
-
- const lastTimeRefElMutated = useMutationObserver(
- referenceElement,
- mutationOptions,
- Date.now,
- observeMutations,
- );
-
- const lastTimeContentElMutated = useMutationObserver(
- contentNode?.parentNode as HTMLElement,
- mutationOptions,
- Date.now,
- observeMutations,
- );
+ const Root = usePortal ? Portal : Fragment;
+ const portalProps = {
+ className: portalContainer ? undefined : portalClassName,
+ container: portalContainer ?? undefined,
+ portalRef,
+ };
+ const rootProps = usePortal ? portalProps : {};
- // We don't memoize these values as they're reliant on scroll positioning
- const referenceElViewportPos = useObjectDependency(
- getElementViewportPosition(referenceElement, scrollContainer, true),
- );
+ const { referenceElement, referenceElDocumentPos, setPlaceholderElement } =
+ useReferenceElement(refEl, scrollContainer);
+ const { contentNodeRef, setContentNode } = useContentNode();
+
+ const { context, elements, placement, refs, strategy, x, y } = useFloating({
+ elements: {
+ reference: referenceElement,
+ },
+ middleware: [
+ offset(
+ ({ rects }) => getOffsetValue(align, spacing, rects),
+ [align, spacing],
+ ),
+ flip({
+ boundary: scrollContainer ?? 'clippingAncestors',
+ }),
+ ],
+ open: active,
+ placement: getFloatingPlacement(align, justify),
+ strategy: renderMode === RenderMode.TopLayer ? 'fixed' : 'absolute',
+ transform: false,
+ whileElementsMounted: autoUpdate,
+ });
- // We use contentNode.parentNode since the parentNode has a transition applied to it and we want to be able to get the width of this element before it is transformed. Also as noted below, the parentNode cannot have a ref on it.
- // Previously the contentNode was passed in but since it is a child of transformed element it was not possible to get an untransformed width.
- const contentElViewportPos = useObjectDependency(
- getElementViewportPosition(
- contentNode?.parentNode as HTMLElement,
- scrollContainer,
- ),
- );
+ const popoverRef = useMergeRefs([refs.setFloating, fwdRef]);
- const referenceElDocumentPos = useObjectDependency(
- useMemo(
- () =>
- getElementDocumentPosition(referenceElement, scrollContainer, true),
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [
- referenceElement,
- scrollContainer,
- viewportSize,
- lastTimeRefElMutated,
- active,
- align,
- justify,
- forceUpdateCounter,
- ],
- ),
- );
+ const { align: windowSafeAlign, justify: windowSafeJustify } =
+ getWindowSafePlacementValues(placement);
+ const { placement: extendedPlacement, transformAlign } =
+ getExtendedPlacementValues({
+ placement,
+ align,
+ });
- const contentElDocumentPos = useObjectDependency(
- useMemo(
- () => getElementDocumentPosition(contentNode),
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [
- contentNode?.parentNode,
- viewportSize,
- lastTimeContentElMutated,
- active,
- align,
- justify,
- forceUpdateCounter,
- ],
- ),
- );
+ const renderChildren = () => {
+ if (children === null) {
+ return null;
+ }
- const prevJustify = usePrevious(justify);
- const prevAlign = usePrevious(align);
+ if (typeof children === 'function') {
+ return children({
+ align: windowSafeAlign,
+ justify: windowSafeJustify,
+ referenceElPos: referenceElDocumentPos,
+ });
+ }
- const layoutMightHaveChanged =
- (prevJustify !== justify &&
- (justify === Justify.Fit || prevJustify === Justify.Fit)) ||
- (prevAlign !== align && justify === Justify.Fit);
+ return children;
+ };
- useIsomorphicLayoutEffect(() => {
- // justify={Justify.Fit} can cause the content's height/width to change
- // If we're switching to/from Fit, force an extra pass to make sure the popover is positioned correctly.
- // Also if we're switching between alignments and have Justify.Fit, it may switch between setting the width and
- // setting the height, so force an update in that case as well.
- if (layoutMightHaveChanged) {
- setForceUpdateCounter(n => n + 1);
+ const handleEntering = (isAppearing: boolean) => {
+ if (renderMode === RenderMode.TopLayer) {
+ // @ts-expect-error - `toggle` event not supported pre-typescript v5
+ elements.floating?.addEventListener('toggle', onToggle);
+ // @ts-expect-error - Popover API not currently supported in react v18 https://github.com/facebook/react/pull/27981
+ elements.floating?.showPopover?.();
}
- }, [layoutMightHaveChanged]);
-
- // Don't render the popover initially since computing the position depends on
- // the window which isn't available if the component is rendered on server side.
- const [shouldRender, setShouldRender] = useState(false);
- useIsomorphicLayoutEffect(() => setShouldRender(true), []);
+ onEntering?.(isAppearing);
+ };
- if (!shouldRender) {
- return null;
- }
+ const handleEntered = (isAppearing: boolean) => {
+ setIsPopoverOpen(true);
+ onEntered?.(isAppearing);
+ };
- const {
- align: windowSafeAlign,
- justify: windowSafeJustify,
- positionCSS: { transform, ...positionCSS },
- } = calculatePosition({
- useRelativePositioning: !usePortal,
- spacing,
- align,
- justify,
- referenceElViewportPos,
- referenceElDocumentPos,
- contentElViewportPos,
- contentElDocumentPos,
- scrollContainer,
- });
+ const handleExited = () => {
+ setIsPopoverOpen(false);
- const activeStyle = css`
- opacity: 1;
- position: ${usePortal ? '' : 'absolute'};
- pointer-events: initial;
- `;
+ if (renderMode === RenderMode.TopLayer) {
+ // @ts-expect-error - `toggle` event not supported pre-typescript v5
+ elements.floating?.removeEventListener('toggle', onToggle);
+ // @ts-expect-error - Popover API not currently supported in react v18 https://github.com/facebook/react/pull/27981
+ elements.floating?.hidePopover?.();
+ }
- const Root = usePortal ? Portal : Fragment;
- const portalProps = {
- className: portalContainer ? undefined : portalClassName,
- container: portalContainer ?? undefined,
- portalRef,
+ onExited?.();
};
- const rootProps = usePortal ? portalProps : {};
-
- let renderedChildren: null | React.ReactNode;
-
- if (children == null) {
- renderedChildren = null;
- } else if (typeof children === 'function') {
- renderedChildren = children({
- align: windowSafeAlign,
- justify: windowSafeJustify,
- referenceElPos: referenceElDocumentPos,
- });
- } else {
- renderedChildren = children;
- }
return (
- {
- setIsPopoverOpen(true);
- onEntered?.(...args);
- }}
- onExiting={onExiting}
- onExit={onExit}
- onExited={(...args) => {
- setIsPopoverOpen(false);
- onExited?.(...args);
- }}
- >
- {state => (
- <>
- {/* Using to prevent validateDOMNesting warnings. Warnings will still show up if `usePortal` is false */}
-
-
-
- {/*
- We create this inner node with a ref because placing it on its parent
- creates an infinite loop in some cases when dynamic styles are applied.
- */}
-
- {renderedChildren}
+ <>
+ {/* Using
as placeholder to prevent validateDOMNesting warnings
+ Warnings will still show up if `usePortal` is false */}
+
+
+ {state => (
+ <>
+
+
+ {/* We need to put `setContentNode` ref on this inner wrapper because
+ placing the ref on the parent will create an infinite loop in some cases
+ when dynamic styles are applied. */}
+
+ {renderChildren()}
+
-
-
- >
- )}
-
+
+ >
+ )}
+
+ >
);
},
);
@@ -370,7 +279,7 @@ Popover.propTypes = {
: PropTypes.any,
}),
/// @ts-ignore Types of property '[nominalTypeHack]' are incompatible. - error only in R18
- usePortal: PropTypes.bool,
+ renderMode: PropTypes.oneOf(Object.values(RenderMode)),
/// @ts-ignore Types of property '[nominalTypeHack]' are incompatible. - error only in R18
portalClassName: PropTypes.string,
spacing: PropTypes.number,
diff --git a/packages/popover/src/Popover/Popover.types.ts b/packages/popover/src/Popover/Popover.types.ts
new file mode 100644
index 0000000000..b612cc99c6
--- /dev/null
+++ b/packages/popover/src/Popover/Popover.types.ts
@@ -0,0 +1,387 @@
+import React from 'react';
+import { Transition } from 'react-transition-group';
+import { Placement } from '@floating-ui/react';
+
+import { HTMLElementProps } from '@leafygreen-ui/lib';
+
+type TransitionProps = React.ComponentProps
>;
+
+type TransitionLifecycleCallbacks = Pick<
+ TransitionProps,
+ 'onEnter' | 'onEntering' | 'onEntered' | 'onExit' | 'onExiting' | 'onExited'
+>;
+
+/**
+ * Options to render the popover element
+ * @param Inline will render the popover element inline in the DOM where it's written
+ * @param Portal will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer`
+ * @param TopLayer will render the popover element in the top layer
+ */
+export const RenderMode = {
+ Inline: 'inline',
+ Portal: 'portal',
+ TopLayer: 'top-layer',
+} as const;
+export type RenderMode = (typeof RenderMode)[keyof typeof RenderMode];
+
+/**
+ * Options to control how the popover element is dismissed. This should not be altered
+ * because it is intended to have parity with the web-native {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/popover popover attribute}
+ * @param Auto will automatically handle dismissal on backdrop click or esc key press, ensuring only one popover is visible at a time
+ * @param Manual will require that the consumer handle dismissal manually
+ */
+export const DismissMode = {
+ Auto: 'auto',
+ Manual: 'manual',
+} as const;
+export type DismissMode = (typeof DismissMode)[keyof typeof DismissMode];
+
+/** Local implementation of web-native `ToggleEvent` until we use typescript v5 */
+export interface ToggleEvent extends Event {
+ type: 'toggle';
+ newState: 'open' | 'closed';
+ oldState: 'open' | 'closed';
+}
+
+/**
+ * Options to determine the alignment of the popover relative to
+ * the other component
+ * @param Top will align content above other element
+ * @param Bottom will align content below other element
+ * @param Left will align content to the left of other element
+ * @param Right will align content to the right of other element
+ */
+const Align = {
+ Top: 'top',
+ Bottom: 'bottom',
+ Left: 'left',
+ Right: 'right',
+ CenterVertical: 'center-vertical',
+ CenterHorizontal: 'center-horizontal',
+} as const;
+
+type Align = (typeof Align)[keyof typeof Align];
+
+export { Align };
+
+/**
+ * Options to determine the justification of the popover relative to
+ * the other component
+ * @param Start will justify content against the start of other element
+ * @param Middle will justify content against the middle of other element
+ * @param End will justify content against the end of other element
+ */
+const Justify = {
+ Start: 'start',
+ Middle: 'middle',
+ End: 'end',
+} as const;
+
+type Justify = (typeof Justify)[keyof typeof Justify];
+
+export { Justify };
+
+/**
+ * This value is derived from the placement value returned by the `useFloating` hook and
+ * used to determine the `transform` styling of the popover element
+ */
+export const TransformAlign = {
+ Top: 'top',
+ Bottom: 'bottom',
+ Left: 'left',
+ Right: 'right',
+ Center: 'center',
+} as const;
+export type TransformAlign =
+ (typeof TransformAlign)[keyof typeof TransformAlign];
+
+export type ExtendedPlacement =
+ | Placement
+ | 'center'
+ | 'center-start'
+ | 'center-end';
+
+export interface ElementPosition {
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+ height: number;
+ width: number;
+}
+
+export interface ChildrenFunctionParameters {
+ align: Align;
+ justify: Justify;
+ referenceElPos: ElementPosition;
+}
+
+export interface RenderInlineProps {
+ /**
+ * Options to render the popover element
+ * @defaultValue 'top-layer'
+ * @param Inline will render the popover element inline in the DOM where it's written. This option is deprecated and will be removed in the future.
+ * @param Portal will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer`. This option is deprecated and will be removed in the future.
+ * @param TopLayer will render the popover element in the top layer
+ */
+ renderMode: 'inline';
+
+ /**
+ * When `renderMode="top-layer"`, these options can control how a popover element is dismissed
+ * - `'auto'` will automatically handle dismissal on backdrop click or key press, ensuring only one popover is visible at a time
+ * - `'manual'` will require that the consumer handle dismissal manually
+ */
+ dismissMode?: never;
+
+ /**
+ * A callback function that is called when the visibility of a popover element rendered in the top layer is toggled
+ */
+ onToggle?: never;
+
+ /**
+ * When `renderMode="portal"`, it specifies a class name to apply to the portal element
+ * @deprecated
+ */
+ portalClassName?: never;
+
+ /**
+ * When `renderMode="portal"`, it specifies an element to portal within. If not provided, a div is generated at the end of the body
+ * @deprecated
+ */
+ portalContainer?: never;
+
+ /**
+ * When `renderMode="portal"`, it passes a ref to forward to the portal element
+ * @deprecated
+ */
+ portalRef?: never;
+
+ /**
+ * When `renderMode="portal"`, it specifies the scrollable element to position relative to
+ * @deprecated
+ */
+ scrollContainer?: never;
+}
+
+export interface RenderPortalProps {
+ /**
+ * Options to render the popover element
+ * @defaultValue 'top-layer'
+ * @param Inline will render the popover element inline in the DOM where it's written. This option is deprecated and will be removed in the future.
+ * @param Portal will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer`. This option is deprecated and will be removed in the future.
+ * @param TopLayer will render the popover element in the top layer
+ */
+ renderMode: 'portal';
+
+ /**
+ * When `renderMode="top-layer"`, these options can control how a popover element is dismissed
+ * - `'auto'` will automatically handle dismissal on backdrop click or key press, ensuring only one popover is visible at a time
+ * - `'manual'` will require that the consumer handle dismissal manually
+ */
+ dismissMode?: never;
+
+ /**
+ * When `renderMode="top-layer"`, this callback function is called when the visibility of a popover element is toggled
+ */
+ onToggle?: never;
+
+ /**
+ * When `renderMode="portal"`, it specifies a class name to apply to the portal element
+ * @deprecated
+ */
+ portalClassName?: string;
+
+ /**
+ * When `renderMode="portal"`, it specifies an element to portal within. If not provided, a div is generated at the end of the body
+ * @deprecated
+ */
+ portalContainer?: HTMLElement | null;
+
+ /**
+ * When `renderMode="portal"`, it passes a ref to forward to the portal element
+ * @deprecated
+ */
+ portalRef?: React.MutableRefObject;
+
+ /**
+ * When `renderMode="portal"`, it specifies the scrollable element to position relative to
+ * @deprecated
+ */
+ scrollContainer?: HTMLElement | null;
+}
+
+export interface RenderTopLayerProps {
+ /**
+ * Options to render the popover element
+ * @defaultValue 'top-layer'
+ * @param Inline will render the popover element inline in the DOM where it's written. This option is deprecated and will be removed in the future.
+ * @param Portal will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer`. This option is deprecated and will be removed in the future.
+ * @param TopLayer will render the popover element in the top layer
+ */
+ renderMode?: 'top-layer';
+
+ /**
+ * When `renderMode="top-layer"`, these options can control how a popover element is dismissed
+ * - `'auto'` will automatically handle dismissal on backdrop click or key press, ensuring only one popover is visible at a time
+ * - `'manual'` will require that the consumer handle dismissal manually
+ */
+ dismissMode?: DismissMode;
+
+ /**
+ * A callback function that is called when the visibility of a popover element rendered in the top layer is toggled
+ */
+ onToggle?: (e: ToggleEvent) => void;
+
+ /**
+ * When `renderMode="portal"`, it specifies a class name to apply to the portal element
+ * @deprecated
+ */
+ portalClassName?: never;
+
+ /**
+ * When `renderMode="portal"`, it specifies an element to portal within. If not provided, a div is generated at the end of the body
+ * @deprecated
+ */
+ portalContainer?: never;
+
+ /**
+ * When `renderMode="portal"`, it passes a ref to forward to the portal element
+ * @deprecated
+ */
+ portalRef?: never;
+
+ /**
+ * When `renderMode="portal"`, it specifies the scrollable element to position relative to
+ * @deprecated
+ */
+ scrollContainer?: never;
+}
+
+export type PopoverRenderModeProps =
+ | RenderInlineProps
+ | RenderPortalProps
+ | RenderTopLayerProps;
+
+/**
+ * Base popover props.
+ * Use these props to extend popover behavior
+ */
+export type PopoverProps = {
+ /**
+ * Content that will appear inside of the popover component.
+ */
+ children:
+ | React.ReactNode
+ | ((Options: ChildrenFunctionParameters) => React.ReactNode);
+
+ /**
+ * Determines the active state of the popover component
+ *
+ * default: `false`
+ */
+ active?: boolean;
+
+ /**
+ * Should the Popover auto adjust its content when the DOM changes (using MutationObserver).
+ *
+ * default: false
+ */
+ adjustOnMutation?: boolean;
+
+ /**
+ * Determines the alignment of the popover content relative to the trigger element
+ *
+ * default: `bottom`
+ */
+ align?: Align;
+
+ /**
+ * Class name applied to popover container.
+ */
+ className?: string;
+
+ /**
+ * Determines the justification of the popover content relative to the trigger element
+ *
+ * default: `start`
+ */
+ justify?: Justify;
+
+ /**
+ * Click event handler passed to the root div element within the portal container.
+ */
+ onClick?: React.MouseEventHandler;
+
+ /**
+ * Number that controls the z-index of the popover element directly.
+ * @deprecated
+ */
+ popoverZIndex?: number;
+
+ /**
+ * A reference to the element against which the popover component will be positioned.
+ */
+ refEl?: React.RefObject;
+
+ /**
+ * Specifies the amount of spacing (in pixels) between the trigger element and the Popover content.
+ *
+ * default: `4`
+ */
+ spacing?: number;
+} & PopoverRenderModeProps &
+ TransitionLifecycleCallbacks;
+
+/** Props used by the popover component */
+export type PopoverComponentProps = Omit, 'children'> &
+ PopoverProps;
+
+export interface UseReferenceElementReturnObj {
+ /**
+ * Element against which the popover component will be positioned
+ */
+ referenceElement: HTMLElement | null;
+
+ /**
+ * Document position details of the reference element
+ */
+ referenceElDocumentPos: ElementPosition;
+
+ /**
+ * Callback ref attached to placeholder span element to access the parent element
+ */
+ setPlaceholderElement: React.Dispatch<
+ React.SetStateAction
+ >;
+}
+
+export interface UseContentNodeReturnObj {
+ /**
+ * `contentNode` is the direct child of the popover element and wraps the children. It
+ * is used to calculate the position of the popover because its parent has a transition.
+ * This prevents getting the width of the popover until the transition completes
+ */
+ contentNode: HTMLDivElement | null;
+
+ /**
+ * We shadow the `contentNode` onto this `contentNodeRef` as `` from
+ * react-transition-group only accepts a `MutableRefObject` type. Without this, StrictMode
+ * warnings are produced by react-transition-group.
+ */
+ contentNodeRef: React.MutableRefObject;
+
+ /**
+ * Dispatch method to attach `contentNode` to the `ContentWrapper`
+ */
+ setContentNode: React.Dispatch>;
+}
+
+export interface GetPopoverRenderModeProps {
+ dismissMode?: DismissMode;
+ onToggle?: (e: ToggleEvent) => void;
+ portalClassName?: string;
+ portalContainer?: HTMLElement | null;
+ portalRef?: React.MutableRefObject;
+ renderMode: RenderMode;
+ scrollContainer?: HTMLElement | null;
+}
diff --git a/packages/popover/src/Popover/index.ts b/packages/popover/src/Popover/index.ts
index 822a9b380d..5bc4c3ec55 100644
--- a/packages/popover/src/Popover/index.ts
+++ b/packages/popover/src/Popover/index.ts
@@ -1 +1,15 @@
-export { contentClassName, Popover } from './Popover';
+export { Popover } from './Popover';
+export { contentClassName } from './Popover.styles';
+export { getAlign, getJustify } from './Popover.testutils';
+export {
+ Align,
+ type ChildrenFunctionParameters,
+ DismissMode,
+ type ElementPosition,
+ type GetPopoverRenderModeProps,
+ Justify,
+ type PopoverProps,
+ type PopoverRenderModeProps,
+ RenderMode,
+ type ToggleEvent,
+} from './Popover.types';
diff --git a/packages/popover/src/index.ts b/packages/popover/src/index.ts
index 20d3ab4ad9..5f80c616d9 100644
--- a/packages/popover/src/index.ts
+++ b/packages/popover/src/index.ts
@@ -1,18 +1,22 @@
-import { contentClassName, Popover } from './Popover';
-import { getAlign, getJustify } from './Popover.testutils';
+import { getAlign, getJustify, Popover } from './Popover';
+
+export const TestUtils = {
+ getAlign,
+ getJustify,
+};
export {
Align,
type ChildrenFunctionParameters,
+ contentClassName,
+ DismissMode,
type ElementPosition,
Justify,
+ Popover,
type PopoverProps,
- type PortalControlProps,
-} from './Popover.types';
-
-export { contentClassName, Popover };
-export const TestUtils = {
- getAlign,
- getJustify,
-};
+ type PopoverRenderModeProps,
+ RenderMode,
+ type ToggleEvent,
+} from './Popover';
+export { getPopoverRenderModeProps } from './utils/getPopoverRenderModeProps';
export default Popover;
diff --git a/packages/popover/src/utils/getPopoverRenderModeProps.spec.ts b/packages/popover/src/utils/getPopoverRenderModeProps.spec.ts
new file mode 100644
index 0000000000..22abcdc5da
--- /dev/null
+++ b/packages/popover/src/utils/getPopoverRenderModeProps.spec.ts
@@ -0,0 +1,51 @@
+import { PopoverRenderModeProps, RenderMode } from '../Popover';
+
+import { getPopoverRenderModeProps } from './getPopoverRenderModeProps';
+
+const testProps = {
+ dismissMode: 'auto',
+ onToggle: jest.fn(),
+ portalClassName: 'portal-class',
+ portalContainer: document.createElement('div'),
+ portalRef: { current: null },
+ scrollContainer: document.createElement('div'),
+};
+
+describe('getPopoverRenderModeProps', () => {
+ test(`should return only renderMode when renderMode is ${RenderMode.Inline}`, () => {
+ const props = getPopoverRenderModeProps({
+ ...testProps,
+ renderMode: RenderMode.Inline,
+ });
+
+ expect(props).toEqual({ renderMode: RenderMode.Inline });
+ });
+
+ test(`should return portal related props when renderMode is ${RenderMode.Portal}`, () => {
+ const props = getPopoverRenderModeProps({
+ ...testProps,
+ renderMode: RenderMode.Portal,
+ });
+
+ expect(props).toEqual({
+ renderMode: RenderMode.Portal,
+ portalClassName: testProps.portalClassName,
+ portalContainer: testProps.portalContainer,
+ portalRef: testProps.portalRef,
+ scrollContainer: testProps.scrollContainer,
+ });
+ });
+
+ test(`should return top layer related props when renderMode is ${RenderMode.TopLayer}`, () => {
+ const props = getPopoverRenderModeProps({
+ ...testProps,
+ renderMode: RenderMode.TopLayer,
+ });
+
+ expect(props).toEqual({
+ renderMode: RenderMode.TopLayer,
+ dismissMode: testProps.dismissMode,
+ onToggle: testProps.onToggle,
+ });
+ });
+});
diff --git a/packages/popover/src/utils/getPopoverRenderModeProps.ts b/packages/popover/src/utils/getPopoverRenderModeProps.ts
new file mode 100644
index 0000000000..4a89ffdb9d
--- /dev/null
+++ b/packages/popover/src/utils/getPopoverRenderModeProps.ts
@@ -0,0 +1,39 @@
+import {
+ GetPopoverRenderModeProps,
+ PopoverRenderModeProps,
+ RenderMode,
+} from '../Popover';
+
+/**
+ * Util function that returns relevant properties based on the `renderMode` prop
+ * @internal
+ */
+export const getPopoverRenderModeProps = ({
+ dismissMode,
+ onToggle,
+ portalClassName,
+ portalContainer,
+ portalRef,
+ renderMode,
+ scrollContainer,
+}: GetPopoverRenderModeProps): PopoverRenderModeProps => {
+ if (renderMode === RenderMode.Inline) {
+ return { renderMode };
+ }
+
+ if (renderMode === RenderMode.Portal) {
+ return {
+ renderMode,
+ portalClassName,
+ portalContainer,
+ portalRef,
+ scrollContainer,
+ };
+ }
+
+ return {
+ dismissMode,
+ onToggle,
+ renderMode,
+ };
+};
diff --git a/packages/popover/src/utils/positionUtils.spec.ts b/packages/popover/src/utils/positionUtils.spec.ts
index d094283b95..3e11e55220 100644
--- a/packages/popover/src/utils/positionUtils.spec.ts
+++ b/packages/popover/src/utils/positionUtils.spec.ts
@@ -1,82 +1,15 @@
-import { Align, Justify } from '../Popover.types';
+import { Placement } from '@floating-ui/react';
+
+import { Align, Justify } from '../Popover/Popover.types';
import {
- calculatePosition,
getElementDocumentPosition,
- getElementViewportPosition,
+ getExtendedPlacementValues,
+ getFloatingPlacement,
+ getOffsetValue,
+ getWindowSafePlacementValues,
} from './positionUtils';
-// These values were explicitly created to test Popover positioning against a clearly defined window size.
-const SPACING = 5;
-const WINDOW_WIDTH = 100;
-const WINDOW_HEIGHT = 100;
-
-const refElPos = {
- top: {
- bottom: 10,
- height: 10,
- left: 45,
- right: 55,
- top: 0,
- width: 10,
- },
-
- right: {
- bottom: 55,
- height: 10,
- left: 90,
- right: 100,
- top: 45,
- width: 10,
- },
-
- bottom: {
- bottom: 100,
- height: 10,
- left: 45,
- right: 55,
- top: 90,
- width: 10,
- },
-
- left: {
- bottom: 55,
- height: 10,
- left: 0,
- right: 10,
- top: 45,
- width: 10,
- },
-
- center: {
- bottom: 55,
- height: 10,
- left: 45,
- right: 55,
- top: 45,
- width: 10,
- },
-};
-
-const contentElPos = {
- bottom: 20,
- height: 20,
- left: 0,
- right: 20,
- top: 0,
- width: 20,
-};
-
-const scrollContainerNull = null;
-
-function checkPixelValue(actual: string | number, expected: number) {
- if (typeof actual === 'string') {
- expect(actual).toBe(`${expected}px`);
- } else {
- expect(actual).toBe(expected);
- }
-}
-
Object.defineProperty(window, 'getComputedStyle', {
value: () => ({
width: '0px',
@@ -84,2980 +17,210 @@ Object.defineProperty(window, 'getComputedStyle', {
});
describe('positionUtils', () => {
- describe('calculatePosition', () => {
- test('returns an object with three key-value pairs', () => {
- const calcPositionObject = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(calcPositionObject.align).toBeTruthy();
- expect(calcPositionObject.justify).toBeTruthy();
- expect(calcPositionObject.positionCSS).toBeTruthy();
- });
-
- describe('uses the scrollContainer offsetWidth and offsetHeight instead of windowHeight and windowWidth', () => {
- let offsetHeightSpy: jest.SpyInstance, offsetWeightSpy: jest.SpyInstance;
- const mockScrollContainer = document.createElement('div');
-
- // Mock the width and height of an HTML element
- beforeEach(() => {
- offsetWeightSpy = jest
- .spyOn(mockScrollContainer, 'offsetWidth', 'get')
- .mockImplementation(() => 100);
- offsetHeightSpy = jest
- .spyOn(mockScrollContainer, 'offsetHeight', 'get')
- .mockImplementation(() => 100);
- });
-
- afterEach(() => {
- offsetWeightSpy.mockRestore();
- offsetHeightSpy.mockRestore();
- });
+ describe('getElementDocumentPosition', () => {
+ test('given an element, it returns an object with information about its position', () => {
+ const div = document.createElement('div');
+ document.body.appendChild(div);
- test('Align.Right, Justify.Start works', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: 0,
- windowWidth: 0,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: mockScrollContainer,
- });
+ const pos = getElementDocumentPosition(div);
- expect(align).toBe('right');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.left, 60);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
+ expect(pos.top).toBe(0);
+ expect(pos.bottom).toBe(0);
+ expect(pos.left).toBe(0);
+ expect(pos.right).toBe(0);
+ expect(pos.height).toBe(0);
+ expect(pos.width).toBe(0);
});
+ });
- describe('when the reference element is on the top', () => {
- describe('Align.Top', () => {
- test('Align.Top respositions to Align.Bottom based on available space', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('bottom');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 15);
- checkPixelValue(positionCSS.left, 45);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 45);
- checkPixelValue(positionCSS.right, 45);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Right', () => {
- test('Justify.Start works', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('right');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.left, 60);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle repositions to Justify.Start based on available space', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('right');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.left, 60);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Bottom repositions to Justify.Start based on available space', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('right');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.left, 60);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.bottom, 90);
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Bottom', () => {
- test('Justify.Start works', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('bottom');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 15);
- checkPixelValue(positionCSS.left, 45);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle works', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('bottom');
- expect(justify).toBe('middle');
- checkPixelValue(positionCSS.top, 15);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('bottom');
- expect(justify).toBe('end');
- checkPixelValue(positionCSS.top, 15);
- checkPixelValue(positionCSS.left, 35);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 45);
- checkPixelValue(positionCSS.right, 45);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Left', () => {
- test('Justify.Start', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('left');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.left, 20);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Center respositions to Justify.Start based on available space', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('left');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.left, 20);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.End respositions to Justify.Start based on available space', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('left');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.left, 20);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.bottom, 90);
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.CenterVertical', () => {
- test('Align.CenterVertical respositions to Align.Bottom based on available space', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('bottom');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 15);
- checkPixelValue(positionCSS.left, 45);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 45);
- checkPixelValue(positionCSS.right, 45);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.CenterHorizontal', () => {
- test('Justify.Start', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterHorizontal,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('center-horizontal');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Center respositions to Justify.Start based on available space', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterHorizontal,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('center-horizontal');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.End respositions to Justify.Start based on available space', () => {
- const { align, justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterHorizontal,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(align).toBe('center-horizontal');
- expect(justify).toBe('start');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterHorizontal,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.top,
- referenceElViewportPos: refElPos.top,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.left, 40);
- checkPixelValue(positionCSS.bottom, 90);
- expect(positionCSS.transformOrigin).toBe('center center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
- });
+ describe('getFloatingPlacement', () => {
+ test('given standard align and justify values, it returns the correct floating placement', () => {
+ expect(getFloatingPlacement(Align.Top, Justify.Start)).toBe('top-start');
+ expect(getFloatingPlacement(Align.Top, Justify.Middle)).toBe('top');
+ expect(getFloatingPlacement(Align.Top, Justify.End)).toBe('top-end');
});
- describe('when the reference element is on the right', () => {
- describe('Align.Top', () => {
- test('Justify.Start repositions to Justify.End based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 20);
- checkPixelValue(positionCSS.left, 80);
- expect(positionCSS.transformOrigin).toBe('right bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle repositions to Justify.Start based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 20);
- checkPixelValue(positionCSS.left, 80);
- expect(positionCSS.transformOrigin).toBe('right bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 20);
- checkPixelValue(positionCSS.left, 80);
- expect(positionCSS.transformOrigin).toBe('right bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 90);
- checkPixelValue(positionCSS.right, 0);
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
- });
- describe('Align.Right', () => {
- test('Align.Right respositions to Align.Left based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.left, 65);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
- });
- describe('Align.Bottom', () => {
- test('Justify.Start repositions to Justify.End based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 60);
- checkPixelValue(positionCSS.left, 80);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle repositions to Justify.End based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 60);
- checkPixelValue(positionCSS.left, 80);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 60);
- checkPixelValue(positionCSS.left, 80);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 90);
- checkPixelValue(positionCSS.right, 0);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
- });
- describe('Align.Left', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.left, 65);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 65);
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 35);
- checkPixelValue(positionCSS.left, 65);
- expect(positionCSS.transformOrigin).toBe('right bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.bottom, 45);
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
- });
- describe('Align.CenterVertical', () => {
- test('Justify.Start repositions to Justify.End based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 80);
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Middle repositions to Justify.Start based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 80);
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 80);
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 90);
- checkPixelValue(positionCSS.right, 0);
- expect(positionCSS.transformOrigin).toBe('center center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
- });
- describe('Align.CenterHorizontal', () => {
- test('Align.CenterHorizontal respositions to Align.Left based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterHorizontal,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.right,
- referenceElViewportPos: refElPos.right,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.left, 65);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
- });
+ test(`given align value of ${Align.CenterHorizontal}, it returns a right* placement`, () => {
+ expect(getFloatingPlacement(Align.CenterHorizontal, Justify.Start)).toBe(
+ 'right-start',
+ );
+ expect(getFloatingPlacement(Align.CenterHorizontal, Justify.Middle)).toBe(
+ 'right',
+ );
+ expect(getFloatingPlacement(Align.CenterHorizontal, Justify.End)).toBe(
+ 'right-end',
+ );
});
- describe('when reference element is on the bottom', () => {
- describe('Align.Top', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 65);
- checkPixelValue(positionCSS.left, 45);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 65);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 65);
- checkPixelValue(positionCSS.left, 35);
- expect(positionCSS.transformOrigin).toBe('right bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 45);
- checkPixelValue(positionCSS.right, 45);
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Right', () => {
- test('Justify.Start repositions to Justify.End based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 80);
- checkPixelValue(positionCSS.left, 60);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle repositions to Justify.End based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 80);
- checkPixelValue(positionCSS.left, 60);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 80);
- checkPixelValue(positionCSS.left, 60);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 90);
- checkPixelValue(positionCSS.bottom, 0);
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
- });
- describe('Align.Bottom', () => {
- test('Align.Bottom repositions to Align.Start based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 65);
- checkPixelValue(positionCSS.left, 45);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Left', () => {
- test('Justify.Start repositions to Justify.End based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 80);
- checkPixelValue(positionCSS.left, 20);
- expect(positionCSS.transformOrigin).toBe('right bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle repositions to Justify.End based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 80);
- checkPixelValue(positionCSS.left, 20);
- expect(positionCSS.transformOrigin).toBe('right bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 80);
- checkPixelValue(positionCSS.left, 20);
- expect(positionCSS.transformOrigin).toBe('right bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 90);
- checkPixelValue(positionCSS.bottom, 0);
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.CenterVertical', () => {
- test('Align.CenterVertical repositions to Align.Start based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 65);
- checkPixelValue(positionCSS.left, 45);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.CenterHorizontal', () => {
- test('Justify.Start repositions to Justify.End based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterHorizontal,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 80);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Middle repositions to Justify.End based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterHorizontal,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 80);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterHorizontal,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 80);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterHorizontal,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.bottom,
- referenceElViewportPos: refElPos.bottom,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 90);
- checkPixelValue(positionCSS.bottom, 0);
- expect(positionCSS.transformOrigin).toBe('center center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
- });
+ test(`given align value of ${Align.CenterVertical}, it returns a bottom* placement`, () => {
+ expect(getFloatingPlacement(Align.CenterVertical, Justify.Start)).toBe(
+ 'bottom-start',
+ );
+ expect(getFloatingPlacement(Align.CenterVertical, Justify.Middle)).toBe(
+ 'bottom',
+ );
+ expect(getFloatingPlacement(Align.CenterVertical, Justify.End)).toBe(
+ 'bottom-end',
+ );
});
+ });
- describe('when reference element is on the left', () => {
- describe('Align.Top', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 20);
- checkPixelValue(positionCSS.left, 0);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle repositions to Justify.Start based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 20);
- checkPixelValue(positionCSS.left, 0);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.End repositions to Justify.Start based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 20);
- checkPixelValue(positionCSS.left, 0);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 0);
- checkPixelValue(positionCSS.right, 90);
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Right', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.left, 15);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 15);
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 35);
- checkPixelValue(positionCSS.left, 15);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.bottom, 45);
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
- });
- describe('Align.Bottom', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 60);
- checkPixelValue(positionCSS.left, 0);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle repositions to Justify.Start based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 60);
- checkPixelValue(positionCSS.left, 0);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.End repositions to Justify.Start based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 60);
- checkPixelValue(positionCSS.left, 0);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 0);
- checkPixelValue(positionCSS.right, 90);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Left', () => {
- test('Align.Left repositions to Align.Right based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.left, 15);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.CenterVertical', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 0);
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Middle repositions to Justify.Start based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 0);
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.End repositions to Justify.Start based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 0);
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 0);
- checkPixelValue(positionCSS.right, 90);
- expect(positionCSS.transformOrigin).toBe('center center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
- });
-
- describe('Align.CenterHorizontal', () => {
- test('Align.CenterHorizontal repositions to Align.Right based on available space', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterHorizontal,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.left,
- referenceElViewportPos: refElPos.left,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
+ describe('getWindowSafePlacementValues', () => {
+ const testPlacements: Array<{
+ placement: Placement;
+ safePlacement: {
+ align: Align;
+ justify: Justify;
+ };
+ }> = [
+ { placement: 'top', safePlacement: { align: 'top', justify: 'middle' } },
+ {
+ placement: 'right',
+ safePlacement: { align: 'right', justify: 'middle' },
+ },
+ {
+ placement: 'bottom',
+ safePlacement: { align: 'bottom', justify: 'middle' },
+ },
+ {
+ placement: 'left',
+ safePlacement: { align: 'left', justify: 'middle' },
+ },
+ {
+ placement: 'top-start',
+ safePlacement: { align: 'top', justify: 'start' },
+ },
+ { placement: 'top-end', safePlacement: { align: 'top', justify: 'end' } },
+ {
+ placement: 'right-start',
+ safePlacement: { align: 'right', justify: 'start' },
+ },
+ {
+ placement: 'right-end',
+ safePlacement: { align: 'right', justify: 'end' },
+ },
+ {
+ placement: 'bottom-start',
+ safePlacement: { align: 'bottom', justify: 'start' },
+ },
+ {
+ placement: 'bottom-end',
+ safePlacement: { align: 'bottom', justify: 'end' },
+ },
+ {
+ placement: 'left-start',
+ safePlacement: { align: 'left', justify: 'start' },
+ },
+ {
+ placement: 'left-end',
+ safePlacement: { align: 'left', justify: 'end' },
+ },
+ ];
+ test.each(testPlacements)(
+ 'given a placement of $placement, it returns the correct window-safe placement values',
+ ({ placement, safePlacement }) => {
+ expect(getWindowSafePlacementValues(placement)).toEqual(safePlacement);
+ },
+ );
+ });
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.left, 15);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
- });
+ describe('getExtendedPlacementValues', () => {
+ test(`returns standard placement values if align prop is not ${Align.CenterHorizontal} or ${Align.CenterVertical}`, () => {
+ expect(
+ getExtendedPlacementValues({ placement: 'top-start', align: 'top' }),
+ ).toEqual({ placement: 'top-start', transformAlign: 'top' });
+ expect(
+ getExtendedPlacementValues({ placement: 'bottom', align: 'bottom' }),
+ ).toEqual({ placement: 'bottom', transformAlign: 'bottom' });
+ expect(
+ getExtendedPlacementValues({ placement: 'left-end', align: 'left' }),
+ ).toEqual({ placement: 'left-end', transformAlign: 'left' });
+ expect(
+ getExtendedPlacementValues({ placement: 'right', align: 'right' }),
+ ).toEqual({ placement: 'right', transformAlign: 'right' });
});
- describe('when reference element is in the center', () => {
- describe('Align.Top', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 20);
- checkPixelValue(positionCSS.left, 45);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 20);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 20);
- checkPixelValue(positionCSS.left, 35);
- expect(positionCSS.transformOrigin).toBe('right bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Top,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 45);
- checkPixelValue(positionCSS.right, 45);
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Right', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.left, 60);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 60);
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 35);
- checkPixelValue(positionCSS.left, 60);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Right,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.bottom, 45);
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Bottom', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 60);
- checkPixelValue(positionCSS.left, 45);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 60);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 60);
- checkPixelValue(positionCSS.left, 35);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Bottom,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 45);
- checkPixelValue(positionCSS.right, 45);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Left', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.left, 20);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 20);
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 35);
- checkPixelValue(positionCSS.left, 20);
- expect(positionCSS.transformOrigin).toBe('right bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.Left,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.bottom, 45);
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.CenterVertical', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 45);
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 35);
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterVertical,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 45);
- checkPixelValue(positionCSS.right, 45);
- expect(positionCSS.transformOrigin).toBe('center center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
- });
-
- describe('Align.CenterHorizontal', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
- align: Align.CenterHorizontal,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
+ describe(`when align prop is ${Align.CenterHorizontal}`, () => {
+ test('returns right* placement values for right, right-start, and right-end placements', () => {
+ expect(
+ getExtendedPlacementValues({
+ placement: 'right',
align: Align.CenterHorizontal,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 40);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
+ }),
+ ).toEqual({ placement: 'center', transformAlign: 'center' });
+ expect(
+ getExtendedPlacementValues({
+ placement: 'right-start',
align: Align.CenterHorizontal,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 35);
- checkPixelValue(positionCSS.left, 40);
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: false,
+ }),
+ ).toEqual({ placement: 'center-start', transformAlign: 'center' });
+ expect(
+ getExtendedPlacementValues({
+ placement: 'right-end',
align: Align.CenterHorizontal,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 45);
- checkPixelValue(positionCSS.bottom, 45);
- expect(positionCSS.transformOrigin).toBe('center center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
+ }),
+ ).toEqual({ placement: 'center-end', transformAlign: 'center' });
});
});
- describe('when useRelativePositioning is true', () => {
- describe('Align.Top', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Top,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(positionCSS.bottom).toBe('calc(100% + 5px)');
- checkPixelValue(positionCSS.left, 0);
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Top,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(positionCSS.bottom).toBe('calc(100% + 5px)');
- expect(positionCSS.left).toBe('-5px');
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Top,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(positionCSS.bottom).toBe('calc(100% + 5px)');
- checkPixelValue(positionCSS.right, 0);
- expect(positionCSS.transformOrigin).toBe('right bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Top,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 0);
- checkPixelValue(positionCSS.right, 0);
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, 5px, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Right', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Right,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 0);
- expect(positionCSS.left).toBe('calc(100% + 5px)');
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Right,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(positionCSS.top).toBe('-5px');
- expect(positionCSS.left).toBe('calc(100% + 5px)');
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Right,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.bottom, 0);
- expect(positionCSS.left).toBe('calc(100% + 5px)');
- expect(positionCSS.transformOrigin).toBe('left bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Right,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.bottom, 0);
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe(
- 'translate3d(-5px, 0, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Bottom', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Bottom,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(positionCSS.top).toBe('calc(100% + 5px)');
- checkPixelValue(positionCSS.left, 0);
- expect(positionCSS.transformOrigin).toBe('left top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Bottom,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(positionCSS.top).toBe('calc(100% + 5px)');
- expect(positionCSS.left).toBe('-5px');
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Bottom,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(positionCSS.top).toBe('calc(100% + 5px)');
- checkPixelValue(positionCSS.right, 0);
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Bottom,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 0);
- checkPixelValue(positionCSS.right, 0);
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe(
- 'translate3d(0, -5px, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.Left', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Left,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 0);
- expect(positionCSS.right).toBe('calc(100% + 5px)');
- expect(positionCSS.transformOrigin).toBe('right top');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Left,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(positionCSS.top).toBe('-5px');
- expect(positionCSS.right).toBe('calc(100% + 5px)');
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Left,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.bottom, 0);
- expect(positionCSS.right).toBe('calc(100% + 5px)');
- expect(positionCSS.transformOrigin).toBe('right bottom');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.Left,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.bottom, 0);
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe(
- 'translate3d(5px, 0, 0) scale(0.8)',
- );
- });
- });
-
- describe('Align.CenterVertical', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.CenterVertical,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(positionCSS.top).toBe('calc(5px - 50%)');
- checkPixelValue(positionCSS.left, 0);
- expect(positionCSS.transformOrigin).toBe('left center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
+ describe(`when align prop is ${Align.CenterVertical}`, () => {
+ test('returns bottom* placement values for bottom, bottom-start, and bottom-end placements', () => {
+ expect(
+ getExtendedPlacementValues({
+ placement: 'bottom',
align: Align.CenterVertical,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(positionCSS.top).toBe('calc(5px - 50%)');
- expect(positionCSS.left).toBe('-5px');
- expect(positionCSS.transformOrigin).toBe('center center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
+ }),
+ ).toEqual({ placement: 'center', transformAlign: 'center' });
+ expect(
+ getExtendedPlacementValues({
+ placement: 'bottom-start',
align: Align.CenterVertical,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(positionCSS.top).toBe('calc(5px - 50%)');
- checkPixelValue(positionCSS.right, 0);
- expect(positionCSS.transformOrigin).toBe('right center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
+ }),
+ ).toEqual({ placement: 'right', transformAlign: 'right' });
+ expect(
+ getExtendedPlacementValues({
+ placement: 'bottom-end',
align: Align.CenterVertical,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.left, 0);
- checkPixelValue(positionCSS.right, 0);
- expect(positionCSS.transformOrigin).toBe('center center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
- });
-
- describe('Align.CenterHorizontal', () => {
- test('Justify.Start works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.CenterHorizontal,
- justify: Justify.Start,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.top, 0);
- expect(positionCSS.left).toBe('calc(5px - 50%)');
- expect(positionCSS.transformOrigin).toBe('center top');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Middle works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.CenterHorizontal,
- justify: Justify.Middle,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(positionCSS.top).toBe('-5px');
- expect(positionCSS.left).toBe('calc(5px - 50%)');
- expect(positionCSS.transformOrigin).toBe('center center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.End works', () => {
- const { positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.CenterHorizontal,
- justify: Justify.End,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- checkPixelValue(positionCSS.bottom, 0);
- expect(positionCSS.left).toBe('calc(5px - 50%)');
- expect(positionCSS.transformOrigin).toBe('center bottom');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
-
- test('Justify.Fit works', () => {
- const { justify, positionCSS } = calculatePosition({
- spacing: SPACING,
- windowHeight: WINDOW_HEIGHT,
- windowWidth: WINDOW_WIDTH,
- useRelativePositioning: true,
- align: Align.CenterHorizontal,
- justify: Justify.Fit,
- referenceElDocumentPos: refElPos.center,
- referenceElViewportPos: refElPos.center,
- contentElDocumentPos: contentElPos,
- contentElViewportPos: contentElPos,
- scrollContainer: scrollContainerNull,
- });
-
- expect(justify).toBe('fit');
- checkPixelValue(positionCSS.top, 0);
- checkPixelValue(positionCSS.bottom, 0);
- expect(positionCSS.transformOrigin).toBe('center center');
- expect(positionCSS.transform).toBe('scale(0.8)');
- });
+ }),
+ ).toEqual({ placement: 'left', transformAlign: 'left' });
});
});
});
- describe('getElementDocumentPosition', () => {
- test('given an element, it returns an object with information about its position', () => {
- const div = document.createElement('div');
- document.body.appendChild(div);
-
- const pos = getElementDocumentPosition(div);
-
- expect(pos.top).toBe(0);
- expect(pos.bottom).toBe(0);
- expect(pos.left).toBe(0);
- expect(pos.right).toBe(0);
- expect(pos.height).toBe(0);
- expect(pos.width).toBe(0);
+ describe('getOffsetValue', () => {
+ const mockRects = {
+ reference: {
+ x: 100,
+ y: 100,
+ width: 50,
+ height: 20,
+ },
+ floating: {
+ x: 100,
+ y: 100,
+ width: 100,
+ height: 200,
+ },
+ } as const;
+ test('returns the spacing value when standard `align` value is used', () => {
+ const mockSpacing = 10;
+ expect(getOffsetValue(Align.Top, mockSpacing, mockRects)).toBe(
+ mockSpacing,
+ );
+ expect(getOffsetValue(Align.Bottom, mockSpacing, mockRects)).toBe(
+ mockSpacing,
+ );
+ expect(getOffsetValue(Align.Left, mockSpacing, mockRects)).toBe(
+ mockSpacing,
+ );
+ expect(getOffsetValue(Align.Right, mockSpacing, mockRects)).toBe(
+ mockSpacing,
+ );
});
- });
- describe('getElementViewportPosition', () => {
- test('given an element, it returns an object with information about its position', () => {
- const div = document.createElement('div');
- document.body.appendChild(div);
-
- const pos = getElementViewportPosition(div);
-
- expect(pos.top).toBe(0);
- expect(pos.bottom).toBe(0);
- expect(pos.left).toBe(0);
- expect(pos.right).toBe(0);
- expect(pos.height).toBe(0);
- expect(pos.width).toBe(0);
+ test('returns the correct offset value when `align` is `center-horizontal`', () => {
+ expect(getOffsetValue(Align.CenterHorizontal, 10, mockRects)).toBe(-75);
+ expect(getOffsetValue(Align.CenterVertical, 10, mockRects)).toBe(-110);
});
});
});
diff --git a/packages/popover/src/utils/positionUtils.ts b/packages/popover/src/utils/positionUtils.ts
index cb429a9ff1..10323847aa 100644
--- a/packages/popover/src/utils/positionUtils.ts
+++ b/packages/popover/src/utils/positionUtils.ts
@@ -1,135 +1,12 @@
-import { Align, ElementPosition, Justify } from '../Popover.types';
+import { ElementRects, Placement } from '@floating-ui/react';
-interface ElementViewportPositions {
- referenceElViewportPos: ElementPosition;
- contentElViewportPos: ElementPosition;
- spacing: number;
-}
-
-interface ElementDocumentPositions {
- referenceElDocumentPos: ElementPosition;
- contentElDocumentPos: ElementPosition;
- spacing: number;
-}
-
-interface ElementPositions
- extends Partial,
- Partial {}
-
-interface WindowSize {
- windowWidth: number;
- windowHeight: number;
-}
-
-interface CalculatePosition
- extends Required,
- Partial {
- useRelativePositioning: boolean;
- align: Align;
- justify: Justify;
- scrollContainer: HTMLElement | null | undefined;
-}
-
-// Returns the style object that is used to position and transition the popover component
-export function calculatePosition({
- useRelativePositioning,
- spacing,
- align,
- justify,
- referenceElViewportPos = defaultElementPosition,
- referenceElDocumentPos = defaultElementPosition,
- contentElViewportPos = defaultElementPosition,
- contentElDocumentPos = defaultElementPosition,
- scrollContainer,
- windowHeight = window.innerHeight,
- windowWidth = window.innerWidth,
-}: CalculatePosition): {
- align: Align;
- justify: Justify;
- positionCSS: any;
-} {
- // Use scrollContainer width and height instead of window width and height when a scrollContainer is set
- // so we can correctly determine if the content element is safely within the "window"
- const windowContainerWidth = scrollContainer
- ? scrollContainer.offsetWidth
- : windowWidth;
- const windowContainerHeight = scrollContainer
- ? scrollContainer.offsetHeight
- : windowHeight;
-
- const windowSafeCommonArgs = {
- windowWidth: windowContainerWidth,
- windowHeight: windowContainerHeight,
- referenceElViewportPos,
- contentElViewportPos,
- spacing,
- };
-
- const windowSafeAlign = getWindowSafeAlign(align, windowSafeCommonArgs);
- const windowSafeJustify = getWindowSafeJustify(
- justify,
- windowSafeAlign,
- windowSafeCommonArgs,
- );
-
- const transformOrigin = getTransformOrigin({
- align: windowSafeAlign,
- justify: windowSafeJustify,
- });
-
- const transform = getTransform(windowSafeAlign, spacing);
-
- // calculatePosition will run and return CSS even if getBoundingClientRect() returns 0 for all properties, which then causes the content to have incorrect CSS. To avoid this we only want to return CSS if something is returned.
- // Justify fit does not position itself properly in this case so we continue to return the CSS
- if (Math.floor(contentElViewportPos.width) === 0 && justify !== Justify.Fit) {
- return {
- align,
- justify,
- positionCSS: {
- left: 0,
- top: 0,
- transform,
- transformOrigin,
- },
- };
- }
-
- if (useRelativePositioning) {
- return {
- align: windowSafeAlign,
- justify: windowSafeJustify,
- positionCSS: {
- ...calcRelativePosition({
- align: windowSafeAlign,
- justify: windowSafeJustify,
- referenceElDocumentPos,
- contentElDocumentPos,
- spacing,
- }),
- transformOrigin,
- transform,
- },
- };
- }
-
- return {
- align: windowSafeAlign,
- justify: windowSafeJustify,
- positionCSS: {
- ...calcAbsolutePosition({
- align: windowSafeAlign,
- justify: windowSafeJustify,
- referenceElDocumentPos,
- contentElDocumentPos,
- spacing,
- windowHeight: windowContainerHeight,
- windowWidth: windowContainerWidth,
- }),
- transformOrigin,
- transform,
- },
- };
-}
+import {
+ Align,
+ ElementPosition,
+ ExtendedPlacement,
+ Justify,
+ TransformAlign,
+} from '../Popover/Popover.types';
const defaultElementPosition = {
top: 0,
@@ -169,6 +46,11 @@ const getElementPosition = (element: HTMLElement, isReference?: boolean) => {
};
};
+/**
+ * Returns the width and height as well as the top, bottom, left, and right positions of an element
+ * within a given container. If `scrollContainer` is undefined, the position is relative to the
+ * document.
+ */
export function getElementDocumentPosition(
element: HTMLElement | null,
scrollContainer?: HTMLElement | null,
@@ -214,539 +96,144 @@ export function getElementDocumentPosition(
};
}
-// Gets top offset, left offset, width and height dimensions for a node
-export function getElementViewportPosition(
- element: HTMLElement | null,
- scrollContainer?: HTMLElement | null,
- isReference?: boolean,
-): ElementPosition {
- if (!element) {
- return defaultElementPosition;
- }
-
- const { top, bottom, left, right, height, width } = getElementPosition(
- element,
- isReference,
- );
-
- if (scrollContainer) {
- const {
- top: offsetTop,
- bottom: offsetBottom,
- left: offsetLeft,
- right: offsetRight,
- } = scrollContainer.getBoundingClientRect();
-
- return {
- top: top - offsetTop,
- bottom: bottom - offsetBottom,
- left: left - offsetLeft,
- right: right - offsetRight,
- height,
- width,
- };
+/**
+ * Function to convert `align` and `justify` props to a desired Floating UI
+ * {@link https://floating-ui.com/docs/useFloating#placement placement}
+ * value to provide in {@link https://floating-ui.com/docs/useFloating#placement useFloating hook}.
+ * Floating UI supports 12 placements out-of-the-box. In addition to these placements, we override
+ * the `align` prop when it is set to 'center-horizontal' or 'center-vertical' to
+ * {@link https://floating-ui.com/docs/offset#creating-custom-placements create custom placements}
+ */
+export const getFloatingPlacement = (
+ align: Align,
+ justify: Justify,
+): Placement => {
+ if (align === Align.CenterHorizontal) {
+ align = Align.Right;
}
- return {
- top,
- bottom,
- left,
- right,
- height,
- width,
- };
-}
-
-interface TransformOriginArgs {
- align: Align;
- justify: Justify;
-}
-
-type XOrigin = 'left' | 'right' | 'center';
-type YOrigin = 'top' | 'bottom' | 'center';
-
-const yJustifyOrigins: Record = {
- [Justify.Start]: 'top',
- [Justify.Middle]: 'center',
- [Justify.End]: 'bottom',
- [Justify.Fit]: 'center',
-};
-
-const xJustifyOrigins: Record = {
- [Justify.Start]: 'left',
- [Justify.Middle]: 'center',
- [Justify.End]: 'right',
- [Justify.Fit]: 'center',
-};
-
-const transformOriginMappings: {
- [A in Align]: { x: XOrigin; y?: undefined } | { x?: undefined; y: YOrigin };
-} = {
- [Align.Left]: { x: 'right' },
- [Align.Right]: { x: 'left' },
- [Align.Top]: { y: 'bottom' },
- [Align.Bottom]: { y: 'top' },
- [Align.CenterHorizontal]: { x: 'center' },
- [Align.CenterVertical]: { y: 'center' },
-};
-
-// Constructs the transform origin for any given pair of alignment / justification
-function getTransformOrigin({ align, justify }: TransformOriginArgs): string {
- const alignMapping = transformOriginMappings[align];
- const x: XOrigin = alignMapping.x ?? xJustifyOrigins[justify];
- const y: YOrigin = alignMapping.y ?? yJustifyOrigins[justify];
-
- return `${x} ${y}`;
-}
-
-// Get transform styles for position object
-function getTransform(align: Align, transformAmount: number): string {
- const scaleAmount = 0.8;
-
- switch (align) {
- case Align.Top:
- return `translate3d(0, ${transformAmount}px, 0) scale(${scaleAmount})`;
-
- case Align.Bottom:
- return `translate3d(0, -${transformAmount}px, 0) scale(${scaleAmount})`;
-
- case Align.Left:
- return `translate3d(${transformAmount}px, 0, 0) scale(${scaleAmount})`;
-
- case Align.Right:
- return `translate3d(-${transformAmount}px, 0, 0) scale(${scaleAmount})`;
-
- case Align.CenterHorizontal:
- case Align.CenterVertical:
- // NOTE(JeT): For centered alignments, "spacing" doesn't make sense
- return `scale(${scaleAmount})`;
+ if (align === Align.CenterVertical) {
+ align = Align.Bottom;
}
-}
-
-interface AbsolutePositionObject {
- top?: string | 0;
- bottom?: string | 0;
- left?: string | 0;
- right?: string | 0;
-}
-interface CalcPositionArgs extends ElementDocumentPositions {
- align: Align;
- justify: Justify;
- spacing: number;
-}
-
-type JustifyPositions = {
- readonly [J in Justify]:
- | AbsolutePositionObject
- | ((positions: ElementDocumentPositions) => AbsolutePositionObject);
-};
-
-/**
- * Position mappings for when the main axis alignment is horizontal
- * (left/right/horizontal-center)
- */
-const verticalJustifyRelativePositions: JustifyPositions = {
- [Justify.Start]: { top: 0 },
- [Justify.End]: { bottom: 0 },
- [Justify.Middle]: ({ contentElDocumentPos, referenceElDocumentPos }) => ({
- top: `${
- referenceElDocumentPos.height / 2 - contentElDocumentPos.height / 2
- }px`,
- }),
- [Justify.Fit]: { top: 0, bottom: 0 },
+ return justify === Justify.Middle ? align : `${align}-${justify}`;
};
/**
- * Position mappings for when the main axis alignment is vertical
- * (top/bottom/vertical-center)
+ * Function to derive window-safe align and justify values that are used when rendering
+ * children. The placement calculated by Floating UI does not explicitly specify the justify
+ * value when it is 'middle'
*/
-const horizontalJustifyRelativePositions: JustifyPositions = {
- [Justify.Start]: { left: 0 },
- [Justify.End]: { right: 0 },
- [Justify.Middle]: ({ contentElDocumentPos, referenceElDocumentPos }) => ({
- left: `${
- referenceElDocumentPos.width / 2 - contentElDocumentPos.width / 2
- }px`,
- }),
- [Justify.Fit]: { left: 0, right: 0 },
-};
-
-const relativePositionMappings: Record<
- Align,
- {
- constant?: (positions: ElementDocumentPositions) => AbsolutePositionObject;
- justifyPositions: JustifyPositions;
- }
-> = {
- [Align.Top]: {
- constant: ({ spacing }) => ({ bottom: `calc(100% + ${spacing}px)` }),
- justifyPositions: horizontalJustifyRelativePositions,
- },
- [Align.Bottom]: {
- constant: ({ spacing }) => ({ top: `calc(100% + ${spacing}px)` }),
- justifyPositions: horizontalJustifyRelativePositions,
- },
- [Align.CenterVertical]: {
- constant: ({ referenceElDocumentPos }) => ({
- top: `calc(${referenceElDocumentPos.height / 2}px - 50%)`,
- }),
- justifyPositions: horizontalJustifyRelativePositions,
- },
- [Align.Left]: {
- constant: ({ spacing }) => ({ right: `calc(100% + ${spacing}px)` }),
- justifyPositions: verticalJustifyRelativePositions,
- },
- [Align.Right]: {
- constant: ({ spacing }) => ({ left: `calc(100% + ${spacing}px)` }),
- justifyPositions: verticalJustifyRelativePositions,
- },
- [Align.CenterHorizontal]: {
- constant: ({ referenceElDocumentPos }) => ({
- left: `calc(${referenceElDocumentPos.width / 2}px - 50%)`,
- }),
- justifyPositions: verticalJustifyRelativePositions,
- },
-};
+export const getWindowSafePlacementValues = (placement: Placement) => {
+ const [floatingAlign, floatingJustify] = placement.split('-');
-// Returns positioning for an element absolutely positioned within it's relative parent
-function calcRelativePosition({
- align,
- justify,
- referenceElDocumentPos,
- contentElDocumentPos,
- spacing,
-}: CalcPositionArgs): AbsolutePositionObject {
- const alignMapping = relativePositionMappings[align];
- const justifyMapping = alignMapping.justifyPositions[justify];
- const mappingArgs = { contentElDocumentPos, referenceElDocumentPos, spacing };
+ const newAlign = floatingAlign as Align;
+ const newJustify = !floatingJustify
+ ? Justify.Middle
+ : (floatingJustify as Justify);
return {
- ...alignMapping.constant?.(mappingArgs),
- ...(typeof justifyMapping === 'function'
- ? justifyMapping(mappingArgs)
- : justifyMapping),
+ align: newAlign,
+ justify: newJustify,
};
-}
-
-type CalcAbsolutePositionArgs = CalcPositionArgs & WindowSize;
-
-function calcAbsolutePosition({
- align,
- justify,
- referenceElDocumentPos,
- contentElDocumentPos,
- spacing,
- windowWidth,
- windowHeight,
-}: CalcAbsolutePositionArgs): AbsolutePositionObject {
- const leftNum = calcLeft({
- align,
- justify,
- referenceElPos: referenceElDocumentPos,
- contentElPos: contentElDocumentPos,
- spacing,
- });
-
- const left = `${leftNum}px`;
-
- const top = `${calcTop({
- align,
- justify,
- referenceElPos: referenceElDocumentPos,
- contentElPos: contentElDocumentPos,
- spacing,
- })}px`;
-
- if (justify !== Justify.Fit) {
- return { left, top };
- }
+};
- if (
- (
- [Align.Left, Align.Right, Align.CenterHorizontal] as Array
- ).includes(align)
- ) {
+/**
+ * Function to extend the {@link https://floating-ui.com/docs/usefloating#placement-1 final placement}
+ * calculated by the `useFloating` hook and provide the align value used for transform styling.
+ *
+ * Floating UI supports 12 placements out-of-the-box. We extend these placements when the `align` prop is
+ * set to 'center-horizontal' or 'center-vertical'
+ */
+export const getExtendedPlacementValues = ({
+ placement,
+ align: alignProp,
+}: {
+ placement: Placement;
+ align: Align;
+}): {
+ placement: ExtendedPlacement;
+ transformAlign: TransformAlign;
+} => {
+ // The `floatingAlign` value is 'top', 'right', 'bottom', or 'left'.
+ // The `floatingJustify` value is 'start', 'end', or undefined.
+ const [floatingAlign, floatingJustify] = placement.split('-');
+
+ const isAlignCenterHorizontal = alignProp === Align.CenterHorizontal;
+ const isAlignCenterVertical = alignProp === Align.CenterVertical;
+
+ // If the `align` prop is not 'center-horizontal' or 'center-vertical', use the placement and
+ // align values calculated by the `useFloating` hook
+ if (!isAlignCenterHorizontal && !isAlignCenterVertical) {
return {
- left,
- top,
- bottom: `${windowHeight - referenceElDocumentPos.bottom}px`,
+ placement,
+ transformAlign: floatingAlign as TransformAlign,
};
}
- return {
- left,
- top,
- right: `${windowWidth - (leftNum + referenceElDocumentPos.width)}px`, // take the left position of the content element and add the width of the reference element. This is where we want the right position of the content element to be. To get the equivalent right position minus this number from the container width.
- };
-}
-
-interface CalcPosition {
- align?: Align;
- justify?: Justify;
- spacing: number;
- contentElPos: ElementPosition;
- referenceElPos: ElementPosition;
-}
-
-// Returns the 'top' position in pixels for a valid alignment or justification.
-function calcTop({
- align,
- justify,
- contentElPos,
- referenceElPos,
- spacing,
-}: CalcPosition): number {
- switch (align) {
- case Align.Left:
- case Align.Right:
- case Align.CenterHorizontal:
- switch (justify) {
- case Justify.Start:
- case Justify.Fit:
- return referenceElPos.top;
-
- case Justify.End:
- return (
- referenceElPos.top + referenceElPos.height - contentElPos.height
- );
-
- case Justify.Middle:
- default:
- return (
- referenceElPos.top -
- (contentElPos.height - referenceElPos.height) / 2
- );
- }
-
- case Align.CenterVertical:
- return (
- referenceElPos.top - (contentElPos.height - referenceElPos.height) / 2
- );
-
- case Align.Top:
- return referenceElPos.top - contentElPos.height - spacing;
-
- case Align.Bottom:
- default:
- return referenceElPos.top + referenceElPos.height + spacing;
+ // If the calculated justify value is 'start'
+ if (floatingJustify === Justify.Start) {
+ // and the `align` prop is 'center-horizontal',
+ if (alignProp === Align.CenterHorizontal) {
+ // we center the floating element horizontally and place it aligned to the start of the reference point
+ return {
+ placement: 'center-start',
+ transformAlign: TransformAlign.Center,
+ };
+ // and the `align` prop is 'center-vertical',
+ } else if (alignProp === Align.CenterVertical) {
+ // we center the floating element vertically and place it to the right of the reference point
+ return {
+ placement: 'right',
+ transformAlign: TransformAlign.Right,
+ };
+ }
}
-}
-
-// Returns the 'left' position in pixels for a valid alignment or justification.
-function calcLeft({
- align,
- justify,
- contentElPos,
- referenceElPos,
- spacing,
-}: CalcPosition): number {
- switch (align) {
- case Align.Top:
- case Align.Bottom:
- case Align.CenterVertical:
- switch (justify) {
- case Justify.End:
- return (
- referenceElPos.left + referenceElPos.width - contentElPos.width
- );
- case Justify.Middle:
- return (
- referenceElPos.left -
- (contentElPos.width - referenceElPos.width) / 2
- );
-
- case Justify.Start:
- case Justify.Fit:
- default:
- return referenceElPos.left;
- }
-
- case Align.Left:
- return referenceElPos.left - contentElPos.width - spacing;
-
- case Align.Right:
- return referenceElPos.left + referenceElPos.width + spacing;
-
- case Align.CenterHorizontal:
- default:
- return (
- referenceElPos.left - (contentElPos.width - referenceElPos.width) / 2
- );
+ // If the calculated justify value is 'end'
+ if (floatingJustify === Justify.End) {
+ // and the `align` prop is 'center-horizontal',
+ if (alignProp === Align.CenterHorizontal) {
+ // we center the floating element horizontally and place it aligned to the end of the reference point
+ return {
+ placement: 'center-end',
+ transformAlign: TransformAlign.Center,
+ };
+ // and the `align` prop is 'center-vertical',
+ } else if (alignProp === Align.CenterVertical) {
+ // we center the floating element vertically and place it to the left of the reference point
+ return {
+ placement: 'left',
+ transformAlign: TransformAlign.Left,
+ };
+ }
}
-}
-// Check if horizontal position is safely within edge of window
-function safelyWithinHorizontalWindow({
- left,
- windowWidth,
- contentWidth,
-}: {
- left: number;
- windowWidth: number;
- contentWidth: number;
-}): boolean {
- const tooWide = left + contentWidth > windowWidth;
- return left >= 0 && !tooWide;
-}
-
-// Check if vertical position is safely within edge of window
-function safelyWithinVerticalWindow({
- top,
- windowHeight,
- contentHeight,
-}: {
- top: number;
- windowHeight: number;
- contentHeight: number;
-}): boolean {
- const tooTall = top + contentHeight > windowHeight;
-
- return top >= 0 && !tooTall;
-}
-
-interface WindowSafeCommonArgs extends ElementViewportPositions, WindowSize {}
-
-const alignFallbacks: { [A in Align]: ReadonlyArray } = {
- [Align.Top]: [Align.Bottom],
- [Align.Bottom]: [Align.Top],
- [Align.Left]: [Align.Right],
- [Align.Right]: [Align.Left],
- [Align.CenterHorizontal]: [Align.Left, Align.Right],
- [Align.CenterVertical]: [Align.Top, Align.Bottom],
+ // If the calculated justify value calculated is not specified, we center the floating element
+ return {
+ placement: 'center',
+ transformAlign: TransformAlign.Center,
+ };
};
-// Determines the alignment to render based on an order of alignment fallbacks
-// Returns the first alignment that doesn't collide with the window,
-// defaulting to the align prop if all alignments fail.
-function getWindowSafeAlign(
+/**
+ * This function calculates the offset value of the popover element based on the `align` prop
+ * If the `align` prop is 'center-horizontal' or 'center-vertical', an offset value is calculated
+ * to position the popover element on top of the reference element. Otherwise, the `spacing` prop
+ * is used to calculate the offset value
+ */
+export const getOffsetValue = (
align: Align,
- windowSafeCommon: WindowSafeCommonArgs,
-): Align {
- const {
- spacing,
- windowWidth,
- windowHeight,
- contentElViewportPos,
- referenceElViewportPos,
- } = windowSafeCommon;
-
- const alignOptions = [align, ...alignFallbacks[align]];
-
- return (
- alignOptions.find(fallback => {
- // Check that an alignment will not cause the popover to collide with the window.
- if (
- (
- [Align.Top, Align.Bottom, Align.CenterVertical] as Array
- ).includes(fallback)
- ) {
- const top = calcTop({
- align: fallback,
- contentElPos: contentElViewportPos,
- referenceElPos: referenceElViewportPos,
- spacing,
- });
-
- return safelyWithinVerticalWindow({
- top,
- windowHeight,
- contentHeight: contentElViewportPos.height,
- });
- }
-
- if (
- (
- [Align.Left, Align.Right, Align.CenterHorizontal] as Array
- ).includes(fallback)
- ) {
- const left = calcLeft({
- align: fallback,
- contentElPos: contentElViewportPos,
- referenceElPos: referenceElViewportPos,
- spacing,
- });
- return safelyWithinHorizontalWindow({
- left,
- windowWidth,
- contentWidth: contentElViewportPos.width,
- });
- }
+ spacing: number,
+ rects: ElementRects,
+) => {
+ if (align === Align.CenterHorizontal) {
+ return -rects.reference.width / 2 - rects.floating.width / 2;
+ }
- return false;
- }) || align
- );
-}
+ if (align === Align.CenterVertical) {
+ return -rects.reference.height / 2 - rects.floating.height / 2;
+ }
-const justifyFallbacks: { [J in Justify]: ReadonlyArray } = {
- [Justify.Start]: [Justify.End, Justify.Middle],
- [Justify.Middle]: [Justify.End, Justify.Start],
- [Justify.End]: [Justify.Start, Justify.Middle],
- [Justify.Fit]: [Justify.Middle, Justify.Start, Justify.End],
+ return spacing;
};
-
-// Determines the justification to render based on an order of justification fallbacks
-// Returns the first justification that doesn't collide with the window,
-// defaulting to the justify prop if all justifications fail.
-function getWindowSafeJustify(
- justify: Justify,
- align: Align,
- windowSafeCommon: WindowSafeCommonArgs,
-): Justify {
- const {
- spacing,
- windowWidth,
- windowHeight,
- contentElViewportPos,
- referenceElViewportPos,
- } = windowSafeCommon;
-
- const justifyOptions = [justify, ...justifyFallbacks[justify]];
-
- switch (align) {
- case Align.Top:
- case Align.Bottom:
- case Align.CenterVertical:
- return (
- justifyOptions.find(fallback =>
- safelyWithinHorizontalWindow({
- contentWidth:
- fallback === Justify.Fit
- ? referenceElViewportPos.width
- : contentElViewportPos.width,
- windowWidth,
- left: calcLeft({
- contentElPos: contentElViewportPos,
- referenceElPos: referenceElViewportPos,
- spacing,
- align: align,
- justify: fallback,
- }),
- }),
- ) ?? justifyFallbacks[justify][0]
- );
-
- case Align.Left:
- case Align.Right:
- case Align.CenterHorizontal:
- return (
- justifyOptions.find(fallback =>
- safelyWithinVerticalWindow({
- contentHeight:
- fallback === Justify.Fit
- ? referenceElViewportPos.height
- : contentElViewportPos.height,
- windowHeight,
- top: calcTop({
- contentElPos: contentElViewportPos,
- referenceElPos: referenceElViewportPos,
- spacing,
- align,
- justify: fallback,
- }),
- }),
- ) ?? justifyFallbacks[justify][0]
- );
- }
-}
diff --git a/packages/search-input/src/SearchInput/SearchInput.spec.tsx b/packages/search-input/src/SearchInput/SearchInput.spec.tsx
index d3dd79ac4c..fe085b4fb8 100644
--- a/packages/search-input/src/SearchInput/SearchInput.spec.tsx
+++ b/packages/search-input/src/SearchInput/SearchInput.spec.tsx
@@ -1,4 +1,4 @@
-import React, { createRef } from 'react';
+import React from 'react';
import {
createEvent,
fireEvent,
@@ -113,20 +113,6 @@ describe('packages/search-input', () => {
const { resultsElements } = getMenuElements();
expect(resultsElements).toHaveLength(1);
});
-
- test('accepts a portalRef', () => {
- const portalContainer = document.createElement('div');
- document.body.appendChild(portalContainer);
- const portalRef = createRef();
- const { openMenu } = renderSearchInput({
- ...defaultProps,
- portalContainer,
- portalRef,
- });
- openMenu();
- expect(portalRef.current).toBeDefined();
- expect(portalRef.current).toBe(portalContainer);
- });
});
describe('Interaction', () => {
diff --git a/packages/search-input/src/SearchInput/SearchInput.tsx b/packages/search-input/src/SearchInput/SearchInput.tsx
index 469bde59bd..bfd247142f 100644
--- a/packages/search-input/src/SearchInput/SearchInput.tsx
+++ b/packages/search-input/src/SearchInput/SearchInput.tsx
@@ -82,11 +82,6 @@ export const SearchInput = React.forwardRef(
onSubmit: onSubmitProp,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
- usePortal = true,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
...rest
}: SearchInputProps,
forwardRef: React.Ref,
@@ -370,18 +365,6 @@ export const SearchInput = React.forwardRef(
isOpen && withTypeAhead,
);
- const popoverProps = {
- ...(usePortal
- ? {
- usePortal,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
- }
- : { usePortal }),
- };
-
return (
(
open={isOpen}
refEl={searchBoxRef}
ref={menuRef}
- {...popoverProps}
>
{updatedChildren}
diff --git a/packages/search-input/src/SearchInput/SearchInput.types.ts b/packages/search-input/src/SearchInput/SearchInput.types.ts
index 4800237809..f8c6a065be 100644
--- a/packages/search-input/src/SearchInput/SearchInput.types.ts
+++ b/packages/search-input/src/SearchInput/SearchInput.types.ts
@@ -6,7 +6,6 @@ import {
import { AriaLabelProps } from '@leafygreen-ui/a11y';
import { DarkModeProps } from '@leafygreen-ui/lib';
-import { PopoverProps } from '@leafygreen-ui/popover';
import { Size } from '@leafygreen-ui/tokens';
export const State = {
@@ -20,15 +19,7 @@ export { Size };
interface BaseSearchInputProps
extends DarkModeProps,
- Omit, 'onChange'>,
- Pick<
- PopoverProps,
- | 'usePortal'
- | 'portalClassName'
- | 'portalContainer'
- | 'portalRef'
- | 'scrollContainer'
- > {
+ Omit, 'onChange'> {
/**
* The current state of the SearchInput. This can be none, or loading.
*/
diff --git a/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.tsx b/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.tsx
index 257cc7e160..ec034c994d 100644
--- a/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.tsx
+++ b/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.tsx
@@ -4,7 +4,7 @@ import isUndefined from 'lodash/isUndefined';
import { css, cx } from '@leafygreen-ui/emotion';
import { useAvailableSpace } from '@leafygreen-ui/hooks';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
-import Popover from '@leafygreen-ui/popover';
+import Popover, { DismissMode, RenderMode } from '@leafygreen-ui/popover';
import { spacing } from '@leafygreen-ui/tokens';
import { useSearchInputContext } from '../SearchInputContext';
@@ -32,11 +32,6 @@ export const SearchResultsMenu = React.forwardRef<
children,
open = false,
refEl,
- usePortal,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
footerSlot,
...rest
}: SearchResultsMenuProps,
@@ -58,10 +53,9 @@ export const SearchResultsMenu = React.forwardRef<
: 'unset';
return (
- // @ts-ignore `portalClassName`, `portalContainer` and `scrollContainer` are only passed in when `usePortal` is true.
{state === 'loading' ? (
diff --git a/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.types.ts b/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.types.ts
index ec935c8d91..3834da23c0 100644
--- a/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.types.ts
+++ b/packages/search-input/src/SearchResultsMenu/SearchResultsMenu.types.ts
@@ -1,11 +1,12 @@
import React, { ReactElement } from 'react';
import { HTMLElementProps } from '@leafygreen-ui/lib';
-import { PortalControlProps } from '@leafygreen-ui/popover';
-export type SearchResultsMenuProps = HTMLElementProps<'ul', HTMLUListElement> &
- PortalControlProps & {
- refEl: React.RefObject;
- open?: boolean;
- footerSlot?: ReactElement;
- };
+export type SearchResultsMenuProps = HTMLElementProps<
+ 'ul',
+ HTMLUListElement
+> & {
+ refEl: React.RefObject;
+ open?: boolean;
+ footerSlot?: ReactElement;
+};
diff --git a/packages/select/README.md b/packages/select/README.md
index 875687a14b..d6caff4595 100644
--- a/packages/select/README.md
+++ b/packages/select/README.md
@@ -44,41 +44,41 @@ import { Option, OptionGroup, Select, Size } from '@leafygreen-ui/select';
## Select Properties
-| Prop | Type | Description | Default |
-| -------------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
-| `children` | `node` | ` ` and ` ` elements. | |
-| `className` | `string` | Adds a className to the outermost element. | |
-| `darkMode` | `boolean` | Determines whether or not the component will appear in dark mode. | `false` |
-| `size` | `'xsmall'`, `'small'`, `'default'`, `'large'` | Sets the size of the component's elements. | `'default'` |
-| `id` | `string` | id associated with the Select component. | |
-| `name` | `string` | The name that will be used when submitted as part of a form. | |
-| `label` | `string` | Text shown in bold above the input element. | |
-| `aria-labelledby` | `string` | Must be provided if and only if neither `label` nor `aria-label` is not provided. |
-| `aria-label` | `string` | Must be provided if and only if neither `label` nor `aria-labelledby` is not provided. | |
-| `description` | `React.ReactNode` | Text that gives more detail about the requirements for the input. | |
-| `placeholder` | `string` | The placeholder text shown in the input element when an option is not selected. | `'Select'` |
-| `disabled` | `boolean` | Disables the component from being edited. | `false` |
-| `value` | `string` | Sets the ` ` that will appear selected and makes the component a controlled component. | `''` |
-| `defaultValue` | `string` | Sets the ` ` that will appear selected on page load when the component is uncontrolled. | `''` |
-| `onChange` | `function` | A function that gets called when the selected value changes. Receives the value string as the first argument. | `() => {}` |
-| `readOnly` | `boolean` | Disables the console warning when the component is controlled and no `onChange` prop is provided. | `false` |
-| `allowDeselect` | `boolean` | Enables or disables the option for a user to select a null default value. | `true` |
-| `usePortal` | `boolean` | Determines if Select dropdown will be rendered inside a portal. | `true` |
-| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same refrence to `scrollContainer` and `portalContainer`. | |
-| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that lement to allow the portal to position properly. | |
-| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
-| `popoverZIndex` | `number` | Sets the z-index CSS property for the popover. | |
-| `state` | `'error'` \|`'none'` \| `'valid'` | Determines the state of the `` | `'none'` |
-| `errorMessage` | `string` | Text that shows when the `state` is set to `error`. | `'This input needs your attention'` |
-| `successMessage` | `string` | Text that shows when the `state` is set to `valid`. | `'Success'` |
-| `baseFontSize` | `'13'`, `'16'` | Determines the base font size if sizeVariant is set to `default` | `'13'` |
-| `dropdownWidthBasis` | `'option'` \| `'trigger'` | Determines the width of the dropdown. `trigger` will make the dropdown width the width of the menu trigger. `option` will make the dropdown width as wide as the widest option. | `trigger` |
+| Prop | Type | Description | Default |
+| -------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
+| `children` | `React.ReactNode` | ` ` and ` ` elements. | |
+| `className` | `string` | Adds a className to the outermost element. | |
+| `darkMode` | `boolean` | Determines whether or not the component will appear in dark mode. | `false` |
+| `size` | `'xsmall'` \| `'small'` \| `'default'` \| `'large'` | Sets the size of the component's elements. | `'default'` |
+| `id` | `string` | id associated with the Select component. | |
+| `name` | `string` | The name that will be used when submitted as part of a form. | |
+| `label` | `string` | Text shown in bold above the input element. | |
+| `aria-labelledby` | `string` | Must be provided if and only if neither `label` nor `aria-label` is not provided. |
+| `aria-label` | `string` | Must be provided if and only if neither `label` nor `aria-labelledby` is not provided. | |
+| `description` | `React.ReactNode` | Text that gives more detail about the requirements for the input. | |
+| `placeholder` | `string` | The placeholder text shown in the input element when an option is not selected. | `'Select'` |
+| `disabled` | `boolean` | Disables the component from being edited. | `false` |
+| `value` | `string` | Sets the ` ` that will appear selected and makes the component a controlled component. | `''` |
+| `defaultValue` | `string` | Sets the ` ` that will appear selected on page load when the component is uncontrolled. | `''` |
+| `onChange` | `function` | A function that gets called when the selected value changes. Receives the value string as the first argument. | `() => {}` |
+| `readOnly` | `boolean` | Disables the console warning when the component is controlled and no `onChange` prop is provided. | `false` |
+| `allowDeselect` | `boolean` | Enables or disables the option for a user to select a null default value. | `true` |
+| `renderMode` | `'inline'` \| `'portal'` \| `'top-layer'` | Options to render the popover element \* [deprecated] `'inline'` will render the popover element inline in the DOM where it's written \* [deprecated] `'portal'` will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer` \* `'top-layer'` will render the popover element in the top layer | `'top-layer'` |
+| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same refrence to `scrollContainer` and `portalContainer`. | |
+| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that lement to allow the portal to position properly. | |
+| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
+| `popoverZIndex` | `number` | Sets the z-index CSS property for the popover. | |
+| `state` | `'error'` \| `'none'` \| `'valid'` | Determines the state of the `` | `'none'` |
+| `errorMessage` | `string` | Text that shows when the `state` is set to `error`. | `'This input needs your attention'` |
+| `successMessage` | `string` | Text that shows when the `state` is set to `valid`. | `'Success'` |
+| `baseFontSize` | `'13'` \| `'16'` | Determines the base font size if sizeVariant is set to `default` | `'13'` |
+| `dropdownWidthBasis` | `'option'` \| `'trigger'` | Determines the width of the dropdown. `trigger` will make the dropdown width the width of the menu trigger. `option` will make the dropdown width as wide as the widest option. | `trigger` |
# Option
| Prop | Type | Description | Default |
| ------------- | -------------------- | ----------------------------------------------------------------------------------------------------- | --------------------------- |
-| `children` | `string`, `number` | Content to appear inside of the component. | |
+| `children` | `string` \| `number` | Content to appear inside of the component. | |
| `className` | `string` | Adds a className to the outermost element. | |
| `glyph` | `React.ReactElement` | Icon to display next to the option text. | |
| `value` | `string` | Corresponds to the value passed into the `onChange` prop of ` ` when the option is selected. | text contents of `children` |
diff --git a/packages/select/src/ListMenu/ListMenu.stories.tsx b/packages/select/src/ListMenu/ListMenu.stories.tsx
index b24211cc2d..3e962aa4c6 100644
--- a/packages/select/src/ListMenu/ListMenu.stories.tsx
+++ b/packages/select/src/ListMenu/ListMenu.stories.tsx
@@ -5,6 +5,7 @@ import { StoryMetaType, StoryType } from '@lg-tools/storybook-utils';
import { css } from '@leafygreen-ui/emotion';
import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider';
+import { DismissMode } from '@leafygreen-ui/popover';
import { InternalOption } from '../Option';
import { SelectContext } from '../SelectContext';
diff --git a/packages/select/src/ListMenu/ListMenu.tsx b/packages/select/src/ListMenu/ListMenu.tsx
index 666c0b7f15..2779201ae8 100644
--- a/packages/select/src/ListMenu/ListMenu.tsx
+++ b/packages/select/src/ListMenu/ListMenu.tsx
@@ -34,18 +34,6 @@ const ListMenu = React.forwardRef(
className,
labelId,
dropdownWidthBasis,
- usePortal = true,
- portalContainer,
- portalRef,
- scrollContainer,
- portalClassName,
- popoverZIndex,
- onEntering,
- onEnter,
- onEntered,
- onExiting,
- onExit,
- onExited,
}: ListMenuProps,
forwardedRef,
) {
@@ -69,25 +57,6 @@ const ListMenu = React.forwardRef(
[ref],
);
- const popoverProps = {
- popoverZIndex,
- onEntering,
- onEnter,
- onEntered,
- onExiting,
- onExit,
- onExited,
- ...(usePortal
- ? {
- usePortal,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
- }
- : { usePortal }),
- };
-
return (
(
[autoWidthStyles]: dropdownWidthBasis === DropdownWidthBasis.Option,
})}
refEl={referenceElement}
- {...popoverProps}
>
{/* Keyboard events handled in Select component through event listener hook */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
diff --git a/packages/select/src/ListMenu/ListMenu.types.ts b/packages/select/src/ListMenu/ListMenu.types.ts
index 4c7e09f134..65c7eb7ee8 100644
--- a/packages/select/src/ListMenu/ListMenu.types.ts
+++ b/packages/select/src/ListMenu/ListMenu.types.ts
@@ -1,11 +1,8 @@
-import { HTMLElementProps } from '@leafygreen-ui/lib';
-import { PopoverProps } from '@leafygreen-ui/popover';
+import { ComponentPropsWithoutRef } from 'react';
import { DropdownWidthBasis } from '../Select/Select.types';
-export interface ListMenuProps
- extends HTMLElementProps<'ul', HTMLLIElement>,
- Omit {
+export interface ListMenuProps extends ComponentPropsWithoutRef<'ul'> {
children: React.ReactNode;
id: string;
referenceElement: React.MutableRefObject;
diff --git a/packages/select/src/MenuButton/MenuButton.types.ts b/packages/select/src/MenuButton/MenuButton.types.ts
index 422537b54e..ec017ced70 100644
--- a/packages/select/src/MenuButton/MenuButton.types.ts
+++ b/packages/select/src/MenuButton/MenuButton.types.ts
@@ -5,7 +5,6 @@ import { State } from '../Select/Select.types';
export interface MenuButtonBaseProps
extends HTMLElementProps<'button', HTMLButtonElement> {
- children: React.ReactNode;
value: string;
text: React.ReactNode;
name?: string;
diff --git a/packages/select/src/Select.stories.tsx b/packages/select/src/Select.stories.tsx
index 5dd67c7b10..594619299f 100644
--- a/packages/select/src/Select.stories.tsx
+++ b/packages/select/src/Select.stories.tsx
@@ -10,6 +10,7 @@ import { userEvent, within } from '@storybook/testing-library';
import { css, cx } from '@leafygreen-ui/emotion';
import BeakerIcon from '@leafygreen-ui/icon/dist/Beaker';
import LeafyGreenProvider from '@leafygreen-ui/leafygreen-provider';
+import { RenderMode } from '@leafygreen-ui/popover';
import { Option, OptionGroup, Select, type SelectProps, Size, State } from '.';
@@ -64,6 +65,19 @@ const childrenArray = [
const meta: StoryMetaType = {
title: 'Components/Select',
component: Select,
+ decorators: [
+ (StoryFn, _ctx) => (
+
+
+
+
+
+ ),
+ ],
parameters: {
default: 'LiveExample',
controls: {
@@ -96,7 +110,7 @@ const meta: StoryMetaType = {
allowDeselect: false,
darkMode: false,
children: childrenArray,
- usePortal: true,
+ renderMode: RenderMode.TopLayer,
},
argTypes: {
placeholder: { control: 'text' },
@@ -175,16 +189,6 @@ WithIcons.parameters = {
},
};
-export const NoPortal = LiveExample.bind({});
-NoPortal.args = {
- usePortal: false,
-};
-NoPortal.parameters = {
- chromatic: {
- disableSnapshot: true,
- },
-};
-
export const Generated = () => {};
export const InitialLongSelectOpen = {
diff --git a/packages/select/src/Select/Select.spec.tsx b/packages/select/src/Select/Select.spec.tsx
index 93c8441295..350abd2590 100644
--- a/packages/select/src/Select/Select.spec.tsx
+++ b/packages/select/src/Select/Select.spec.tsx
@@ -1,8 +1,7 @@
-import React, { createRef, PropsWithChildren, useState } from 'react';
+import React, { createRef, useState } from 'react';
import {
act,
fireEvent,
- getByText as getByTextFor,
render,
RenderResult,
waitFor,
@@ -11,8 +10,10 @@ import {
import userEvent from '@testing-library/user-event';
import BeakerIcon from '@leafygreen-ui/icon/dist/Beaker';
-import { PopoverContext } from '@leafygreen-ui/leafygreen-provider';
+import * as LeafyGreenProviderModule from '@leafygreen-ui/leafygreen-provider';
+import { PopoverProvider } from '@leafygreen-ui/leafygreen-provider';
import { keyMap } from '@leafygreen-ui/lib';
+import { RenderMode } from '@leafygreen-ui/popover';
import { Context, jest as Jest } from '@leafygreen-ui/testing-lib';
import { getTestUtils } from '../utils/getTestUtils/getTestUtils';
@@ -305,6 +306,7 @@ describe('packages/select', () => {
document.body.appendChild(portalContainer);
const portalRef = createRef();
const { getInput } = renderSelect({
+ renderMode: RenderMode.Portal,
portalContainer,
portalRef,
});
@@ -694,8 +696,9 @@ describe('packages/select', () => {
await waitForElementToBeRemoved(listbox);
- expect(getByTextFor(button, optionText)).toBeVisible();
+ expect(button).toBeVisible();
expect(button).toHaveFocus();
+ expect(button).toHaveTextContent(optionText);
expect(button).toHaveValue(optionValue);
});
@@ -722,8 +725,9 @@ describe('packages/select', () => {
await waitForElementToBeRemoved(listbox);
- expect(getByTextFor(button, optionText)).toBeVisible();
+ expect(button).toBeVisible();
expect(button).toHaveFocus();
+ expect(button).toHaveTextContent(optionText);
expect(button).toHaveValue(optionValue);
});
@@ -746,8 +750,9 @@ describe('packages/select', () => {
await waitForElementToBeRemoved(listbox);
- expect(getByTextFor(button, optionText)).toBeVisible();
+ expect(button).toBeVisible();
expect(button).toHaveFocus();
+ expect(button).toHaveTextContent(optionText);
expect(button).toHaveValue(optionValue);
});
});
@@ -776,9 +781,9 @@ describe('packages/select', () => {
expect(onChangeSpy).not.toHaveBeenCalled();
expect(listbox).toBeInTheDocument();
- expect(getByTextFor(button, 'Select')).toBeVisible();
+ expect(button).toBeVisible();
expect(targetOption).toHaveFocus();
-
+ expect(button).toHaveTextContent('Select');
expect(button).toHaveValue('');
});
@@ -797,7 +802,8 @@ describe('packages/select', () => {
expect(onChangeSpy).not.toHaveBeenCalled();
expect(listbox).toBeInTheDocument();
- expect(getByTextFor(button, 'Select')).toBeVisible();
+ expect(button).toBeVisible();
+ expect(button).toHaveTextContent('Select');
expect(button).toHaveValue('');
});
});
@@ -959,186 +965,168 @@ describe('packages/select', () => {
});
});
- describe('without Portal (usePortal="false")', () => {
- test('menu opens', async () => {
- const { getInput, getPopover } = renderSelect({ usePortal: false });
-
- userEvent.click(getInput());
- await waitFor(() => expect(getPopover()).toBeVisible());
- });
-
- test('menu renders as a child of button', async () => {
- const { getPopover, getInput } = renderSelect({
- usePortal: false,
- });
-
- userEvent.click(getInput());
-
- await waitFor(() => {
- const popover = getPopover();
- expect(popover).toBeInTheDocument();
- expect(getInput()).toContainElement(popover);
- });
- });
-
- describe('fires onChange when selecting an option', () => {
- test('on mouse click', async () => {
- const onChange = jest.fn();
-
- const { getInput, getOptionByValue } = renderSelect({
- usePortal: false,
- onChange: onChange,
- });
+ describe.each([RenderMode.Inline, RenderMode.TopLayer])(
+ `when renderMode=%p`,
+ renderMode => {
+ test('menu opens', async () => {
+ const { getInput, getPopover } = renderSelect({ renderMode });
userEvent.click(getInput());
- userEvent.click(getOptionByValue('Red')!);
- expect(onChange).toHaveBeenCalled();
+ await waitFor(() => expect(getPopover()).toBeVisible());
});
- test('on enter', async () => {
- const onChange = jest.fn();
- const { getInput } = renderSelect({
- usePortal: false,
- onChange: onChange,
- });
-
- const button = getInput();
- userEvent.type(button, '{arrowdown}');
- // first option is focused by default
- userEvent.keyboard('{enter}');
- expect(onChange).toHaveBeenCalled();
- });
-
- test('on space', async () => {
- const onChange = jest.fn();
- const { getInput } = renderSelect({
- usePortal: false,
- onChange: onChange,
- });
-
- const button = getInput();
- userEvent.type(button, '{arrowdown}');
- // first option is focused by default
- userEvent.keyboard('{space}');
- expect(onChange).toHaveBeenCalled();
- });
- });
-
- describe('closing', () => {
- describe('selecting an option closes menu', () => {
+ describe('fires onChange when selecting an option', () => {
test('on mouse click', async () => {
- const { getInput, getPopover, getOptionByValue } = renderSelect({
- usePortal: false,
+ const onChange = jest.fn();
+
+ const { getInput, getOptionByValue } = renderSelect({
+ renderMode,
+ onChange: onChange,
});
+
userEvent.click(getInput());
- await waitFor(() => {
- expect(getPopover()).toBeVisible();
- });
userEvent.click(getOptionByValue('Red')!);
- await waitForElementToBeRemoved(getPopover());
- expect(getPopover()).not.toBeInTheDocument();
+ expect(onChange).toHaveBeenCalled();
});
test('on enter', async () => {
- const { getInput, getPopover } = renderSelect({
- usePortal: false,
+ const onChange = jest.fn();
+ const { getInput } = renderSelect({
+ renderMode,
+ onChange: onChange,
});
const button = getInput();
userEvent.type(button, '{arrowdown}');
- await waitFor(() => {
- expect(getPopover()).toBeInTheDocument();
- });
// first option is focused by default
userEvent.keyboard('{enter}');
- await waitForElementToBeRemoved(getPopover());
- expect(getPopover()).not.toBeInTheDocument();
+ expect(onChange).toHaveBeenCalled();
});
test('on space', async () => {
- const { getInput, getPopover } = renderSelect({
- usePortal: false,
+ const onChange = jest.fn();
+ const { getInput } = renderSelect({
+ renderMode,
+ onChange: onChange,
});
const button = getInput();
userEvent.type(button, '{arrowdown}');
- await waitFor(() => {
- expect(getPopover()).toBeInTheDocument();
- });
// first option is focused by default
userEvent.keyboard('{space}');
- await waitForElementToBeRemoved(getPopover());
- expect(getPopover()).not.toBeInTheDocument();
+ expect(onChange).toHaveBeenCalled();
});
});
- });
- describe('fires Popover callbacks', () => {
- test('opening the select fires the `onEnter*` callbacks', async () => {
- const onEnter = jest.fn();
- const onEntering = jest.fn();
- const onEntered = jest.fn();
+ describe('closing', () => {
+ describe('selecting an option closes menu', () => {
+ test('on mouse click', async () => {
+ const { getInput, getPopover, getOptionByValue } = renderSelect({
+ renderMode,
+ });
+ userEvent.click(getInput());
+ await waitFor(() => {
+ expect(getPopover()).toBeVisible();
+ });
+ userEvent.click(getOptionByValue('Red')!);
+ await waitForElementToBeRemoved(getPopover());
+ expect(getPopover()).not.toBeInTheDocument();
+ });
- const { getInput } = renderSelect({
- onEnter: onEnter,
- onEntering: onEntering,
- onEntered: onEntered,
- usePortal: false,
- });
+ test('on enter', async () => {
+ const { getInput, getPopover } = renderSelect({
+ renderMode,
+ });
+
+ const button = getInput();
+ userEvent.type(button, '{arrowdown}');
+ await waitFor(() => {
+ expect(getPopover()).toBeInTheDocument();
+ });
+ // first option is focused by default
+ userEvent.keyboard('{enter}');
+ await waitForElementToBeRemoved(getPopover());
+ expect(getPopover()).not.toBeInTheDocument();
+ });
- userEvent.click(getInput());
- expect(onEnter).toHaveBeenCalled();
- expect(onEntering).toHaveBeenCalled();
- await waitFor(() => expect(onEntered).toHaveBeenCalled());
+ test('on space', async () => {
+ const { getInput, getPopover } = renderSelect({
+ renderMode,
+ });
+
+ const button = getInput();
+ userEvent.type(button, '{arrowdown}');
+ await waitFor(() => {
+ expect(getPopover()).toBeInTheDocument();
+ });
+ // first option is focused by default
+ userEvent.keyboard('{space}');
+ await waitForElementToBeRemoved(getPopover());
+ expect(getPopover()).not.toBeInTheDocument();
+ });
+ });
});
- test('closing the select fires the `onExit*` callbacks', async () => {
- const onExit = jest.fn();
- const onExiting = jest.fn();
- const onExited = jest.fn();
+ describe('fires Popover callbacks', () => {
+ test('opening the select fires the `onEnter*` callbacks', async () => {
+ const onEnter = jest.fn();
+ const onEntering = jest.fn();
+ const onEntered = jest.fn();
+
+ const { getInput } = renderSelect({
+ onEnter: onEnter,
+ onEntering: onEntering,
+ onEntered: onEntered,
+ renderMode,
+ });
- const { getInput } = renderSelect({
- onExit: onExit,
- onExiting: onExiting,
- onExited: onExited,
- usePortal: false,
+ userEvent.click(getInput());
+ expect(onEnter).toHaveBeenCalled();
+ expect(onEntering).toHaveBeenCalled();
+ await waitFor(() => expect(onEntered).toHaveBeenCalled());
});
- userEvent.click(getInput());
- userEvent.click(getInput());
+ test('closing the select fires the `onExit*` callbacks', async () => {
+ const onExit = jest.fn();
+ const onExiting = jest.fn();
+ const onExited = jest.fn();
- expect(onExit).toHaveBeenCalled();
- expect(onExiting).toHaveBeenCalled();
- await waitFor(() => expect(onExited).toHaveBeenCalled());
+ const { getInput } = renderSelect({
+ onExit: onExit,
+ onExiting: onExiting,
+ onExited: onExited,
+ renderMode,
+ });
+
+ userEvent.click(getInput());
+ userEvent.click(getInput());
+
+ expect(onExit).toHaveBeenCalled();
+ expect(onExiting).toHaveBeenCalled();
+ await waitFor(() => expect(onExited).toHaveBeenCalled());
+ });
});
- });
- });
+ },
+ );
describe('with PopoverContext', () => {
const mockSetIsPopoverOpen = jest.fn();
- const MockPopoverProvider = ({ children }: PropsWithChildren<{}>) => {
- return (
-
- {children}
-
- );
- };
-
- beforeEach(() => {
- mockSetIsPopoverOpen.mockClear();
+ afterEach(() => {
+ mockSetIsPopoverOpen.mockReset();
});
test('calls `setIsPopoverOpen`', async () => {
+ jest
+ .spyOn(LeafyGreenProviderModule, 'usePopoverContext')
+ .mockImplementation(() => ({
+ isPopoverOpen: false,
+ setIsPopoverOpen: mockSetIsPopoverOpen,
+ }));
render(
-
+
- ,
+ ,
);
const { getInput } = getTestUtils();
@@ -1153,11 +1141,17 @@ describe('packages/select', () => {
);
});
- test('calls `setIsPopoverOpen` when `usePortal == false`', async () => {
+ test(`calls setIsPopoverOpen when renderMode="${RenderMode.Inline}"`, async () => {
+ jest
+ .spyOn(LeafyGreenProviderModule, 'usePopoverContext')
+ .mockImplementation(() => ({
+ isPopoverOpen: false,
+ setIsPopoverOpen: mockSetIsPopoverOpen,
+ }));
render(
-
-
- ,
+
+
+ ,
);
const { getInput } = getTestUtils();
diff --git a/packages/select/src/Select/Select.tsx b/packages/select/src/Select/Select.tsx
index ef36baabb6..747f2890e0 100644
--- a/packages/select/src/Select/Select.tsx
+++ b/packages/select/src/Select/Select.tsx
@@ -18,9 +18,11 @@ import {
useViewportSize,
} from '@leafygreen-ui/hooks';
import LeafyGreenProvider, {
+ PopoverPropsProvider,
useDarkMode,
} from '@leafygreen-ui/leafygreen-provider';
import { keyMap } from '@leafygreen-ui/lib';
+import { getPopoverRenderModeProps } from '@leafygreen-ui/popover';
import { BaseFontSize } from '@leafygreen-ui/tokens';
import { Description, Label } from '@leafygreen-ui/typography';
@@ -46,7 +48,14 @@ import {
largeLabelStyles,
wrapperStyle,
} from './Select.styles';
-import { DropdownWidthBasis, SelectProps, Size, State } from './Select.types';
+import {
+ DismissMode,
+ DropdownWidthBasis,
+ RenderMode,
+ SelectProps,
+ Size,
+ State,
+} from './Select.types';
/**
* Select inputs are typically used alongside other form elements like toggles, radio boxes, or text inputs when a user needs to make a selection from a list of items.
@@ -61,7 +70,7 @@ export const Select = forwardRef(
size = Size.Default,
disabled = false,
allowDeselect = true,
- usePortal = true,
+ renderMode = RenderMode.TopLayer,
placeholder = 'Select',
errorMessage = DEFAULT_MESSAGES.error,
successMessage = DEFAULT_MESSAGES.success,
@@ -493,16 +502,15 @@ export const Select = forwardRef(
onExiting,
onExit,
onExited,
- ...(usePortal
- ? {
- usePortal,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
- }
- : { usePortal }),
- };
+ ...getPopoverRenderModeProps({
+ dismissMode: DismissMode.Manual,
+ portalClassName,
+ portalContainer,
+ portalRef,
+ renderMode,
+ scrollContainer,
+ }),
+ } as const;
return (
@@ -596,7 +604,8 @@ export const Select = forwardRef(
state={state}
baseFontSize={baseFontSize}
__INTERNAL__menuButtonSlot__={__INTERNAL__menuButtonSlot__}
- >
+ />
+
-
+
,
'onChange' | 'onClick'
>,
- Omit,
+ Omit,
DarkModeProps,
LgIdProps {
/**
diff --git a/packages/select/src/Select/index.ts b/packages/select/src/Select/index.ts
index 42296fc636..f2239a5e55 100644
--- a/packages/select/src/Select/index.ts
+++ b/packages/select/src/Select/index.ts
@@ -1,6 +1,8 @@
export { Select } from './Select';
export {
+ DismissMode,
DropdownWidthBasis,
+ RenderMode,
type SelectProps,
Size,
State,
diff --git a/packages/select/src/index.ts b/packages/select/src/index.ts
index 6f4aff1ee0..4c5449e986 100644
--- a/packages/select/src/index.ts
+++ b/packages/select/src/index.ts
@@ -4,7 +4,9 @@ export { menuButtonTextClassName } from './MenuButton';
export { Option } from './Option';
export { OptionGroup } from './OptionGroup';
export {
+ DismissMode,
DropdownWidthBasis,
+ RenderMode,
Select,
type SelectProps,
Size,
diff --git a/packages/select/src/utils/getTestUtils/getTestUtils.spec.tsx b/packages/select/src/utils/getTestUtils/getTestUtils.spec.tsx
index 28a0bef802..d089c054ec 100644
--- a/packages/select/src/utils/getTestUtils/getTestUtils.spec.tsx
+++ b/packages/select/src/utils/getTestUtils/getTestUtils.spec.tsx
@@ -2,6 +2,8 @@ import React, { useState } from 'react';
import { render, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
+import { RenderMode } from '@leafygreen-ui/popover';
+
import { Option, OptionGroup, Select, State } from '../../';
import { getTestUtils } from './getTestUtils';
@@ -12,6 +14,8 @@ const defaultProps = {
description: 'Description',
} as const;
+const renderModes = Object.values(RenderMode);
+
const children = [
Red
@@ -176,10 +180,10 @@ describe('packages/select/getTestUtils', () => {
});
describe('getOptions', () => {
- describe.each([true, false])('usePortal={%p}', boolean => {
+ describe.each(renderModes)('when renderMode={%p}', renderMode => {
test('returns all options', async () => {
renderSelect({
- usePortal: boolean,
+ renderMode,
});
const { getInput, getOptions } = getTestUtils();
@@ -191,10 +195,10 @@ describe('packages/select/getTestUtils', () => {
});
describe('getOptionByValue', () => {
- describe.each([true, false])('usePortal={%p}', boolean => {
+ describe.each(renderModes)('when renderMode={%p}', renderMode => {
test('is in the document', async () => {
renderSelect({
- usePortal: boolean,
+ renderMode,
});
const { getInput, getOptionByValue } = getTestUtils();
@@ -205,7 +209,7 @@ describe('packages/select/getTestUtils', () => {
test('is not in the document', async () => {
renderSelect({
- usePortal: boolean,
+ renderMode,
});
const { getInput, getOptionByValue } = getTestUtils();
@@ -215,7 +219,7 @@ describe('packages/select/getTestUtils', () => {
test('can be clicked', async () => {
renderSelect({
- usePortal: boolean,
+ renderMode,
});
const { getInput, getOptionByValue, getInputValue } = getTestUtils();
@@ -226,7 +230,7 @@ describe('packages/select/getTestUtils', () => {
test('throws an error if the option does not exist', async () => {
renderSelect({
- usePortal: boolean,
+ renderMode,
});
const { getInput, getOptionByValue } = getTestUtils();
@@ -236,7 +240,7 @@ describe('packages/select/getTestUtils', () => {
test('prevents a click on a disabled option', async () => {
renderSelect({
- usePortal: boolean,
+ renderMode,
});
const { getInput, getOptionByValue, getPopover, getInputValue } =
getTestUtils();
@@ -251,11 +255,11 @@ describe('packages/select/getTestUtils', () => {
});
describe('getPopover', () => {
- describe.each([true, false])('when usePortal={%p}', boolean => {
+ describe.each(renderModes)('when renderMode={%p}', renderMode => {
describe('is in the document', () => {
test('after awaiting waitFor', async () => {
renderSelect({
- usePortal: boolean,
+ renderMode,
});
const { getInput, getPopover } = getTestUtils();
@@ -268,7 +272,7 @@ describe('packages/select/getTestUtils', () => {
describe('is not in the document ', () => {
test('after clicking the trigger', async () => {
renderSelect({
- usePortal: boolean,
+ renderMode,
});
const { getInput, getPopover } = getTestUtils();
@@ -281,7 +285,7 @@ describe('packages/select/getTestUtils', () => {
test('after clicking an option', async () => {
renderSelect({
- usePortal: boolean,
+ renderMode,
});
const { getInput, getPopover, getOptionByValue } = getTestUtils();
@@ -340,11 +344,11 @@ describe('packages/select/getTestUtils', () => {
expect(getInputValue()).toBe('Yellow');
});
- describe.each([true, false])('when usePortal={%p}', boolean => {
+ describe.each(renderModes)('when renderMode={%p}', renderMode => {
test('returns the updated value after clicking on an option', async () => {
renderSelectControlled({
value: 'Blue',
- usePortal: boolean,
+ renderMode,
});
const { getInput, getInputValue, getOptionByValue } =
getTestUtils();
@@ -364,11 +368,11 @@ describe('packages/select/getTestUtils', () => {
expect(getInputValue()).toBe('Green');
});
- describe.each([true, false])('when usePortal={%p}', boolean => {
+ describe.each(renderModes)('when renderMode={%p}', renderMode => {
test('returns the updated value after clicking on an option', async () => {
renderSelect({
defaultValue: 'Green',
- usePortal: boolean,
+ renderMode,
});
const { getInput, getInputValue, getOptionByValue } =
getTestUtils();
diff --git a/packages/side-nav/src/CollapseToggle/CollapseToggle.tsx b/packages/side-nav/src/CollapseToggle/CollapseToggle.tsx
index 7e879b3386..ba9135b935 100644
--- a/packages/side-nav/src/CollapseToggle/CollapseToggle.tsx
+++ b/packages/side-nav/src/CollapseToggle/CollapseToggle.tsx
@@ -3,7 +3,7 @@ import React from 'react';
import { cx } from '@leafygreen-ui/emotion';
import ChevronLeft from '@leafygreen-ui/icon/dist/ChevronLeft';
import ChevronRight from '@leafygreen-ui/icon/dist/ChevronRight';
-import Tooltip from '@leafygreen-ui/tooltip';
+import Tooltip, { Align, Justify, RenderMode } from '@leafygreen-ui/tooltip';
import { InlineKeyCode } from '@leafygreen-ui/typography';
import { useSideNavContext } from '../SideNav/SideNavContext';
@@ -33,10 +33,10 @@ export function CollapseToggle({
return (
` | The menu items to appear in the menu dropdown. Must be an array of ` `. | |
-| `onTriggerClick` | `React.MouseEventHandler` | Callback fired when the trigger is clicked. | |
-| `triggerAriaLabel` | `string` | aria-label for the menu trigger button. | |
-| `onChange` | `React.MouseEventHandler` | Callback fired when a menuItem is clicked. | |
-| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same reference to `scrollContainer` and `portalContainer`. | |
-| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. | |
-| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
-| `popoverZIndex` | `number` | Sets the z-index CSS property for the popover. | |
-| ... | `native attributes of component passed to as prop` | Any other properties will be spread on the root element | |
+| Prop | Type | Description | Default |
+| ------------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
+| `label` | `string` | The text that will appear inside of the primary button. | |
+| `darkMode` | `boolean` | Renders the component with dark mode styles. | `false` |
+| `variant` | `'default'` \| `'primary'` \| `'danger'` | Sets the variant for both Buttons. | `'default'` |
+| `size` | `'xsmall'` \| `'small'` \| `'default'` \| `'large'` | Sets the size for both buttons. | `'default'` |
+| `align` | `'top'` \| `'bottom'` | Determines the alignment of the menu relative to the component wrapper. | `'bottom'` |
+| `justify` | `'start'` \| `'end'` | Determines the justification of the menu relative to the component wrapper. | `'end'` |
+| `menuItems` | `Array` | The menu items to appear in the menu dropdown. Must be an array of ` `. | |
+| `onTriggerClick` | `React.MouseEventHandler` | Callback fired when the trigger is clicked. | |
+| `triggerAriaLabel` | `string` | aria-label for the menu trigger button. | |
+| `onChange` | `React.MouseEventHandler` | Callback fired when a menuItem is clicked. | |
+| `renderMode` | `'inline'` \| `'portal'` \| `'top-layer'` | Options to render the popover element \* [deprecated] `'inline'` will render the popover element inline in the DOM where it's written \* [deprecated] `'portal'` will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer` \* `'top-layer'` will render the popover element in the top layer | `'top-layer'` |
+| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same reference to `scrollContainer` and `portalContainer`. | |
+| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that element to allow the portal to position properly. | |
+| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
+| `popoverZIndex` | `number` | Sets the z-index CSS property for the popover. | |
+| ... | `native attributes of component passed to as prop` | Any other properties will be spread on the root element | |
diff --git a/packages/split-button/src/SplitButton/SplitButton.spec.tsx b/packages/split-button/src/SplitButton/SplitButton.spec.tsx
index 746cf9ce69..06b441a764 100644
--- a/packages/split-button/src/SplitButton/SplitButton.spec.tsx
+++ b/packages/split-button/src/SplitButton/SplitButton.spec.tsx
@@ -3,6 +3,7 @@ import {
fireEvent,
getAllByRole as globalGetAllByRole,
render,
+ waitFor,
waitForElementToBeRemoved,
within,
} from '@testing-library/react';
@@ -10,6 +11,7 @@ import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { MenuItem } from '@leafygreen-ui/menu';
+import { RenderMode } from '@leafygreen-ui/popover';
import { MenuItemsType } from './SplitButton.types';
import { SplitButton } from '.';
@@ -206,6 +208,7 @@ describe('packages/split-button', () => {
open,
portalContainer,
portalRef,
+ renderMode: RenderMode.Portal,
});
expect(portalRef.current).toBeDefined();
expect(portalRef.current).toBe(portalContainer);
@@ -300,7 +303,7 @@ describe('packages/split-button', () => {
const { menuItemElements } = await openMenu({
withKeyboard: true,
});
- expect(menuItemElements[0]).toHaveFocus();
+ await waitFor(() => expect(menuItemElements[0]).toHaveFocus());
userEvent.type(menuItemElements?.[0]!, `{${key}}`);
expect(onClick).toHaveBeenCalled();
@@ -333,10 +336,10 @@ describe('packages/split-button', () => {
describe('Down arrow', () => {
test('highlights the next option in the menu', async () => {
const { openMenu } = renderSplitButton({ menuItems });
- const { menuEl, menuItemElements } = await openMenu();
-
- userEvent.type(menuEl!, '{arrowdown}');
- expect(menuItemElements[0]).toHaveFocus();
+ const { menuEl, menuItemElements } = await openMenu({
+ withKeyboard: true,
+ });
+ await waitFor(() => expect(menuItemElements[0]).toHaveFocus());
userEvent.type(menuEl!, '{arrowdown}');
expect(menuItemElements[1]).toHaveFocus();
@@ -344,9 +347,12 @@ describe('packages/split-button', () => {
test('cycles highlight to the top', async () => {
const { openMenu } = renderSplitButton({ menuItems });
- const { menuEl, menuItemElements } = await openMenu();
+ const { menuEl, menuItemElements } = await openMenu({
+ withKeyboard: true,
+ });
+ await waitFor(() => expect(menuItemElements[0]).toHaveFocus());
- for (let i = 0; i <= menuItemElements.length; i++) {
+ for (let i = 0; i < menuItemElements.length; i++) {
userEvent.type(menuEl!, '{arrowdown}');
}
@@ -357,9 +363,11 @@ describe('packages/split-button', () => {
describe('Up arrow', () => {
test('highlights the previous option in the menu', async () => {
const { openMenu } = renderSplitButton({ menuItems });
- const { menuEl, menuItemElements } = await openMenu();
+ const { menuEl, menuItemElements } = await openMenu({
+ withKeyboard: true,
+ });
+ await waitFor(() => expect(menuItemElements[0]).toHaveFocus());
- userEvent.type(menuEl!, '{arrowdown}');
userEvent.type(menuEl!, '{arrowdown}');
expect(menuItemElements[1]).toHaveFocus();
@@ -369,7 +377,10 @@ describe('packages/split-button', () => {
test('cycles highlight to the bottom', async () => {
const { openMenu } = renderSplitButton({ menuItems });
- const { menuEl, menuItemElements } = await openMenu();
+ const { menuEl, menuItemElements } = await openMenu({
+ withKeyboard: true,
+ });
+ await waitFor(() => expect(menuItemElements[0]).toHaveFocus());
const lastOption = menuItemElements[menuItemElements.length - 1];
userEvent.type(menuEl!, '{arrowup}');
@@ -407,7 +418,7 @@ describe('packages/split-button', () => {
align="top"
justify="start"
className="test"
- usePortal={true}
+ renderMode="portal"
portalContainer={{} as HTMLElement}
scrollContainer={{} as HTMLElement}
portalClassName="classname"
diff --git a/packages/split-button/src/SplitButton/SplitButton.tsx b/packages/split-button/src/SplitButton/SplitButton.tsx
index b317c9453e..c501a85580 100644
--- a/packages/split-button/src/SplitButton/SplitButton.tsx
+++ b/packages/split-button/src/SplitButton/SplitButton.tsx
@@ -11,6 +11,7 @@ import {
InferredPolymorphic,
useInferredPolymorphic,
} from '@leafygreen-ui/polymorphic';
+import { RenderMode } from '@leafygreen-ui/popover';
import { BaseFontSize } from '@leafygreen-ui/tokens';
import { Menu } from '../Menu';
@@ -40,7 +41,7 @@ export const SplitButton = InferredPolymorphic(
maxHeight,
adjustOnMutation,
popoverZIndex,
- usePortal,
+ renderMode = RenderMode.TopLayer,
portalClassName,
portalContainer,
portalRef,
@@ -64,14 +65,14 @@ export const SplitButton = InferredPolymorphic(
const buttonProps = {
// only add these props if not an anchor
...(!isAnchor && { type }),
- };
+ } as const;
const sharedButtonProps = {
variant,
size,
baseFontSize,
disabled,
- };
+ } as const;
return (
@@ -92,7 +93,7 @@ export const SplitButton = InferredPolymorphic
(
maxHeight={maxHeight}
adjustOnMutation={adjustOnMutation}
popoverZIndex={popoverZIndex}
- usePortal={usePortal}
+ renderMode={renderMode}
portalClassName={portalClassName}
portalContainer={portalContainer}
portalRef={portalRef}
@@ -149,4 +150,5 @@ SplitButton.propTypes = {
? PropTypes.instanceOf(Element)
: PropTypes.any,
}),
+ renderMode: PropTypes.oneOf(Object.values(RenderMode)),
} as any;
diff --git a/packages/stepper/src/EllipsesStep/EllipsesStep.tsx b/packages/stepper/src/EllipsesStep/EllipsesStep.tsx
index d5d0ab6146..27266d2fe3 100644
--- a/packages/stepper/src/EllipsesStep/EllipsesStep.tsx
+++ b/packages/stepper/src/EllipsesStep/EllipsesStep.tsx
@@ -1,7 +1,7 @@
-import React from 'react';
+import React, { useState } from 'react';
import { useDarkMode } from '@leafygreen-ui/leafygreen-provider';
-import Tooltip, { Align, Justify } from '@leafygreen-ui/tooltip';
+import Tooltip, { Align, Justify, RenderMode } from '@leafygreen-ui/tooltip';
import { InternalStep } from '../InternalStep';
@@ -16,15 +16,23 @@ export const EllipsesStep = ({
...rest
}: React.PropsWithChildren) => {
const { darkMode, theme } = useDarkMode();
+ const [tooltipOpen, setTooltipOpen] = useState(false);
+
+ const handleMouseEnter = () => {
+ setTooltipOpen(true);
+ };
return (
needs to be defined here and not in because the Tooltip doesn't trigger without a wrapping HTML element.
-
+
` will appear open or closed. | `false` |
-| `setOpen` | `function` | If controlling the component, pass state handling function to setOpen prop. This will keep the consuming application's state in-sync with LeafyGreen's state, while the ` ` component responds to events such as backdrop clicks and a user pressing the Escape key. | `(boolean) => boolean` |
-| `initialOpen` | `boolean` | Passes an initial "open" value to an uncontrolled Tooltip. | `false` |
-| `shouldClose` | `function` | Callback that should return a boolean that determines whether or not the ` ` should close when a user tries to close it. | `() => true` |
-| `align` | `'top'`, `'bottom'`, `'left'`, `'right'` | Determines the preferred alignment of the ` ` component relative to the element passed to the `trigger` prop. If no `trigger` is passed, the Tooltip will be positioned against its nearest parent element. | `'top'` |
-| `justify` | `'start'`, `'middle'`, `'end'`, `'fit'` | Determines the preferred justification of the ` ` component (based on the alignment) relative to the element passed to the `trigger` prop. If no `trigger` is passed, the Tooltip will be positioned against its nearest parent element. | `'start'` |
-| `trigger` | `function`, `React.ReactNode` | A `React.ReactNode` against which the ` ` will be positioned, and what will be used to trigger the opening and closing of the `Tooltip` component, when the `Tooltip` is uncontrolled. If no `trigger` is passed, the `Tooltip` will be positioned against its nearest parent element. If using a `ReactNode` or inline function, trigger signature is: ({children, ...rest}) => (trigger {children} ). When using a function, you must pass `children` as an argument in order for the tooltip to render. | |
-| `triggerEvent` | `'hover'`, `'click'` | DOM event that triggers opening/closing of ` ` component | `'hover'` |
-| `darkMode` | `boolean` | Determines if the ` ` will appear in dark mode. | `false` |
-| `id` | `string` | `id` applied to ` ` component | |
-| `className` | `string` | Applies a className to Tooltip container | |
-| `children` | `node` | Content that will be rendered inside of ` ` | |
-| `enabled` | `boolean` | Enables Tooltip to trigger based on the event specified by `triggerEvent`. | `true` |
-| `onClose` | `function` | Callback that is called when the tooltip is closed internally. E.g. on ESC press, on backdrop click, on blur.. | `() => {}` |
-| `usePortal` | `boolean` | Determines if the Tooltip will be rendered within a portal. | `true` |
-| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same refrence to `scrollContainer` and `portalContainer`. | |
-| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that lement to allow the portal to position properly. | |
-| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
-| `popoverZIndex` | `number` | Sets the z-index CSS property for the popover. | |
-| `baseFontSize` | `13` \| `16` | font-size applied to typography element | default to value set by LeafyGreen Provider |
-| ... | native `div` attributes | Any other props will be spread on the root `div` element | |
+| Prop | Type | Description | Default |
+| ----------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
+| `open` | `boolean` | Controls the component, and determines whether or not the ` ` will appear open or closed. | `false` |
+| `setOpen` | `function` | If controlling the component, pass state handling function to setOpen prop. This will keep the consuming application's state in-sync with LeafyGreen's state, while the ` ` component responds to events such as backdrop clicks and a user pressing the Escape key. | `(boolean) => boolean` |
+| `initialOpen` | `boolean` | Passes an initial "open" value to an uncontrolled Tooltip. | `false` |
+| `shouldClose` | `function` | Callback that should return a boolean that determines whether or not the ` ` should close when a user tries to close it. | `() => true` |
+| `align` | `'top'`, `'bottom'`, `'left'`, `'right'` | Determines the preferred alignment of the ` ` component relative to the element passed to the `trigger` prop. If no `trigger` is passed, the Tooltip will be positioned against its nearest parent element. | `'top'` |
+| `justify` | `'start'`, `'middle'`, `'end'` | Determines the preferred justification of the ` ` component (based on the alignment) relative to the element passed to the `trigger` prop. If no `trigger` is passed, the Tooltip will be positioned against its nearest parent element. | `'start'` |
+| `trigger` | `function`, `React.ReactNode` | A `React.ReactNode` against which the ` ` will be positioned, and what will be used to trigger the opening and closing of the `Tooltip` component, when the `Tooltip` is uncontrolled. If no `trigger` is passed, the `Tooltip` will be positioned against its nearest parent element. If using a `ReactNode` or inline function, trigger signature is: ({children, ...rest}) => (trigger {children} ). When using a function, you must pass `children` as an argument in order for the tooltip to render. | |
+| `triggerEvent` | `'hover'`, `'click'` | DOM event that triggers opening/closing of ` ` component | `'hover'` |
+| `darkMode` | `boolean` | Determines if the ` ` will appear in dark mode. | `false` |
+| `id` | `string` | `id` applied to ` ` component | |
+| `className` | `string` | Applies a className to Tooltip container | |
+| `children` | `node` | Content that will be rendered inside of ` ` | |
+| `enabled` | `boolean` | Enables Tooltip to trigger based on the event specified by `triggerEvent`. | `true` |
+| `onClose` | `function` | Callback that is called when the tooltip is closed internally. E.g. on ESC press, on backdrop click, on blur.. | `() => {}` |
+| `renderMode` | `'inline'` \| `'portal'` \| `'top-layer'` | Options to render the popover element \* [deprecated] `'inline'` will render the popover element inline in the DOM where it's written \* [deprecated] `'portal'` will render the popover element in a new div appended to the body. Alternatively, can be portaled into a provided `portalContainer` \* `'top-layer'` will render the popover element in the top layer | `'top-layer'` |
+| `portalContainer` | `HTMLElement` \| `null` | Sets the container used for the popover's portal. NOTE: If using a `scrollContainer` make sure that the `portalContainer` is contained within the `scrollContainer`. E.g, passing the same refrence to `scrollContainer` and `portalContainer`. | |
+| `scrollContainer` | `HTMLElement` \| `null` | If the popover portal has a scrollable ancestor other than the window, this prop allows passing a reference to that lement to allow the portal to position properly. | |
+| `portalClassName` | `string` | Passes the given className to the popover's portal container if the default portal container is being used. | |
+| `popoverZIndex` | `number` | Sets the z-index CSS property for the popover. | |
+| `baseFontSize` | `13` \| `16` | font-size applied to typography element | default to value set by LeafyGreen Provider |
+| ... | native `div` attributes | Any other props will be spread on the root `div` element | |
diff --git a/packages/tooltip/src/Tooltip.stories.tsx b/packages/tooltip/src/Tooltip.stories.tsx
index fb0278fba5..6e38394ac8 100644
--- a/packages/tooltip/src/Tooltip.stories.tsx
+++ b/packages/tooltip/src/Tooltip.stories.tsx
@@ -10,7 +10,7 @@ import { StoryFn } from '@storybook/react';
import Button, { Size } from '@leafygreen-ui/button';
import { css } from '@leafygreen-ui/emotion';
import Icon from '@leafygreen-ui/icon';
-import { TestUtils } from '@leafygreen-ui/popover';
+import { DismissMode, RenderMode, TestUtils } from '@leafygreen-ui/popover';
import { BaseFontSize, transitionDuration } from '@leafygreen-ui/tokens';
import { Body, InlineCode, Subtitle } from '@leafygreen-ui/typography';
@@ -41,16 +41,6 @@ const meta: StoryMetaType = {
justify: Object.values(Justify),
baseFontSize: Object.values(BaseFontSize),
},
- excludeCombinations: [
- {
- justify: Justify.Fit,
- children: longText,
- },
- {
- justify: Justify.Fit,
- align: [Align.Left, Align.Right],
- },
- ],
args: {
open: true,
},
@@ -77,7 +67,7 @@ const meta: StoryMetaType = {
},
args: {
enabled: true,
- usePortal: true,
+ renderMode: RenderMode.TopLayer,
trigger: Trigger ,
},
argTypes: {
@@ -141,7 +131,7 @@ export const InitialOpen = (args: TooltipProps) => {
};
InitialOpen.args = {
enabled: true,
- usePortal: true,
+ renderMode: RenderMode.TopLayer,
trigger: Trigger ,
children: 'I am a tooltip!',
};
@@ -256,14 +246,11 @@ export const ScrollableContainer = ({
align,
...args
}: TooltipScrollableProps) => {
- const [portalContainer, setPortalContainer] = useState(
- null,
- );
const position = referenceElPositions[refButtonPosition];
return (
-
setPortalContainer(el)}>
+
}
- portalContainer={portalContainer}
- scrollContainer={portalContainer}
triggerEvent="click"
justify={justify}
align={align}
@@ -292,10 +277,6 @@ ScrollableContainer.parameters = {
},
};
-ScrollableContainer.args = {
- usePortal: true,
-};
-
ScrollableContainer.argTypes = {
refButtonPosition: {
options: ['centered', 'top', 'right', 'bottom', 'left'],
@@ -304,7 +285,7 @@ ScrollableContainer.argTypes = {
'Storybook only prop. Used to change position of the reference button',
defaultValue: 'centered',
},
- usePortal: { control: 'none' },
+ renderMode: { control: 'none' },
portalClassName: { control: 'none' },
refEl: { control: 'none' },
className: { control: 'none' },
@@ -319,21 +300,22 @@ ScrollableContainer.argTypes = {
};
export const ShortString: StoryFn
= () => <>>;
-ShortString.args = { children: 'I am a tooltip!' };
+ShortString.args = {
+ children: 'I am a tooltip!',
+ dismissMode: DismissMode.Manual,
+ renderMode: RenderMode.TopLayer,
+};
export const LongString: StoryFn = () => <>>;
-LongString.args = { children: longText };
-LongString.parameters = {
- generate: {
- excludeCombinations: [
- {
- justify: Justify.Fit,
- },
- ],
- },
+LongString.args = {
+ children: longText,
+ dismissMode: DismissMode.Manual,
+ renderMode: RenderMode.TopLayer,
};
export const JSXChildren: StoryFn = () => <>>;
JSXChildren.args = {
children: @leafygreen-ui/tooltip ,
+ dismissMode: DismissMode.Manual,
+ renderMode: RenderMode.TopLayer,
};
diff --git a/packages/tooltip/src/Tooltip/Tooltip.spec.tsx b/packages/tooltip/src/Tooltip/Tooltip.spec.tsx
index 81f500f462..62b5ed0ac6 100644
--- a/packages/tooltip/src/Tooltip/Tooltip.spec.tsx
+++ b/packages/tooltip/src/Tooltip/Tooltip.spec.tsx
@@ -13,6 +13,7 @@ import { axe } from 'jest-axe';
import Icon from '@leafygreen-ui/icon';
import CloudIcon from '@leafygreen-ui/icon/dist/Cloud';
import { HTMLElementProps, OneOf } from '@leafygreen-ui/lib';
+import { RenderMode } from '@leafygreen-ui/popover';
import { transitionDuration } from '@leafygreen-ui/tokens';
import Tooltip from './Tooltip';
@@ -63,8 +64,8 @@ const triggerTypes = [
function renderTooltip(
props: Omit &
OneOf<
- { usePortal?: true; portalClassName?: string },
- { usePortal: false }
+ { renderMode?: 'portal'; portalClassName?: string },
+ { renderMode: 'inline' | 'top-layer' }
> = {},
) {
const utils = render(
@@ -145,15 +146,13 @@ describe('packages/tooltip', () => {
fireEvent.mouseEnter(button);
- await waitFor(() => getByTestId(tooltipTestId), { timeout: 500 });
+ await waitFor(() => getByTestId(tooltipTestId));
expect(getByTestId(tooltipTestId)).toBeInTheDocument();
fireEvent.mouseLeave(button);
- await waitForElementToBeRemoved(getByTestId(tooltipTestId), {
- timeout: 500,
- });
+ await waitForElementToBeRemoved(getByTestId(tooltipTestId));
expect(queryByTestId(tooltipTestId)).not.toBeInTheDocument();
});
@@ -320,12 +319,12 @@ describe('packages/tooltip', () => {
});
describe('clicking content inside of tooltip does not force tooltip to close', () => {
- function testCase(name: string, usePortal: boolean): void {
+ function testCase(name: string, renderMode: RenderMode): void {
test(`${name}`, async () => {
const { button, getByTestId } = renderTooltip({
open: true,
setOpen,
- usePortal,
+ renderMode,
});
fireEvent.click(button);
@@ -348,8 +347,9 @@ describe('packages/tooltip', () => {
});
}
- testCase('with portal', true);
- testCase('without portal', false);
+ testCase('render inline', RenderMode.Inline);
+ testCase('render portal', RenderMode.Portal);
+ testCase('render top layer', RenderMode.TopLayer);
});
});
@@ -516,22 +516,32 @@ describe('packages/tooltip', () => {
open: true,
portalContainer,
portalRef,
+ renderMode: RenderMode.Portal,
});
expect(portalRef.current).toBeDefined();
expect(portalRef.current).toBe(portalContainer);
});
- test('portals popover content to end of DOM, when "usePortal" is not set', () => {
+ test(`does not portal popover content to end of DOM when renderMode=${RenderMode.Inline}`, () => {
+ const { container } = renderTooltip({
+ open: true,
+ renderMode: RenderMode.Inline,
+ });
+ expect(container.innerHTML.includes(tooltipTestId)).toBeTruthy();
+ });
+
+ test(`portals popover content to end of DOM when renderMode=${RenderMode.Portal}`, () => {
const { container, getByTestId } = renderTooltip({
open: true,
+ renderMode: RenderMode.Portal,
});
expect(container).not.toContain(getByTestId(tooltipTestId));
});
- test('does not portal popover content to end of DOM when "usePortal" is false', () => {
+ test(`does not portal popover content to end of DOM when renderMode=${RenderMode.TopLayer}`, () => {
const { container } = renderTooltip({
open: true,
- usePortal: false,
+ renderMode: RenderMode.TopLayer,
});
expect(container.innerHTML.includes(tooltipTestId)).toBe(true);
@@ -541,6 +551,7 @@ describe('packages/tooltip', () => {
const { getByTestId } = renderTooltip({
open: true,
portalClassName: 'test-classname',
+ renderMode: RenderMode.Portal,
});
const matchedElements = document.querySelectorAll('body > .test-classname');
@@ -550,16 +561,6 @@ describe('packages/tooltip', () => {
expect(portalRoot).toContainElement(getByTestId(tooltipTestId));
});
- // eslint-disable-next-line jest/expect-expect
- test('does not allow specifying "portalClassName", when "usePortal" is false', () => {
- renderTooltip({
- open: true,
- usePortal: false,
- // @ts-expect-error
- portalClassName: 'test-classname',
- });
- });
-
describe('Renders warning when', () => {
const expectedWarnMsg =
'Using a LeafyGreenUI Icon or Glyph component as a trigger will not render a Tooltip,' +
diff --git a/packages/tooltip/src/Tooltip/Tooltip.styles.ts b/packages/tooltip/src/Tooltip/Tooltip.styles.ts
index 64011164f2..f75ff86723 100644
--- a/packages/tooltip/src/Tooltip/Tooltip.styles.ts
+++ b/packages/tooltip/src/Tooltip/Tooltip.styles.ts
@@ -3,11 +3,7 @@ import { transparentize } from 'polished';
import { css } from '@leafygreen-ui/emotion';
import { Theme } from '@leafygreen-ui/lib';
import { palette } from '@leafygreen-ui/palette';
-import {
- fontFamilies,
- fontWeights,
- transitionDuration,
-} from '@leafygreen-ui/tokens';
+import { fontFamilies, fontWeights } from '@leafygreen-ui/tokens';
import { borderRadius, notchWidth } from './tooltipConstants';
@@ -21,6 +17,7 @@ export const baseTypeStyle = css`
font-weight: ${fontWeights.regular};
width: 100%;
overflow-wrap: anywhere;
+ text-transform: none;
`;
export const baseStyles = css`
@@ -65,7 +62,3 @@ export const minSize = notchWidth + 2 * borderRadius;
export const minHeightStyle = css`
min-height: ${minSize}px;
`;
-
-export const transitionDelay = css`
- transition-delay: ${transitionDuration.slowest}ms;
-`;
diff --git a/packages/tooltip/src/Tooltip/Tooltip.tsx b/packages/tooltip/src/Tooltip/Tooltip.tsx
index 949c3652ce..73fb21013e 100644
--- a/packages/tooltip/src/Tooltip/Tooltip.tsx
+++ b/packages/tooltip/src/Tooltip/Tooltip.tsx
@@ -19,7 +19,7 @@ import { isComponentGlyph } from '@leafygreen-ui/icon';
import LeafyGreenProvider, {
useDarkMode,
} from '@leafygreen-ui/leafygreen-provider';
-import Popover, { Justify } from '@leafygreen-ui/popover';
+import Popover, { getPopoverRenderModeProps } from '@leafygreen-ui/popover';
import {
bodyTypeScaleStyles,
useUpdatedBaseFontSize,
@@ -33,14 +33,17 @@ import {
colorSet,
minHeightStyle,
positionRelative,
- transitionDelay,
} from './Tooltip.styles';
import {
Align,
+ DismissMode,
+ Justify,
PopoverFunctionParameters,
+ RenderMode,
TooltipProps,
TriggerEvent,
} from './Tooltip.types';
+import { hoverDelay } from './tooltipConstants';
import { notchPositionStyles } from './tooltipUtils';
const stopClickPropagation = (evt: React.MouseEvent) => {
@@ -72,7 +75,7 @@ const stopClickPropagation = (evt: React.MouseEvent) => {
* @param props.trigger Trigger element can be ReactNode or function.
* @param props.triggerEvent Whether the Tooltip should be triggered by a `click` or `hover`.
* @param props.id id given to Tooltip content.
- * @param props.usePortal Determines whether or not Tooltip will be Portaled
+ * @param props.renderMode Options to render the popover element: `inline`, `portal`, `top-layer`.
* @param props.portalClassName Classname applied to root element of the portal.
* @param props.portalRef A ref for the portal element
* @param props.onClose Callback that is fired when the tooltip is closed.
@@ -88,7 +91,7 @@ function Tooltip({
align = 'top',
justify = 'start',
spacing = 12,
- usePortal = true,
+ renderMode = RenderMode.TopLayer,
onClose = () => {},
id,
shouldClose,
@@ -111,6 +114,7 @@ function Tooltip({
const setOpen =
isControlled && controlledSetOpen ? controlledSetOpen : uncontrolledSetOpen;
+ const timeoutRef = useRef(null);
const tooltipRef = useRef(null);
const existingId = id ?? tooltipRef.current?.id;
@@ -126,6 +130,14 @@ function Tooltip({
}
}, [trigger]);
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ }
+ };
+ }, [timeoutRef]);
+
const handleClose = useCallback(() => {
if (typeof shouldClose !== 'function' || shouldClose()) {
onClose();
@@ -143,11 +155,17 @@ function Tooltip({
// Without this the tooltip sometimes opens without a transition. flushSync prevents this state update from automatically batching. Instead updates are made synchronously.
// https://react.dev/reference/react-dom/flushSync#flushing-updates-for-third-party-integrations
flushSync(() => {
- setOpen(true);
+ timeoutRef.current = setTimeout(() => {
+ setOpen(true);
+ }, hoverDelay);
});
}, 35),
onMouseLeave: debounce((e: MouseEvent) => {
userTriggerHandler('onMouseLeave', e);
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
handleClose();
}, 35),
onFocus: (e: MouseEvent) => {
@@ -190,19 +208,18 @@ function Tooltip({
useBackdropClick(handleClose, [tooltipRef], open && triggerEvent === 'click');
const popoverProps = {
- refEl,
popoverZIndex,
- ...(usePortal
- ? {
- spacing,
- usePortal,
- portalClassName,
- portalContainer,
- portalRef,
- scrollContainer,
- }
- : { spacing, usePortal }),
- };
+ refEl,
+ spacing,
+ ...getPopoverRenderModeProps({
+ dismissMode: DismissMode.Manual,
+ portalClassName,
+ portalContainer,
+ portalRef,
+ renderMode,
+ scrollContainer,
+ }),
+ } as const;
const active = enabled && open;
const isLeftOrRightAligned = ['left', 'right'].includes(align);
@@ -215,18 +232,11 @@ function Tooltip({
justify={justify}
adjustOnMutation={true}
onClick={stopClickPropagation}
- className={cx(transitionDelay, {
- [css`
- // Try to fit all the content on one line (until it hits max-width)
- // Overrides default behavior, which is to set width to size of the trigger.
- // Except when justify is set to fit because the width should be the size of the trigger.
- // Another exception is when justify is set to fit and the alignment is either left or right. In this case only the height should be the size of the trigger so we still want the width to fit the max content.
- width: max-content;
- `]:
- justify !== Justify.Fit ||
- (justify === Justify.Fit &&
- (align === Align.Left || align === Align.Right)),
- })}
+ className={css`
+ // Try to fit all the content on one line (until it hits max-width)
+ // Overrides default behavior, which is to set width to size of the trigger.
+ width: max-content;
+ `}
{...popoverProps}
>
{({ align, justify, referenceElPos }: PopoverFunctionParameters) => {
@@ -323,7 +333,7 @@ Tooltip.propTypes = {
setOpen: PropTypes.func,
id: PropTypes.string,
shouldClose: PropTypes.func,
- usePortal: PropTypes.bool,
+ renderMode: PropTypes.oneOf(Object.values(RenderMode)),
portalClassName: PropTypes.string,
portalRef: PropTypes.shape({
current:
diff --git a/packages/tooltip/src/Tooltip/Tooltip.types.ts b/packages/tooltip/src/Tooltip/Tooltip.types.ts
index de0e32adb3..180db65599 100644
--- a/packages/tooltip/src/Tooltip/Tooltip.types.ts
+++ b/packages/tooltip/src/Tooltip/Tooltip.types.ts
@@ -3,9 +3,11 @@ import React from 'react';
import { HTMLElementProps } from '@leafygreen-ui/lib';
import {
Align as PopoverAlign,
+ DismissMode,
ElementPosition,
Justify,
PopoverProps,
+ RenderMode,
} from '@leafygreen-ui/popover';
import { BaseFontSize } from '@leafygreen-ui/tokens';
@@ -28,7 +30,7 @@ export type Align = Exclude<
'center-vertical' | 'center-horizontal'
>;
-export { Justify };
+export { DismissMode, Justify, RenderMode };
export interface PopoverFunctionParameters {
align: PopoverAlign;
@@ -38,7 +40,12 @@ export interface PopoverFunctionParameters {
type ModifiedPopoverProps = Omit<
PopoverProps,
- 'active' | 'adjustOnMutation' | 'children' | 'align'
+ | 'active'
+ | 'adjustOnMutation'
+ | 'align'
+ | 'children'
+ | 'dismissMode'
+ | 'onToggle'
>;
export type TooltipProps = Omit<
diff --git a/packages/tooltip/src/Tooltip/index.ts b/packages/tooltip/src/Tooltip/index.ts
index 4031fd3930..82beba5bed 100644
--- a/packages/tooltip/src/Tooltip/index.ts
+++ b/packages/tooltip/src/Tooltip/index.ts
@@ -1,10 +1,21 @@
import Tooltip from './Tooltip';
import {
Align,
+ DismissMode,
Justify,
+ RenderMode,
type TooltipProps,
TriggerEvent,
} from './Tooltip.types';
+import { hoverDelay } from './tooltipConstants';
-export { Align, Justify, type TooltipProps, TriggerEvent };
+export {
+ Align,
+ DismissMode,
+ hoverDelay,
+ Justify,
+ RenderMode,
+ type TooltipProps,
+ TriggerEvent,
+};
export default Tooltip;
diff --git a/packages/tooltip/src/Tooltip/tooltipConstants.ts b/packages/tooltip/src/Tooltip/tooltipConstants.ts
index d1a4894c74..e18c414cd5 100644
--- a/packages/tooltip/src/Tooltip/tooltipConstants.ts
+++ b/packages/tooltip/src/Tooltip/tooltipConstants.ts
@@ -1,3 +1,6 @@
+import { transitionDuration } from '@leafygreen-ui/tokens';
+
export const notchHeight = 8;
export const notchWidth = 26;
export const borderRadius = 16;
+export const hoverDelay = transitionDuration.slowest;
diff --git a/packages/tooltip/src/Tooltip/tooltipUtils.tsx b/packages/tooltip/src/Tooltip/tooltipUtils.tsx
index 7dd64ad709..0187dc4c6b 100644
--- a/packages/tooltip/src/Tooltip/tooltipUtils.tsx
+++ b/packages/tooltip/src/Tooltip/tooltipUtils.tsx
@@ -112,17 +112,6 @@ export function notchPositionStyles({
break;
- case Justify.Fit:
- containerStyleObj.left = `${notchOffset}px`;
-
- if (shouldTransformPosition) {
- tooltipOffsetTransform = `translateX(-${
- notchOffsetLowerBound - notchOffsetActual
- }px)`;
- }
-
- break;
-
case Justify.End:
containerStyleObj.right = `${notchOffset}px`;
@@ -178,16 +167,6 @@ export function notchPositionStyles({
containerStyleObj.bottom = '0px';
break;
- case Justify.Fit:
- containerStyleObj.top = `${notchOffset}px`;
-
- if (shouldTransformPosition) {
- tooltipOffsetTransform = `translateY(-${
- notchOffsetLowerBound - notchOffsetActual
- }px)`;
- }
- break;
-
case Justify.End:
containerStyleObj.bottom = `${notchOffset}px`;
diff --git a/packages/tooltip/src/index.ts b/packages/tooltip/src/index.ts
index 9adfb1b931..9c5036afb8 100644
--- a/packages/tooltip/src/index.ts
+++ b/packages/tooltip/src/index.ts
@@ -1,9 +1,20 @@
import Tooltip, {
Align,
+ DismissMode,
+ hoverDelay,
Justify,
+ RenderMode,
type TooltipProps,
TriggerEvent,
} from './Tooltip';
-export { Align, Justify, type TooltipProps, TriggerEvent };
+export {
+ Align,
+ DismissMode,
+ hoverDelay,
+ Justify,
+ RenderMode,
+ type TooltipProps,
+ TriggerEvent,
+};
export default Tooltip;
diff --git a/tools/cli/src/index.ts b/tools/cli/src/index.ts
index a3da8ab34d..00cd9fae85 100644
--- a/tools/cli/src/index.ts
+++ b/tools/cli/src/index.ts
@@ -180,21 +180,25 @@ cli
'Files or directory to transform. Can be a glob like like src/**.test.js',
)
.option(
- '--i, --ignore ',
- 'Glob patterns to ignore. E.g. --i **/node_modules/** **/.next/**',
+ '-i, --ignore ',
+ 'Glob patterns to ignore. E.g. -i **/node_modules/** **/.next/**',
false,
)
- .option('--d, --dry', 'dry run (no changes are made to files)', false)
+ .option('-d, --dry', 'dry run (no changes are made to files)', false)
.option(
- '--p, --print',
+ '-p, --print',
'print transformed files to stdout, useful for development',
false,
)
.option(
- '--f, --force',
+ '-f, --force',
'Bypass Git safety checks and forcibly run codemods',
false,
)
+ .option(
+ '--packages ',
+ 'Specific package names to transform. E.g. --packages @leafygreen-ui/button @leafygreen-ui/menu',
+ )
.action(migrator);
/** Build steps */
diff --git a/tools/codemods/README.md b/tools/codemods/README.md
index 813931404b..9742a14b19 100644
--- a/tools/codemods/README.md
+++ b/tools/codemods/README.md
@@ -19,7 +19,7 @@ npm install @lg-tools/codemods
## Usage
```jsx
-yarn lg codemod [...options]
+yarn lg codemod [...options]
```
### Arguments
@@ -38,111 +38,148 @@ files or directory to transform
### Options
-#### `--i or --ignore`
+#### `-i or --ignore`
Glob patterns to ignore
-```jsx
-yarn lg codemod --ignore **/node_modules/** **/.next/**
+```js
+yarn lg codemod --ignore **/node_modules/** **/.next/**
```
-#### `--d or --dry`
+#### `-d or --dry`
Dry run (no changes to files are made)
-```jsx
-yarn lg codemod --dry
+```js
+yarn lg codemod --dry
```
-#### `--p or --print`
+#### `-p or --print`
Print transformed files to stdout and changes are also made to files
-```jsx
-yarn lg codemod --print
+```js
+yarn lg codemod --print
```
-#### `--f or --force`
+#### `-f or --force`
Bypass Git safety checks and forcibly run codemods.
-```jsx
-yarn lg codemod --force
+```js
+yarn lg codemod --force
```
-## Codemods
+#### `--packages`
-**_NOTE:_ These codemods are for testing purposes only**
+Restrict the codemod to certain packages
-### `consolidate-props`
+```js
+yarn lg codemod --packages @leafygreen-ui/popover @leafygreen-ui/select
+```
-This codemod consolidates two props into one.
+## Codemods
-```jsx
-yarn lg codemod codemode-props
-```
+### `popover-v12`
-E.g.
-In this example, the `disabled` props is merged into the `state` prop.
+This codemod can be used to get started in refactoring LG components dependent on v12+ of `@leafygreen-ui/popover`.
-**Before**:
+By default, the codemod will apply for all below listed packages. Use the `--packages` flag to filter for a subset of these.
-```jsx
-
-```
+This codemod does the following:
-**After**:
+1. Adds an explicit `usePortal={true}` declaration if left undefined and consolidates the `usePortal` and `renderMode` props into a single `renderMode` prop for components in the following packages:
-```jsx
-
-```
+- `@leafygreen-ui/combobox`
+- `@leafygreen-ui/menu`
+- `@leafygreen-ui/popover`
+- `@leafygreen-ui/select`
+- `@leafygreen-ui/split-button`
+- `@leafygreen-ui/tooltip`
-
+2. Removes `popoverZIndex`, `portalClassName`, `portalContainer`, `portalRef`, `scrollContainer`, and `usePortal` props from the following components:
-### `rename-component-prop`
+- `@leafygreen-ui/info-sprinkle`
+- `@leafygreen-ui/inline-definition`
+- `@leafygreen-ui/number-input`
-This codemod renames a component prop
+3. Removes `popoverZIndex`, `portalClassName`, `portalContainer`, `portalRef`, and `scrollContainer` props from the following components:
-```jsx
-yarn lg codemod codemode-component-prop
-```
+- `@leafygreen-ui/date-picker`
+- `@leafygreen-ui/guide-cue`
-E.g.
-In this example, `prop` is renamed to `newProp`.
+4. Removes `popoverZIndex`, `portalClassName`, `portalContainer`, `scrollContainer`, and `usePortal` props from `Code` component in the `@leafygreen-ui/code` package
-**Before**:
+5. Removes `portalClassName`, `portalContainer`, `portalRef`, `scrollContainer`, and `usePortal` props from `SearchInput` component in the `@leafygreen-ui/search-input` package
-```jsx
-
-```
+6. Removes `shouldTooltipUsePortal` prop from `Copyable` component in the `@leafygreen-ui/copyable` package
-**After**:
+7. Replaces `justify="fit"` prop value with `justify="middle"` for components in the following packages:
-```jsx
-
+- `@leafygreen-ui/date-picker`
+- `@leafygreen-ui/info-sprinkle`
+- `@leafygreen-ui/inline-definition`
+- `@leafygreen-ui/menu`
+- `@leafygreen-ui/popover`
+- `@leafygreen-ui/tooltip`
+
+```js
+yarn lg codemod popover-v12 --packages @leafygreen-ui/combobox @leafygreen-ui/code @leafygreen-ui/info-sprinkle @leafygreen-ui/copyable
```
-
+**Before**:
-### `update-component-prop-value`
+```jsx
+import LeafyGreenCode from '@leafygreen-ui/code';
+import { Combobox as LGCombobox } from '@leafygreen-ui/combobox';
+import { DatePicker } from '@leafygreen-ui/date-picker';
+import { InfoSprinkle } from '@leafygreen-ui/info-sprinkle';
+import { Menu } from '@leafygreen-ui/menu';
+import Copyable from '@leafygreen-ui/copyable';
+import Tooltip from '@leafygreen-ui/tooltip';
-This codemod updates a prop value
+
+
+
-```jsx
-yarn lg codemod codemode-component-prop-value
-```
+
+
-E.g.
-In this example, `value` is updated to `new prop value`.
+
+
-**Before**:
+
+
+
-```jsx
-
+
+
```
**After**:
```jsx
-
+import LeafyGreenCode from '@leafygreen-ui/code';
+import { Combobox as LGCombobox } from '@leafygreen-ui/combobox';
+import { DatePicker } from '@leafygreen-ui/date-picker';
+import { InfoSprinkle } from '@leafygreen-ui/info-sprinkle';
+import { Menu } from '@leafygreen-ui/menu';
+import Copyable from '@leafygreen-ui/copyable';
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
```
diff --git a/tools/codemods/package.json b/tools/codemods/package.json
index 28c05e7d9a..c011caa0da 100644
--- a/tools/codemods/package.json
+++ b/tools/codemods/package.json
@@ -14,17 +14,17 @@
"access": "public"
},
"dependencies": {
- "jscodeshift": "0.15.2",
+ "@lg-tools/build": "0.5.0",
"chalk": "4.1.2",
+ "fs-extra": "11.1.1",
"glob": "10.3.12",
"is-git-clean": "1.1.0",
- "prettier": "2.8.8",
- "fs-extra": "11.1.1",
- "@lg-tools/build": "0.5.0"
+ "jscodeshift": "0.15.2",
+ "prettier": "2.8.8"
},
"devDependencies": {
- "@types/jscodeshift": "0.11.11",
- "@types/is-git-clean": "1.1.0"
+ "@types/is-git-clean": "1.1.0",
+ "@types/jscodeshift": "0.11.11"
},
"homepage": "https://github.com/mongodb/leafygreen-ui/tree/main/packages/codemods",
"repository": {
diff --git a/tools/codemods/src/codemods/consolidate-props/tests/consolidate-props.output.tsx b/tools/codemods/src/codemods/consolidate-props/tests/consolidate-props.output.tsx
index 5b3b10dd72..d2e329bc50 100644
--- a/tools/codemods/src/codemods/consolidate-props/tests/consolidate-props.output.tsx
+++ b/tools/codemods/src/codemods/consolidate-props/tests/consolidate-props.output.tsx
@@ -15,7 +15,7 @@ export const App = () => {
const Test = () => {
return (
- /* Please update manually */
+ /* Please manually update from prop: propToRemove to prop: propToUpdate */
);
};
@@ -23,7 +23,7 @@ export const App = () => {
const TestTwo = () => {
return (
<>
- {/* Please update manually */}
+ {/* Please manually update from prop: propToRemove to prop: propToUpdate */}
>
);
@@ -39,7 +39,7 @@ export const App = () => {
- {/* Please update manually */}
+ {/* Please manually update from prop: propToRemove to prop: propToUpdate */}
diff --git a/tools/codemods/src/codemods/popover-v12/tests/add-usePortal-consolidate-renderMode.input.jsx b/tools/codemods/src/codemods/popover-v12/tests/add-usePortal-consolidate-renderMode.input.jsx
new file mode 100644
index 0000000000..9f48c06c4c
--- /dev/null
+++ b/tools/codemods/src/codemods/popover-v12/tests/add-usePortal-consolidate-renderMode.input.jsx
@@ -0,0 +1,53 @@
+import React from 'react';
+
+import { Combobox as LGCombobox } from '@leafygreen-ui/combobox';
+import { Menu } from '@leafygreen-ui/menu';
+import Popover from '@leafygreen-ui/popover';
+import { Select } from '@leafygreen-ui/select';
+import { SplitButton } from '@leafygreen-ui/split-button';
+import LeafyGreenTooltip from '@leafygreen-ui/tooltip';
+
+const Combobox = ({ children, ...props }) => {
+ return {children}
;
+};
+
+const Child = (props) => {
+ return {props.children}
;
+};
+
+export const App = () => {
+ const spreadProps = {
+ prop: true,
+ };
+
+ const WrappedPopover = () => {
+ return ;
+ };
+
+ const DefaultWrappedPopover = () => {
+ return ;
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/codemods/popover-v12/tests/add-usePortal-consolidate-renderMode.output.jsx b/tools/codemods/src/codemods/popover-v12/tests/add-usePortal-consolidate-renderMode.output.jsx
new file mode 100644
index 0000000000..548fbb209b
--- /dev/null
+++ b/tools/codemods/src/codemods/popover-v12/tests/add-usePortal-consolidate-renderMode.output.jsx
@@ -0,0 +1,61 @@
+import React from 'react';
+
+import { Combobox as LGCombobox } from '@leafygreen-ui/combobox';
+import { Menu } from '@leafygreen-ui/menu';
+import Popover from '@leafygreen-ui/popover';
+import { Select } from '@leafygreen-ui/select';
+import { SplitButton } from '@leafygreen-ui/split-button';
+import LeafyGreenTooltip from '@leafygreen-ui/tooltip';
+
+const Combobox = ({ children, ...props }) => {
+ return {children}
;
+};
+
+const Child = (props) => {
+ return {props.children}
;
+};
+
+export const App = () => {
+ const spreadProps = {
+ prop: true,
+ };
+
+ const WrappedPopover = () => {
+ return (
+ /* Please manually update from prop: usePortal to prop: renderMode */
+
+ );
+ };
+
+ const DefaultWrappedPopover = () => {
+ return (
+ /* Please manually add prop: renderMode */
+
+ );
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Please manually update from prop: usePortal to prop: renderMode */}
+
+ {/* Please manually add prop: renderMode */}
+
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/codemods/popover-v12/tests/filter-packages.input.jsx b/tools/codemods/src/codemods/popover-v12/tests/filter-packages.input.jsx
new file mode 100644
index 0000000000..4adc36b239
--- /dev/null
+++ b/tools/codemods/src/codemods/popover-v12/tests/filter-packages.input.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+
+import { Combobox } from '@leafygreen-ui/combobox';
+import { Menu } from '@leafygreen-ui/menu';
+import Popover from '@leafygreen-ui/popover';
+import { Select } from '@leafygreen-ui/select';
+import Tooltip from '@leafygreen-ui/tooltip';
+
+export const App = () => {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/codemods/popover-v12/tests/filter-packages.output.jsx b/tools/codemods/src/codemods/popover-v12/tests/filter-packages.output.jsx
new file mode 100644
index 0000000000..a2c5761bd1
--- /dev/null
+++ b/tools/codemods/src/codemods/popover-v12/tests/filter-packages.output.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+
+import { Combobox } from '@leafygreen-ui/combobox';
+import { Menu } from '@leafygreen-ui/menu';
+import Popover from '@leafygreen-ui/popover';
+import { Select } from '@leafygreen-ui/select';
+import Tooltip from '@leafygreen-ui/tooltip';
+
+export const App = () => {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/codemods/popover-v12/tests/remove-legacy-props.input.jsx b/tools/codemods/src/codemods/popover-v12/tests/remove-legacy-props.input.jsx
new file mode 100644
index 0000000000..a460c6b37c
--- /dev/null
+++ b/tools/codemods/src/codemods/popover-v12/tests/remove-legacy-props.input.jsx
@@ -0,0 +1,49 @@
+import React from 'react';
+
+import LeafyGreenCode from '@leafygreen-ui/code';
+import { DatePicker } from '@leafygreen-ui/date-picker';
+import { GuideCue } from '@leafygreen-ui/guide-cue';
+import { InfoSprinkle } from '@leafygreen-ui/info-sprinkle';
+import InlineDefinition from '@leafygreen-ui/inline-definition';
+import { NumberInput } from '@leafygreen-ui/number-input';
+import { SearchInput as LGSearchInput } from '@leafygreen-ui/search-input';
+
+const Child = (props) => {
+ return {props.children}
;
+};
+
+export const App = () => {
+ const spreadProps = {
+ prop: true,
+ };
+
+ const WrappedInfoSprinkle = (props) => {
+ return ;
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/codemods/popover-v12/tests/remove-legacy-props.output.jsx b/tools/codemods/src/codemods/popover-v12/tests/remove-legacy-props.output.jsx
new file mode 100644
index 0000000000..fe19ba8865
--- /dev/null
+++ b/tools/codemods/src/codemods/popover-v12/tests/remove-legacy-props.output.jsx
@@ -0,0 +1,57 @@
+import React from 'react';
+
+import LeafyGreenCode from '@leafygreen-ui/code';
+import { DatePicker } from '@leafygreen-ui/date-picker';
+import { GuideCue } from '@leafygreen-ui/guide-cue';
+import { InfoSprinkle } from '@leafygreen-ui/info-sprinkle';
+import InlineDefinition from '@leafygreen-ui/inline-definition';
+import { NumberInput } from '@leafygreen-ui/number-input';
+import { SearchInput as LGSearchInput } from '@leafygreen-ui/search-input';
+
+const Child = (props) => {
+ return {props.children}
;
+};
+
+export const App = () => {
+ const spreadProps = {
+ prop: true,
+ };
+
+ const WrappedInfoSprinkle = (props) => {
+ return (
+ /* Please manually remove prop: popoverZIndex */
+ /* Please manually remove prop: portalClassName */
+ /* Please manually remove prop: portalContainer */
+ /* Please manually remove prop: portalRef */
+ /* Please manually remove prop: scrollContainer */
+ /* Please manually remove prop: usePortal */
+
+ );
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Please manually remove prop: popoverZIndex */}
+ {/* Please manually remove prop: portalClassName */}
+ {/* Please manually remove prop: portalContainer */}
+ {/* Please manually remove prop: portalRef */}
+ {/* Please manually remove prop: scrollContainer */}
+ {/* Please manually remove prop: usePortal */}
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/codemods/popover-v12/tests/remove-shouldTooltipUsePortal.input.jsx b/tools/codemods/src/codemods/popover-v12/tests/remove-shouldTooltipUsePortal.input.jsx
new file mode 100644
index 0000000000..8977d48bd8
--- /dev/null
+++ b/tools/codemods/src/codemods/popover-v12/tests/remove-shouldTooltipUsePortal.input.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+
+import Copyable from '@leafygreen-ui/copyable';
+
+const Child = (props) => {
+ return {props.children}
;
+};
+
+export const App = () => {
+ const spreadProps = {
+ prop: true,
+ };
+
+ const WrappedCopyable = (props) => {
+ return ;
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/codemods/popover-v12/tests/remove-shouldTooltipUsePortal.output.jsx b/tools/codemods/src/codemods/popover-v12/tests/remove-shouldTooltipUsePortal.output.jsx
new file mode 100644
index 0000000000..23c3c4c713
--- /dev/null
+++ b/tools/codemods/src/codemods/popover-v12/tests/remove-shouldTooltipUsePortal.output.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+
+import Copyable from '@leafygreen-ui/copyable';
+
+const Child = (props) => {
+ return {props.children}
;
+};
+
+export const App = () => {
+ const spreadProps = {
+ prop: true,
+ };
+
+ const WrappedCopyable = (props) => {
+ return (
+ /* Please manually remove prop: shouldTooltipUsePortal */
+
+ );
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ {/* Please manually remove prop: shouldTooltipUsePortal */}
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/codemods/popover-v12/tests/replace-justify-fit.input.jsx b/tools/codemods/src/codemods/popover-v12/tests/replace-justify-fit.input.jsx
new file mode 100644
index 0000000000..e9dfb8d1d3
--- /dev/null
+++ b/tools/codemods/src/codemods/popover-v12/tests/replace-justify-fit.input.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+
+import { DatePicker as LGDatePicker } from '@leafygreen-ui/date-picker';
+import { InfoSprinkle } from '@leafygreen-ui/info-sprinkle';
+import InlineDefinition from '@leafygreen-ui/inline-definition';
+import { Menu } from '@leafygreen-ui/menu';
+import Popover from '@leafygreen-ui/popover';
+import LeafyGreenTooltip from '@leafygreen-ui/tooltip';
+
+export const App = () => {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/codemods/popover-v12/tests/replace-justify-fit.output.jsx b/tools/codemods/src/codemods/popover-v12/tests/replace-justify-fit.output.jsx
new file mode 100644
index 0000000000..c9e030c2af
--- /dev/null
+++ b/tools/codemods/src/codemods/popover-v12/tests/replace-justify-fit.output.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+
+import { DatePicker as LGDatePicker } from '@leafygreen-ui/date-picker';
+import { InfoSprinkle } from '@leafygreen-ui/info-sprinkle';
+import InlineDefinition from '@leafygreen-ui/inline-definition';
+import { Menu } from '@leafygreen-ui/menu';
+import Popover from '@leafygreen-ui/popover';
+import LeafyGreenTooltip from '@leafygreen-ui/tooltip';
+
+export const App = () => {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/codemods/popover-v12/tests/transform.spec.ts b/tools/codemods/src/codemods/popover-v12/tests/transform.spec.ts
new file mode 100644
index 0000000000..f75388b391
--- /dev/null
+++ b/tools/codemods/src/codemods/popover-v12/tests/transform.spec.ts
@@ -0,0 +1,33 @@
+import { transformTest } from '../../../utils/tests/transformTest';
+
+const transform = 'popover-v12';
+
+const tests = [
+ {
+ name: 'add-usePortal-consolidate-renderMode',
+ },
+ {
+ name: 'filter-packages',
+ options: {
+ packages: ['@leafygreen-ui/popover', '@leafygreen-ui/select'],
+ },
+ },
+ {
+ name: 'remove-legacy-props',
+ },
+ {
+ name: 'remove-shouldTooltipUsePortal',
+ },
+ {
+ name: 'replace-justify-fit',
+ },
+];
+
+for (const test of tests) {
+ transformTest(__dirname, {
+ extension: 'jsx',
+ fixture: test.name,
+ transform,
+ options: test.options,
+ });
+}
diff --git a/tools/codemods/src/codemods/popover-v12/transform.ts b/tools/codemods/src/codemods/popover-v12/transform.ts
new file mode 100644
index 0000000000..2568c77c5c
--- /dev/null
+++ b/tools/codemods/src/codemods/popover-v12/transform.ts
@@ -0,0 +1,271 @@
+import type { API, FileInfo, Options } from 'jscodeshift';
+
+import { MigrateOptions } from '../..';
+import { MIGRATOR_ERROR } from '../../constants';
+import { LGPackage } from '../../types';
+import { getImportSpecifiersForDeclaration } from '../../utils/imports';
+import { getJSXAttributes } from '../../utils/jsx';
+import {
+ addJSXAttributes,
+ consolidateJSXAttributes,
+ removeJSXAttributes,
+ replaceJSXAttributes,
+} from '../../utils/transformations';
+
+const lgPackageComponentForPropConsolidationMap: Partial<
+ Record
+> = {
+ [LGPackage.Combobox]: 'Combobox',
+ [LGPackage.Menu]: 'Menu',
+ [LGPackage.Popover]: 'Popover',
+ [LGPackage.Select]: 'Select',
+ [LGPackage.SplitButton]: 'SplitButton',
+ [LGPackage.Tooltip]: 'Tooltip',
+};
+
+const lgPackageComponentForPropRemovalMap: Partial> =
+ {
+ [LGPackage.Code]: 'Code',
+ [LGPackage.Copyable]: 'Copyable',
+ [LGPackage.DatePicker]: 'DatePicker',
+ [LGPackage.GuideCue]: 'GuideCue',
+ [LGPackage.InfoSprinkle]: 'InfoSprinkle',
+ [LGPackage.InlineDefinition]: 'InlineDefinition',
+ [LGPackage.NumberInput]: 'NumberInput',
+ [LGPackage.SearchInput]: 'SearchInput',
+ };
+
+const lgPackageComponentForPropReplacementMap: Partial<
+ Record
+> = {
+ [LGPackage.DatePicker]: 'DatePicker',
+ [LGPackage.InfoSprinkle]: 'InfoSprinkle',
+ [LGPackage.InlineDefinition]: 'InlineDefinition',
+ [LGPackage.Menu]: 'Menu',
+ [LGPackage.Popover]: 'Popover',
+ [LGPackage.Tooltip]: 'Tooltip',
+};
+
+const defaultPackages: Array = [
+ ...(Object.keys({
+ ...lgPackageComponentForPropConsolidationMap,
+ ...lgPackageComponentForPropRemovalMap,
+ ...lgPackageComponentForPropReplacementMap,
+ }) as Array),
+];
+
+const propNamesToRemove = [
+ 'popoverZIndex',
+ 'portalClassName',
+ 'portalContainer',
+ 'portalRef',
+ 'scrollContainer',
+ 'usePortal',
+];
+
+const componentPropsToRemoveMap: Record> = {
+ [LGPackage.Code]: propNamesToRemove.filter(
+ propName => propName !== 'portalRef',
+ ),
+ [LGPackage.Copyable]: ['shouldTooltipUsePortal'],
+ [LGPackage.DatePicker]: propNamesToRemove.filter(
+ propName => propName !== 'usePortal',
+ ),
+ [LGPackage.GuideCue]: propNamesToRemove.filter(
+ propName => propName !== 'usePortal',
+ ),
+ [LGPackage.InfoSprinkle]: propNamesToRemove,
+ [LGPackage.InlineDefinition]: propNamesToRemove,
+ [LGPackage.NumberInput]: propNamesToRemove,
+ [LGPackage.SearchInput]: propNamesToRemove.filter(
+ propName => propName !== 'popoverZIndex',
+ ),
+};
+
+/**
+ * Transformer function that will transform the below packages by default. Consumers can
+ * use the `--packages` flag to apply the codemod to a subset of these packages.
+ *
+ * It does the following:
+ * 1. Adds an explicit `usePortal={true}` declaration if left undefined and consolidates
+ * the `usePortal` and `renderMode` props into a single `renderMode` prop for components
+ * in the following packages:
+ * - `@leafygreen-ui/combobox`
+ * - `@leafygreen-ui/menu`
+ * - `@leafygreen-ui/popover`
+ * - `@leafygreen-ui/select`
+ * - `@leafygreen-ui/split-button`
+ * - `@leafygreen-ui/tooltip`
+ *
+ * 2. Removes `popoverZIndex`, `portalClassName`, `portalContainer`, `portalRef`,
+ * `scrollContainer`, and `usePortal` props from the components in the following packages:
+ * - `@leafygreen-ui/info-sprinkle`
+ * - `@leafygreen-ui/inline-definition`
+ * - `@leafygreen-ui/number-input`
+ *
+ * 3. Removes `popoverZIndex`, `portalClassName`, `portalContainer`, `portalRef`, and
+ * `scrollContainer` props from components in the following packages:
+ * - `@leafygreen-ui/date-picker`
+ * - `@leafygreen-ui/guide-cue`
+ *
+ * 4. Removes `popoverZIndex`, `portalClassName`, `portalContainer`, `scrollContainer`,
+ * and `usePortal` props from `Code` component in the `@leafygreen-ui/code` package
+ *
+ * 5. Removes `portalClassName`, `portalContainer`, `portalRef`, `scrollContainer`, and
+ * `usePortal` props from `SearchInput` component in the `@leafygreen-ui/search-input` package
+ *
+ * 6. Removes `shouldTooltipUsePortal` prop from the `Copyable` component in the `@leafygreen-ui/copyable` package
+ *
+ * 7. Replaces `justify="fit"` prop value with `justify="middle"` for components in the following packages:
+ * - `@leafygreen-ui/date-picker`
+ * - `@leafygreen-ui/info-sprinkle`
+ * - `@leafygreen-ui/inline-definition`
+ * - `@leafygreen-ui/menu`
+ * - `@leafygreen-ui/popover`
+ * - `@leafygreen-ui/tooltip`
+ *
+ * @param file the file to transform
+ * @param jscodeshiftOptions an object containing at least a reference to the jscodeshift library
+ * @param options an object containing options to pass to the transform function
+ * @returns Either the modified file or the original file
+ */
+export default function transformer(
+ file: FileInfo,
+ { jscodeshift: j }: API,
+ options: MigrateOptions & Options,
+): string {
+ const source = j(file.source);
+ const providedPackages = options.packages;
+
+ /**
+ * If the `packages` option is provided, ensure that the provided packages are all valid.
+ */
+ if (providedPackages) {
+ providedPackages.forEach(packageName => {
+ if (!defaultPackages.includes(packageName)) {
+ throw new Error(
+ `Cannot run popover-v12 codemod on package: ${packageName}`,
+ );
+ }
+ });
+ }
+
+ /**
+ * By default, transform all components in the default packages. If the `packages` option is provided,
+ * only transform the provided packages.
+ */
+ const packagesToCheck = providedPackages || defaultPackages;
+ const packagesForPropConsolidation = packagesToCheck.filter(
+ packageName => lgPackageComponentForPropConsolidationMap[packageName],
+ );
+ const packagesForPropRemoval = packagesToCheck.filter(
+ packageName => lgPackageComponentForPropRemovalMap[packageName],
+ );
+ const packagesForPropReplacement = packagesToCheck.filter(
+ packageName => lgPackageComponentForPropReplacementMap[packageName],
+ );
+
+ /**
+ * This block handles transforming components that require prop consolidation.
+ */
+ packagesForPropConsolidation.forEach(packageName => {
+ const componentsForPropConsolidation = getImportSpecifiersForDeclaration({
+ j,
+ source,
+ packageName,
+ packageSpecifiersMap: lgPackageComponentForPropConsolidationMap,
+ });
+
+ componentsForPropConsolidation.forEach(componentName => {
+ const elements = source.findJSXElements(componentName);
+
+ if (elements.length === 0) return;
+
+ elements.forEach(element => {
+ const attributes = getJSXAttributes(j, element, 'renderMode');
+
+ if (attributes.length === 0) {
+ addJSXAttributes({
+ j,
+ element,
+ propName: 'usePortal',
+ propValue: true,
+ commentOverride: `${MIGRATOR_ERROR.manualAdd} prop: renderMode`,
+ });
+ }
+
+ consolidateJSXAttributes({
+ j,
+ element,
+ propToRemove: 'usePortal',
+ propToUpdate: 'renderMode',
+ propMapping: {
+ false: 'inline',
+ true: 'portal',
+ },
+ propToRemoveType: 'boolean',
+ });
+ });
+ });
+ });
+
+ /**
+ * This block handles transforming components that require prop removal.
+ */
+ packagesForPropRemoval.forEach(packageName => {
+ const componentsForPropRemoval = getImportSpecifiersForDeclaration({
+ j,
+ source,
+ packageName,
+ packageSpecifiersMap: lgPackageComponentForPropRemovalMap,
+ });
+
+ componentsForPropRemoval.forEach(componentName => {
+ const elements = source.findJSXElements(componentName);
+
+ if (elements.length === 0) return;
+
+ elements.forEach(element => {
+ componentPropsToRemoveMap[packageName].forEach(propName => {
+ removeJSXAttributes({
+ j,
+ element,
+ propName,
+ });
+ });
+ });
+ });
+ });
+
+ /**
+ * This block handles transforming components that require prop replacement.
+ */
+ packagesForPropReplacement.forEach(packageName => {
+ const componentsForPropReplacement = getImportSpecifiersForDeclaration({
+ j,
+ source,
+ packageName,
+ packageSpecifiersMap: lgPackageComponentForPropReplacementMap,
+ });
+
+ componentsForPropReplacement.forEach(componentName => {
+ const elements = source.findJSXElements(componentName);
+
+ if (elements.length === 0) return;
+
+ elements.forEach(element => {
+ replaceJSXAttributes({
+ j,
+ element,
+ propName: 'justify',
+ newPropName: 'justify',
+ newPropValue: {
+ fit: 'middle',
+ },
+ });
+ });
+ });
+ });
+
+ return source.toSource();
+}
diff --git a/tools/codemods/src/constants.ts b/tools/codemods/src/constants.ts
index c6c0f20943..242b8c1da0 100644
--- a/tools/codemods/src/constants.ts
+++ b/tools/codemods/src/constants.ts
@@ -1,3 +1,5 @@
export const MIGRATOR_ERROR = {
- manual: 'Please update manually',
+ manualAdd: 'Please manually add',
+ manualRemove: 'Please manually remove',
+ manualUpdate: 'Please manually update',
};
diff --git a/tools/codemods/src/index.ts b/tools/codemods/src/index.ts
index ab1ab42d76..9d291c112c 100644
--- a/tools/codemods/src/index.ts
+++ b/tools/codemods/src/index.ts
@@ -6,12 +6,14 @@ import * as jscodeshift from 'jscodeshift/src/Runner';
import path from 'path';
import { checkGitStatus } from './utils/checkGitStatus';
+import { LGPackage } from './types';
export interface MigrateOptions {
dry?: boolean;
print?: boolean;
force?: boolean;
ignore?: Array;
+ packages?: Array;
}
export const migrator = async (
diff --git a/tools/codemods/src/types.ts b/tools/codemods/src/types.ts
new file mode 100644
index 0000000000..79c7dee274
--- /dev/null
+++ b/tools/codemods/src/types.ts
@@ -0,0 +1,71 @@
+/**
+ * All `@leafygreen-ui/*` packages as of November 2024 that can be identified in
+ * import declarations to determine if a codemod should be run on them.
+ */
+export const LGPackage = {
+ A11y: '@leafygreen-ui/a11y',
+ Avatar: '@leafygreen-ui/avatar',
+ Badge: '@leafygreen-ui/badge',
+ Banner: '@leafygreen-ui/banner',
+ Box: '@leafygreen-ui/box',
+ Button: '@leafygreen-ui/button',
+ Callout: '@leafygreen-ui/callout',
+ Card: '@leafygreen-ui/card',
+ Checkbox: '@leafygreen-ui/checkbox',
+ Chip: '@leafygreen-ui/chip',
+ Code: '@leafygreen-ui/code',
+ Combobox: '@leafygreen-ui/combobox',
+ ConfirmationModal: '@leafygreen-ui/confirmation-modal',
+ Copyable: '@leafygreen-ui/copyable',
+ DatePicker: '@leafygreen-ui/date-picker',
+ DateUtils: '@leafygreen-ui/date-utils',
+ Descendants: '@leafygreen-ui/descendants',
+ Emotion: '@leafygreen-ui/emotion',
+ EmptyState: '@leafygreen-ui/empty-state',
+ ExpandableCard: '@leafygreen-ui/expandable-card',
+ FormField: '@leafygreen-ui/form-field',
+ FormFooter: '@leafygreen-ui/form-footer',
+ GuideCue: '@leafygreen-ui/guide-cue',
+ Hooks: '@leafygreen-ui/hooks',
+ Icon: '@leafygreen-ui/icon',
+ IconButton: '@leafygreen-ui/icon-button',
+ InfoSprinkle: '@leafygreen-ui/info-sprinkle',
+ InlineDefinition: '@leafygreen-ui/inline-definition',
+ InputOption: '@leafygreen-ui/input-option',
+ LeafyGreenProvider: '@leafygreen-ui/leafygreen-provider',
+ Lib: '@leafygreen-ui/lib',
+ LoadingIndicator: '@leafygreen-ui/loading-indicator',
+ Logo: '@leafygreen-ui/logo',
+ MarketingModal: '@leafygreen-ui/marketing-modal',
+ Menu: '@leafygreen-ui/menu',
+ Modal: '@leafygreen-ui/modal',
+ NumberInput: '@leafygreen-ui/number-input',
+ Pagination: '@leafygreen-ui/pagination',
+ Palette: '@leafygreen-ui/palette',
+ PasswordInput: '@leafygreen-ui/password-input',
+ Pipeline: '@leafygreen-ui/pipeline',
+ Polymorphic: '@leafygreen-ui/polymorphic',
+ Popover: '@leafygreen-ui/popover',
+ Portal: '@leafygreen-ui/portal',
+ RadioBoxGroup: '@leafygreen-ui/radio-box-group',
+ RadioGroup: '@leafygreen-ui/radio-group',
+ Ripple: '@leafygreen-ui/ripple',
+ SearchInput: '@leafygreen-ui/search-input',
+ SegmentedControl: '@leafygreen-ui/segmented-control',
+ Select: '@leafygreen-ui/select',
+ SideNav: '@leafygreen-ui/side-nav',
+ SkeletonLoader: '@leafygreen-ui/skeleton-loader',
+ SplitButton: '@leafygreen-ui/split-button',
+ Stepper: '@leafygreen-ui/stepper',
+ Table: '@leafygreen-ui/table',
+ Tabs: '@leafygreen-ui/tabs',
+ TestingLib: '@leafygreen-ui/testing-lib',
+ TextArea: '@leafygreen-ui/text-area',
+ TextInput: '@leafygreen-ui/text-input',
+ Toast: '@leafygreen-ui/toast',
+ Toggle: '@leafygreen-ui/toggle',
+ Tokens: '@leafygreen-ui/tokens',
+ Tooltip: '@leafygreen-ui/tooltip',
+ Typography: '@leafygreen-ui/typography',
+} as const;
+export type LGPackage = (typeof LGPackage)[keyof typeof LGPackage];
diff --git a/tools/codemods/src/utils/imports/getImportSpecifiersForDeclaration.ts b/tools/codemods/src/utils/imports/getImportSpecifiersForDeclaration.ts
new file mode 100644
index 0000000000..4f33d88aed
--- /dev/null
+++ b/tools/codemods/src/utils/imports/getImportSpecifiersForDeclaration.ts
@@ -0,0 +1,102 @@
+import type { Collection } from 'jscodeshift';
+import type core from 'jscodeshift';
+
+import { LGPackage } from '../../types';
+
+export interface GetImportSpecifiersForDeclarationType {
+ /**
+ * A reference to the jscodeshift library
+ */
+ j: core.JSCodeshift;
+
+ /**
+ * The source file to transform
+ */
+ source: Collection;
+
+ /**
+ * The package name to check for specifiers to target
+ */
+ packageName: LGPackage;
+
+ /**
+ * The map of package name to specifier names to look for
+ */
+ packageSpecifiersMap: Partial>;
+}
+
+/**
+ * Gets all components to transform for a given package name, including if aliased.
+ *
+ * e.g:
+ * Source file:
+ *
+ * ```typescript
+ * import { Option, Select as LGSelect, Size } from '@leafygreen-ui/select';
+ * ```
+ *
+ * | packageName | packageSpecifiersMap | specifierNames |
+ * |-------------|------------------------|-----------------|
+ * | '@leafygreen-ui/select' | { '@leafygreen-ui/select': 'Select' } | ['LGSelect'] |
+ * | '@leafygreen-ui/select' | { '@leafygreen-ui/select': 'Option' } | ['Option'] |
+ * | '@leafygreen-ui/select' | { '@leafygreen-ui/select': 'Size' } | ['Size'] |
+ */
+export function getImportSpecifiersForDeclaration({
+ j,
+ source,
+ packageName,
+ packageSpecifiersMap,
+}: GetImportSpecifiersForDeclarationType) {
+ const specifierNames: Array = [];
+
+ /**
+ * Look for the import declaration for the given package name.
+ */
+ const matchingImportDeclaration = source
+ .find(j.ImportDeclaration)
+ .filter(path => path.node.source.value === packageName);
+
+ /**
+ * If no matching import declaration is found, return empty array.
+ */
+ if (matchingImportDeclaration.length === 0) {
+ return specifierNames;
+ }
+
+ /**
+ * Look for default import declarations for the given package name.
+ * This will also apply if the default import is aliased.
+ */
+ matchingImportDeclaration.find(j.ImportDefaultSpecifier).forEach(path => {
+ if (!path.node.local) {
+ return;
+ }
+
+ specifierNames.push(path.node.local.name);
+ });
+
+ /**
+ * Look for named import declarations for the given package name.
+ */
+ const namedExportToLookFor = packageSpecifiersMap[packageName];
+ matchingImportDeclaration.find(j.ImportSpecifier).forEach(path => {
+ /**
+ * If the named import does not match the component to look for, keep checking the next import.
+ */
+ const matchesComponentImport =
+ path.node.imported.name === namedExportToLookFor;
+
+ if (!matchesComponentImport) {
+ return;
+ }
+
+ /**
+ * If the named import is aliased, use the alias name.
+ */
+ specifierNames.push(
+ path.node.local ? path.node.local.name : namedExportToLookFor,
+ );
+ });
+
+ return specifierNames;
+}
diff --git a/tools/codemods/src/utils/imports/index.ts b/tools/codemods/src/utils/imports/index.ts
new file mode 100644
index 0000000000..d2a4be788c
--- /dev/null
+++ b/tools/codemods/src/utils/imports/index.ts
@@ -0,0 +1 @@
+export { getImportSpecifiersForDeclaration } from './getImportSpecifiersForDeclaration';
diff --git a/tools/codemods/src/utils/jsx/insertJSXComment/insertJSXComment.ts b/tools/codemods/src/utils/jsx/insertJSXComment/insertJSXComment.ts
index 1afeed6bfe..14710543e8 100644
--- a/tools/codemods/src/utils/jsx/insertJSXComment/insertJSXComment.ts
+++ b/tools/codemods/src/utils/jsx/insertJSXComment/insertJSXComment.ts
@@ -19,15 +19,14 @@ export function insertJSXComment(
) {
// https://github.com/facebook/jscodeshift/issues/354
const commentContent = j.jsxEmptyExpression();
- const commentConcat = ` ${comment} `;
- commentContent.comments = [j.commentBlock(commentConcat, false, true)];
+ commentContent.comments = [j.commentBlock(` ${comment} `, false, true)];
const jsxComment = j.jsxExpressionContainer(commentContent);
const lineBreak = j.jsxText('\n');
if (position === 'before') {
// If the component is the first direct child after a return statement, this means it is not nested in another component so comments should look like /* comment */, without the brackets
if (element.parentPath.value.type === 'ReturnStatement') {
- insertCommentBefore(j, element, commentConcat);
+ insertCommentBefore(j, element, comment);
} else {
// The element is nested inside another component so comments should look like {/* comment */}, with the brackets
element.insertBefore(jsxComment);
@@ -51,14 +50,15 @@ export function insertCommentBefore(
path: ASTPath,
commentString: string,
) {
+ const content = ` ${commentString} `;
path.value.comments = path.value.comments || [];
const duplicateComment = path.value.comments.find(
- (comment: Comment) => comment.value === commentString,
+ (comment: Comment) => comment.value === content,
);
// Avoiding duplicates of the same comment
if (duplicateComment) return;
- path.value.comments.push(j.commentBlock(commentString));
+ path.value.comments.push(j.commentBlock(content));
}
diff --git a/tools/codemods/src/utils/transformations/addJSXAttributes/addJSXAttributes.ts b/tools/codemods/src/utils/transformations/addJSXAttributes/addJSXAttributes.ts
new file mode 100644
index 0000000000..66612791b3
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/addJSXAttributes/addJSXAttributes.ts
@@ -0,0 +1,96 @@
+import type { ASTNode, ASTPath, JSXAttribute } from 'jscodeshift';
+import type core from 'jscodeshift';
+
+import { MIGRATOR_ERROR } from '../../../constants';
+import { insertJSXComment } from '../../jsx';
+
+export interface AddJSXAttributesType {
+ /**
+ * A reference to the jscodeshift library
+ */
+ j: core.JSCodeshift;
+
+ /**
+ * The element(component) to transform
+ */
+ element: ASTPath;
+
+ /**
+ * The name of the prop that will be added to the element
+ */
+ propName: string;
+
+ /**
+ * The new value of the prop. This can either be a string, number, boolean, or null.
+ */
+ propValue: string | number | boolean | null;
+
+ /**
+ * Optional comment for cases where adding prop did not work as expected
+ */
+ commentOverride?: string;
+}
+
+/**
+ * `addJSXAttributes` can add a value of an attribute(prop).
+ *
+ * e.g:
+ * ```tsx
+ * propName: prop
+ * propValue: false
+ *
+ * Before:
+ *
+ * After:
+ *
+ * -----------------------------------
+ * propName: prop
+ * propValue: 'hey'
+ *
+ * Before:
+ *
+ * After:
+ *
+ * ```
+ */
+export function addJSXAttributes({
+ j,
+ element,
+ propName,
+ propValue,
+ commentOverride,
+}: AddJSXAttributesType) {
+ const allAttributes = element.node.openingElement.attributes;
+
+ const hasSpreadOperator = allAttributes.some(
+ (attr: ASTNode) => attr.type !== 'JSXAttribute',
+ );
+
+ const allAttributesWithoutSpread = allAttributes.filter(
+ (attr: ASTNode) => attr.type === 'JSXAttribute',
+ );
+
+ const foundAttribute: ASTPath = allAttributesWithoutSpread.find(
+ (attribute: ASTPath) => attribute.name.name === propName,
+ );
+
+ if (foundAttribute) {
+ return;
+ }
+
+ if (hasSpreadOperator) {
+ insertJSXComment(
+ j,
+ element,
+ commentOverride ?? `${MIGRATOR_ERROR.manualAdd} prop: ${propName}`,
+ );
+ return;
+ }
+
+ const isPropValueString = typeof propValue === 'string';
+ const attrValueNode = isPropValueString
+ ? j.stringLiteral(propValue)
+ : j.jsxExpressionContainer(j.literal(propValue));
+ const newAttribute = j.jsxAttribute(j.jsxIdentifier(propName), attrValueNode);
+ allAttributes.push(newAttribute);
+}
diff --git a/tools/codemods/src/utils/transformations/addJSXAttributes/index.ts b/tools/codemods/src/utils/transformations/addJSXAttributes/index.ts
new file mode 100644
index 0000000000..f0ac8ffe39
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/addJSXAttributes/index.ts
@@ -0,0 +1 @@
+export { addJSXAttributes, AddJSXAttributesType } from './addJSXAttributes';
diff --git a/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-boolean.input.tsx b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-boolean.input.tsx
new file mode 100644
index 0000000000..181c1f2b8a
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-boolean.input.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+
+const MyComponent = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const Child = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const spreadProps = {
+ prop: true,
+};
+
+export const App = () => {
+ return (
+ <>
+
+ Hello
+
+
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-boolean.output.tsx b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-boolean.output.tsx
new file mode 100644
index 0000000000..2a5bb4eaf4
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-boolean.output.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+
+const MyComponent = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const Child = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const spreadProps = {
+ prop: true,
+};
+
+export const App = () => {
+ return (
+ <>
+
+ Hello
+
+
+ {/* Please manually add prop: prop */}
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-null.input.tsx b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-null.input.tsx
new file mode 100644
index 0000000000..75fa9f1fe6
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-null.input.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+
+const MyComponent = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const Child = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const spreadProps = {
+ prop: 'existing-value',
+};
+
+export const App = () => {
+ return (
+ <>
+
+ Hello
+
+
+
+ Hello
+ >
+ );
+};
diff --git a/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-null.output.tsx b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-null.output.tsx
new file mode 100644
index 0000000000..579b6aa853
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-null.output.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+
+const MyComponent = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const Child = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const spreadProps = {
+ prop: 'existing-value',
+};
+
+export const App = () => {
+ return (
+ <>
+
+ Hello
+
+
+ {/* Please manually add prop: prop */}
+
+ Hello
+ >
+ );
+};
diff --git a/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-number.input.tsx b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-number.input.tsx
new file mode 100644
index 0000000000..f7513a9acb
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-number.input.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+
+const MyComponent = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const Child = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const spreadProps = {
+ prop: 789,
+};
+
+export const App = () => {
+ return (
+ <>
+
+ Hello
+
+
+
+ Hello
+ >
+ );
+};
diff --git a/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-number.output.tsx b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-number.output.tsx
new file mode 100644
index 0000000000..d0dd0a704b
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-number.output.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+
+const MyComponent = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const Child = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const spreadProps = {
+ prop: 789,
+};
+
+export const App = () => {
+ return (
+ <>
+
+ Hello
+
+
+ {/* Please manually add prop: prop */}
+
+ Hello
+ >
+ );
+};
diff --git a/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-string.input.tsx b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-string.input.tsx
new file mode 100644
index 0000000000..75fa9f1fe6
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-string.input.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+
+const MyComponent = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const Child = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const spreadProps = {
+ prop: 'existing-value',
+};
+
+export const App = () => {
+ return (
+ <>
+
+ Hello
+
+
+
+ Hello
+ >
+ );
+};
diff --git a/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-string.output.tsx b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-string.output.tsx
new file mode 100644
index 0000000000..868463b7fd
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/add-component-prop-string.output.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+
+const MyComponent = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const Child = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const spreadProps = {
+ prop: 'existing-value',
+};
+
+export const App = () => {
+ return (
+ <>
+
+ Hello
+
+
+ {/* Please manually add prop: newStringProp */}
+
+ Hello
+ >
+ );
+};
diff --git a/tools/codemods/src/utils/transformations/addJSXAttributes/tests/transform.spec.ts b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/transform.spec.ts
new file mode 100644
index 0000000000..53814b7fe4
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/addJSXAttributes/tests/transform.spec.ts
@@ -0,0 +1,48 @@
+import { MIGRATOR_ERROR } from '../../../../constants';
+import { transformTest } from '../../../tests/transformTest';
+
+const transform = 'add-jsx-attributes';
+
+const tests = [
+ {
+ name: 'add-component-prop-boolean',
+ options: {
+ componentName: 'MyComponent',
+ propName: 'prop',
+ propValue: false,
+ },
+ },
+ {
+ name: 'add-component-prop-number',
+ options: {
+ componentName: 'MyComponent',
+ propName: 'prop',
+ propValue: 123,
+ },
+ },
+ {
+ name: 'add-component-prop-string',
+ options: {
+ componentName: 'MyComponent',
+ propName: 'prop',
+ propValue: 'new-value',
+ commentOverride: `${MIGRATOR_ERROR.manualAdd} prop: newStringProp`,
+ },
+ },
+ {
+ name: 'add-component-prop-null',
+ options: {
+ componentName: 'MyComponent',
+ propName: 'prop',
+ propValue: null,
+ },
+ },
+];
+
+for (const test of tests) {
+ transformTest(__dirname, {
+ fixture: test.name,
+ transform,
+ options: test.options,
+ });
+}
diff --git a/tools/codemods/src/utils/transformations/addJSXAttributes/transform.ts b/tools/codemods/src/utils/transformations/addJSXAttributes/transform.ts
new file mode 100644
index 0000000000..2f855e7ceb
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/addJSXAttributes/transform.ts
@@ -0,0 +1,41 @@
+import type { API, FileInfo } from 'jscodeshift';
+
+import { addJSXAttributes, AddJSXAttributesType } from './addJSXAttributes';
+
+type TransformerOptions = AddJSXAttributesType & { componentName: string };
+
+/**
+ * Example transformer function to add a component prop value
+ *
+ * @param file the file to transform
+ * @param jscodeshiftOptions an object containing at least a reference to the jscodeshift library
+ * @param options an object containing options to pass to the transform function
+ * @returns Either the modified file or the original file
+ */
+export default function transformer(
+ file: FileInfo,
+ { jscodeshift: j }: API,
+ options: TransformerOptions,
+) {
+ const { propName, propValue, componentName, commentOverride } = options;
+
+ const source = j(file.source);
+
+ // Check if the element is on the page
+ const elements = source.findJSXElements(componentName);
+
+ // If there are no elements then return the original file
+ if (elements.length === 0) return file.source;
+
+ elements.forEach(element => {
+ addJSXAttributes({
+ j,
+ element,
+ propName,
+ propValue,
+ commentOverride,
+ });
+ });
+
+ return source.toSource();
+}
diff --git a/tools/codemods/src/utils/transformations/consolidateJSXAttributes/consolidateJSXAttributes.ts b/tools/codemods/src/utils/transformations/consolidateJSXAttributes/consolidateJSXAttributes.ts
index a53c59c4c5..2721d6be4c 100644
--- a/tools/codemods/src/utils/transformations/consolidateJSXAttributes/consolidateJSXAttributes.ts
+++ b/tools/codemods/src/utils/transformations/consolidateJSXAttributes/consolidateJSXAttributes.ts
@@ -76,7 +76,7 @@ export function consolidateJSXAttributes({
propMapping,
propToRemoveType,
}: ConsolidateJSXAttributesOptions) {
- const isPropToRemoveABoolean = propToRemoveType === 'boolean';
+ const isRemovingBoolean = propToRemoveType === 'boolean';
// gets all the props on the elements opening tag
const allAttributes = element.node.openingElement.attributes;
@@ -111,10 +111,20 @@ export function consolidateJSXAttributes({
allAttributes.splice(attributeToRemoveIndex, 1);
// find the new value that propToUpdate should be updated with
- const newValueMapping =
- propMapping[getPropToRemoveValue(isPropToRemoveABoolean, _propToRemove)];
+ const oldValue = getPropToRemoveValue(isRemovingBoolean, _propToRemove);
+ const newValueMapping = propMapping[oldValue];
+
+ // if fromProp value is variable of the same name then return early since we don't know the value
+ if (oldValue === propToRemove) {
+ insertJSXComment(
+ j,
+ element,
+ `${MIGRATOR_ERROR.manualUpdate} from prop: ${propToRemove} to prop: ${propToUpdate}`,
+ );
+ return;
+ }
- // if fromProp value is not in the mapping then remove that item from the atributes and return
+ // if fromProp value is not in the mapping then remove that item from the attributes and return
if (!newValueMapping) {
removePropToRemove();
return;
@@ -122,7 +132,11 @@ export function consolidateJSXAttributes({
// if the propToUpdate does not exist and there is a spread operator then return early since we don't know if the propToUpdate could be inside the spread
if (!_propToUpdate && hasSpreadOperator) {
- insertJSXComment(j, element, MIGRATOR_ERROR.manual);
+ insertJSXComment(
+ j,
+ element,
+ `${MIGRATOR_ERROR.manualUpdate} from prop: ${propToRemove} to prop: ${propToUpdate}`,
+ );
return;
}
@@ -158,11 +172,11 @@ export function consolidateJSXAttributes({
/**
* This function checks whether the propToRemove is a string or a boolean and returns that value as a string.
*
- * @param isPropToRemoveABoolean
+ * @param isRemovingBoolean
* @returns string
*/
const getPropToRemoveValue = (
- isPropToRemoveABoolean: boolean,
+ isRemovingBoolean: boolean,
propToRemove: ASTPath,
) => {
let propToRemoveValue;
@@ -173,15 +187,24 @@ const getPropToRemoveValue = (
propToRemove.value === null ||
propToRemove.value === undefined;
- if (isPropToRemoveABoolean) {
- if (isBoolean) {
- propToRemoveValue =
- propToRemove.value === null
- ? true
- : // @ts-expect-error: unsure why it says expression does not exist on type 'JSXAttribute'.
- propToRemove?.value?.expression.value;
- return propToRemoveValue.toString();
+ // edge case if prop is set to variable of same name. e.g.
+ if (
+ expressionType === 'JSXExpressionContainer' &&
+ // @ts-expect-error: unsure why it says expression does not exist on type 'JSXAttribute'.
+ propToRemove?.value?.expression.value === undefined
+ ) {
+ return propToRemove.name.name;
+ }
+
+ if (isRemovingBoolean && isBoolean) {
+ // edge case if prop existence implies truthiness. e.g.
+ if (propToRemove.value === null) {
+ return 'true';
}
+
+ // @ts-expect-error: unsure why it says expression does not exist on type 'JSXAttribute'.
+ propToRemoveValue = propToRemove?.value?.expression.value;
+ return propToRemoveValue.toString();
}
if (isString) {
diff --git a/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-boolean.input.tsx b/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-boolean.input.tsx
index af5742a12c..6d0197d024 100644
--- a/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-boolean.input.tsx
+++ b/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-boolean.input.tsx
@@ -8,7 +8,7 @@ const Child = (props: any) => {
return Testing {props.children}
;
};
-export const App = () => {
+export const App = (disabled?: boolean) => {
const props = {
randomProp: 'value',
};
@@ -37,6 +37,7 @@ export const App = () => {
+
>
);
};
diff --git a/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-boolean.output.tsx b/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-boolean.output.tsx
index 3969dd44f6..b78c95364d 100644
--- a/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-boolean.output.tsx
+++ b/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-boolean.output.tsx
@@ -8,7 +8,7 @@ const Child = (props: any) => {
return Testing {props.children}
;
};
-export const App = () => {
+export const App = (disabled?: boolean) => {
const props = {
randomProp: 'value',
};
@@ -35,9 +35,11 @@ export const App = () => {
- {/* Please update manually */}
+ {/* Please manually update from prop: disabled to prop: state */}
+ {/* Please manually update from prop: disabled to prop: state */}
+
>
);
};
diff --git a/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-string.input.tsx b/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-string.input.tsx
index f4f142b9f0..f3f7e03d39 100644
--- a/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-string.input.tsx
+++ b/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-string.input.tsx
@@ -8,7 +8,7 @@ const Child = (props: any) => {
return Testing {props.children}
;
};
-export const App = () => {
+export const App = (propToRemove?: string) => {
const props = {
randomProp: 'value',
};
@@ -38,6 +38,7 @@ export const App = () => {
+
>
diff --git a/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-string.output.tsx b/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-string.output.tsx
index 5b3b10dd72..4954b06442 100644
--- a/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-string.output.tsx
+++ b/tools/codemods/src/utils/transformations/consolidateJSXAttributes/tests/consolidate-jsx-attributes-string.output.tsx
@@ -8,14 +8,14 @@ const Child = (props: any) => {
return Testing {props.children}
;
};
-export const App = () => {
+export const App = (propToRemove?: string) => {
const props = {
randomProp: 'value',
};
const Test = () => {
return (
- /* Please update manually */
+ /* Please manually update from prop: propToRemove to prop: propToUpdate */
);
};
@@ -23,7 +23,7 @@ export const App = () => {
const TestTwo = () => {
return (
<>
- {/* Please update manually */}
+ {/* Please manually update from prop: propToRemove to prop: propToUpdate */}
>
);
@@ -39,10 +39,12 @@ export const App = () => {
- {/* Please update manually */}
+ {/* Please manually update from prop: propToRemove to prop: propToUpdate */}
+ {/* Please manually update from prop: propToRemove to prop: propToUpdate */}
+
>
diff --git a/tools/codemods/src/utils/transformations/index.ts b/tools/codemods/src/utils/transformations/index.ts
index 4061074a50..158d4ffb29 100644
--- a/tools/codemods/src/utils/transformations/index.ts
+++ b/tools/codemods/src/utils/transformations/index.ts
@@ -1,7 +1,15 @@
+export {
+ addJSXAttributes,
+ type AddJSXAttributesType,
+} from './addJSXAttributes';
export {
consolidateJSXAttributes,
type ConsolidateJSXAttributesOptions,
-} from './consolidateJSXAttributes/consolidateJSXAttributes';
+} from './consolidateJSXAttributes';
+export {
+ removeJSXAttributes,
+ type RemoveJSXAttributesType,
+} from './removeJSXAttributes';
export {
replaceJSXAttributes,
type ReplaceJSXAttributesType,
diff --git a/tools/codemods/src/utils/transformations/removeJSXAttributes/index.ts b/tools/codemods/src/utils/transformations/removeJSXAttributes/index.ts
new file mode 100644
index 0000000000..bee73ea42f
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/removeJSXAttributes/index.ts
@@ -0,0 +1,4 @@
+export {
+ removeJSXAttributes,
+ RemoveJSXAttributesType,
+} from './removeJSXAttributes';
diff --git a/tools/codemods/src/utils/transformations/removeJSXAttributes/removeJSXAttributes.ts b/tools/codemods/src/utils/transformations/removeJSXAttributes/removeJSXAttributes.ts
new file mode 100644
index 0000000000..472917f279
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/removeJSXAttributes/removeJSXAttributes.ts
@@ -0,0 +1,70 @@
+import type { ASTNode, ASTPath, JSXAttribute, JSXElement } from 'jscodeshift';
+import type core from 'jscodeshift';
+
+import { MIGRATOR_ERROR } from '../../../constants';
+import { insertJSXComment } from '../../jsx';
+
+export interface RemoveJSXAttributesType {
+ /**
+ * A reference to the jscodeshift library
+ */
+ j: core.JSCodeshift;
+
+ /**
+ * The element(component) to transform
+ */
+ element: ASTPath;
+
+ /**
+ * The name of the attribute that will be removed from the element
+ */
+ propName: string;
+}
+
+/**
+ * `removeJSXAttributes` can remove a value of an attribute(prop).
+ *
+ * e.g:
+ * ```tsx
+ * propName: prop
+ *
+ * Before:
+ *
+ * After:
+ *
+ * ```
+ */
+export function removeJSXAttributes({
+ j,
+ element,
+ propName,
+}: RemoveJSXAttributesType) {
+ const allAttributes = element.value.openingElement?.attributes;
+
+ const hasSpreadOperator = allAttributes?.some(
+ (attr: ASTNode) => attr.type !== 'JSXAttribute',
+ );
+
+ if (hasSpreadOperator) {
+ insertJSXComment(
+ j,
+ element,
+ `${MIGRATOR_ERROR.manualRemove} prop: ${propName}`,
+ );
+ return;
+ }
+
+ const jsxAttributes = allAttributes?.filter(
+ (attr: ASTNode) =>
+ attr.type === 'JSXAttribute' && attr.name.name === propName,
+ );
+
+ if (!jsxAttributes) return;
+
+ jsxAttributes.forEach(attr => {
+ const jsxAttribute = attr as JSXAttribute;
+
+ jsxAttribute.name.name = '';
+ jsxAttribute.value = null;
+ });
+}
diff --git a/tools/codemods/src/utils/transformations/removeJSXAttributes/tests/remove-component-prop.input.tsx b/tools/codemods/src/utils/transformations/removeJSXAttributes/tests/remove-component-prop.input.tsx
new file mode 100644
index 0000000000..55518d6b61
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/removeJSXAttributes/tests/remove-component-prop.input.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+
+const MyComponent = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const Child = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const spreadProps = {
+ prop: true,
+};
+
+export const App = () => {
+ return (
+ <>
+
+ Hello
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/utils/transformations/removeJSXAttributes/tests/remove-component-prop.output.tsx b/tools/codemods/src/utils/transformations/removeJSXAttributes/tests/remove-component-prop.output.tsx
new file mode 100644
index 0000000000..0e29802cdd
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/removeJSXAttributes/tests/remove-component-prop.output.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+
+const MyComponent = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const Child = (props: any) => {
+ return Testing {props.children}
;
+};
+
+const spreadProps = {
+ prop: true,
+};
+
+export const App = () => {
+ return (
+ <>
+
+ Hello
+
+
+ {/* Please manually remove prop: prop */}
+
+
+
+
+
+ >
+ );
+};
diff --git a/tools/codemods/src/utils/transformations/removeJSXAttributes/tests/transform.spec.ts b/tools/codemods/src/utils/transformations/removeJSXAttributes/tests/transform.spec.ts
new file mode 100644
index 0000000000..e05a271098
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/removeJSXAttributes/tests/transform.spec.ts
@@ -0,0 +1,21 @@
+import { transformTest } from '../../../tests/transformTest';
+
+const transform = 'add-jsx-attributes';
+
+const tests = [
+ {
+ name: 'remove-component-prop',
+ options: {
+ componentName: 'MyComponent',
+ propName: 'prop',
+ },
+ },
+];
+
+for (const test of tests) {
+ transformTest(__dirname, {
+ fixture: test.name,
+ transform,
+ options: test.options,
+ });
+}
diff --git a/tools/codemods/src/utils/transformations/removeJSXAttributes/transform.ts b/tools/codemods/src/utils/transformations/removeJSXAttributes/transform.ts
new file mode 100644
index 0000000000..88e91b6577
--- /dev/null
+++ b/tools/codemods/src/utils/transformations/removeJSXAttributes/transform.ts
@@ -0,0 +1,42 @@
+import type { API, FileInfo } from 'jscodeshift';
+
+import {
+ removeJSXAttributes,
+ RemoveJSXAttributesType,
+} from './removeJSXAttributes';
+
+type TransformerOptions = RemoveJSXAttributesType & { componentName: string };
+
+/**
+ * Example transformer function to remove a component prop value
+ *
+ * @param file the file to transform
+ * @param jscodeshiftOptions an object containing at least a reference to the jscodeshift library
+ * @param options an object containing options to pass to the transform function
+ * @returns Either the modified file or the original file
+ */
+export default function transformer(
+ file: FileInfo,
+ { jscodeshift: j }: API,
+ options: TransformerOptions,
+) {
+ const { propName, componentName } = options;
+
+ const source = j(file.source);
+
+ // Check if the element is on the page
+ const elements = source.findJSXElements(componentName);
+
+ // If there are no elements then return the original file
+ if (elements.length === 0) return file.source;
+
+ elements.forEach(element => {
+ removeJSXAttributes({
+ j,
+ element,
+ propName,
+ });
+ });
+
+ return source.toSource();
+}
diff --git a/tools/codemods/transform.config.js b/tools/codemods/transform.config.js
index 0146873f1f..21557efd42 100644
--- a/tools/codemods/transform.config.js
+++ b/tools/codemods/transform.config.js
@@ -5,8 +5,6 @@ function resolve(transform) {
module.exports = {
presets: {
- 'consolidate-props': resolve('consolidate-props'),
- 'rename-component-prop': resolve('rename-component-prop'),
- 'update-component-prop-value': resolve('update-component-prop-value'),
+ 'popover-v12': resolve('popover-v12'),
},
};
diff --git a/tools/codemods/tsconfig.json b/tools/codemods/tsconfig.json
index 05778289f1..8355aef68a 100644
--- a/tools/codemods/tsconfig.json
+++ b/tools/codemods/tsconfig.json
@@ -6,14 +6,15 @@
"rootDir": "src",
"baseUrl": ".",
"paths": {
- "@leafygreen-ui/icon/dist/*": ["../icon/src/generated/*"],
- "@leafygreen-ui/*": ["../*/src"]
+ "@lg-tools/*": [
+ "../*/src"
+ ]
}
},
"include": [
"src/**/*"
],
- "exclude": ["**/*.spec.*", "**/*.story.*"],
+ "exclude": ["**/*.input.*", "**/*.output.*", "**/*.spec.*", "**/*.stories.*"],
"references": [
]
}
diff --git a/tools/validate/src/dependencies/config.ts b/tools/validate/src/dependencies/config.ts
index bf154010d1..264ed515ae 100644
--- a/tools/validate/src/dependencies/config.ts
+++ b/tools/validate/src/dependencies/config.ts
@@ -38,6 +38,7 @@ export const ignoreFilePatterns: Array = [
/.*package.json?/,
/.*README.md/,
/.*CHANGELOG.md/,
+ /.*.(input|output).(t|j)sx?/,
];
/**
@@ -73,6 +74,15 @@ export const externalDependencies = [
'*-lint*',
];
+/**
+ * These are directories that should be ignored when running depcheck
+ */
+export const patternsToIgnore = [
+ 'tools/codemods/src/codemods/*/tests',
+ 'tools/codemods/src/utils/transformations/*/tests',
+];
+
export const depcheckOptions: depcheck.Options = {
ignoreMatches: externalDependencies,
+ ignorePatterns: patternsToIgnore,
};
diff --git a/yarn.lock b/yarn.lock
index bd68b1d6a9..7df59468d6 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3352,6 +3352,21 @@
dependencies:
"@floating-ui/utils" "^0.2.1"
+"@floating-ui/core@^1.6.0":
+ version "1.6.7"
+ resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.7.tgz#7602367795a390ff0662efd1c7ae8ca74e75fb12"
+ integrity sha512-yDzVT/Lm101nQ5TCVeK65LtdN7Tj4Qpr9RTXJ2vPFLqtLxwOrpoxAHAJI8J3yYWUc40J0BDBheaitK5SJmno2g==
+ dependencies:
+ "@floating-ui/utils" "^0.2.7"
+
+"@floating-ui/dom@^1.0.0":
+ version "1.6.10"
+ resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.10.tgz#b74c32f34a50336c86dcf1f1c845cf3a39e26d6f"
+ integrity sha512-fskgCFv8J8OamCmyun8MfjB1Olfn+uZKjOKZ0vhYF3gRmEUXcGOjxWL8bBr7i4kIuPZ2KD2S3EUIOxnjC8kl2A==
+ dependencies:
+ "@floating-ui/core" "^1.6.0"
+ "@floating-ui/utils" "^0.2.7"
+
"@floating-ui/dom@^1.6.1":
version "1.6.3"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.3.tgz#954e46c1dd3ad48e49db9ada7218b0985cee75ef"
@@ -3367,11 +3382,37 @@
dependencies:
"@floating-ui/dom" "^1.6.1"
+"@floating-ui/react-dom@^2.1.2":
+ version "2.1.2"
+ resolved "https://artifactory.corp.mongodb.com/artifactory/api/npm/npm/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31"
+ integrity sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==
+ dependencies:
+ "@floating-ui/dom" "^1.0.0"
+
+"@floating-ui/react@^0.26.28":
+ version "0.26.28"
+ resolved "https://artifactory.corp.mongodb.com/artifactory/api/npm/npm/@floating-ui/react/-/react-0.26.28.tgz#93f44ebaeb02409312e9df9507e83aab4a8c0dc7"
+ integrity sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==
+ dependencies:
+ "@floating-ui/react-dom" "^2.1.2"
+ "@floating-ui/utils" "^0.2.8"
+ tabbable "^6.0.0"
+
"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1":
version "0.2.1"
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2"
integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==
+"@floating-ui/utils@^0.2.7":
+ version "0.2.7"
+ resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.7.tgz#d0ece53ce99ab5a8e37ebdfe5e32452a2bfc073e"
+ integrity sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==
+
+"@floating-ui/utils@^0.2.8":
+ version "0.2.8"
+ resolved "https://artifactory.corp.mongodb.com/artifactory/api/npm/npm/@floating-ui/utils/-/utils-0.2.8.tgz#21a907684723bbbaa5f0974cf7730bd797eb8e62"
+ integrity sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==
+
"@humanwhocodes/config-array@^0.11.10":
version "0.11.10"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2"
@@ -7591,7 +7632,7 @@ camelcase@^7.0.0:
caniuse-lite@^1.0.30001406, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001587:
version "1.0.30001674"
- resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001674.tgz#eb200a716c3e796d33d30b9c8890517a72f862c8"
+ resolved "https://artifactory.corp.mongodb.com/artifactory/api/npm/npm/caniuse-lite/-/caniuse-lite-1.0.30001674.tgz#eb200a716c3e796d33d30b9c8890517a72f862c8"
integrity sha512-jOsKlZVRnzfhLojb+Ykb+gyUSp9Xb57So+fAiFlLzzTKpqg8xxSav0e40c8/4F/v9N8QSvrRRaLeVzQbLqomYw==
case-sensitive-paths-webpack-plugin@^2.4.0:
@@ -10719,7 +10760,7 @@ istanbul-reports@^3.1.3:
jackspeak@^2.0.3:
version "2.1.1"
- resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.1.1.tgz#2a42db4cfbb7e55433c28b6f75d8b796af9669cd"
+ resolved "https://artifactory.corp.mongodb.com/artifactory/api/npm/npm/jackspeak/-/jackspeak-2.1.1.tgz#2a42db4cfbb7e55433c28b6f75d8b796af9669cd"
integrity sha512-juf9stUEwUaILepraGOWIJTLwg48bUnBmRqd2ln2Os1sW987zeoj/hzhbvRB95oMuS2ZTpjULmdwHNX4rzZIZw==
dependencies:
cliui "^8.0.1"
@@ -14914,6 +14955,11 @@ tabbable@^5.3.3:
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf"
integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA==
+tabbable@^6.0.0:
+ version "6.2.0"
+ resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97"
+ integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==
+
tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0"
@@ -15988,6 +16034,7 @@ wrap-ansi@^6.2.0:
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
+ name wrap-ansi-cjs
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==