UNPKG

@atlaskit/popup

Version:

A popup displays brief content in an overlay.

480 lines (479 loc) • 20.1 kB
import { type ComponentType, type CSSProperties, type Dispatch, type PropsWithChildren, type default as React, type ReactNode, type Ref, type SetStateAction } from 'react'; import { type StrictXCSSProp } from '@atlaskit/css'; import type { Modifier, Placement, PopperChildrenProps } from '@atlaskit/popper/main'; export interface TriggerAriaProps { /** * Identifies the popup element that the trigger controls. * Should match the `id` of the popup content for screen readers to understand the relationship. */ 'aria-controls'?: string; /** * Announces to assistive technology whether the popup is currently open or closed. */ 'aria-expanded': boolean; /** * Informs assistive technology that this element triggers a popup. * With `platform-dst-top-layer`, it is not set when the popup has no `role`. */ 'aria-haspopup'?: boolean | 'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'; } export interface TriggerProps extends TriggerAriaProps { /** * React ref that will be attached to the trigger element. */ ref: Ref<any>; 'data-ds--level'?: string; } export type PopupRef = HTMLDivElement | null; export type TriggerRef = HTMLElement | HTMLButtonElement | null; /** * Roles whose pattern lets the author choose the initial focus target. * Only a popup with one of these roles gets `setInitialFocusRef`. */ export type TInitialFocusRole = 'dialog' | 'alertdialog'; /** * Args for `content` (and the `PopupContent` `children` render prop) for every popup. */ export interface ContentProps { /** * This will reposition the popup if any of the content has changed. * This is useful when positions change, and the popup wasn't aware. */ update: PopperChildrenProps['update']; /** * Passed through from the parent popup. */ isOpen: boolean; /** * Passed through from the parent popup. */ onClose?: BaseProps['onClose']; } /** * Args for `content` (and the `PopupContent` `children` render prop) when `role` is * `'dialog'` or `'alertdialog'`. */ export interface DialogContentProps extends ContentProps { /** * Choose the element that gets focus when the dialog opens, in place of the first * focusable element (for example the least destructive action, or a heading with * `tabIndex={-1}`). Only for `role="dialog"` and `role="alertdialog"`: other roles * have a fixed focus rule (a menu focuses its first item), and a popup with no role * must not take focus. * * The specified element needs to be available in the DOM as soon as the popup is opened, otherwise it * will not be focused upon. This means it will not work for content that renders into the popup content * after it has already opened (e.g. async or lazy loaded content). * * Pass it as the element's `ref`, or call it in a layout effect. With * `platform-dst-top-layer` on, a call from a `useEffect` is too late. */ setInitialFocusRef: Dispatch<SetStateAction<HTMLElement | null>>; } /** * Props passed to a custom `popupComponent`, which renders as the popup container in * place of the default surface. * * **Contract on the top-layer path (`platform-dst-top-layer`).** The container is * the first child of a `display: flex` popover host whose `& > *` rule sets * `flex-grow: 1`, `min-inline-size: 0` and `min-block-size: 0`. A size cap * (`shouldFitViewport`, or the always-on viewport backstop) reaches the content only * through that first child, so a container has to: * * 1. Render exactly one in-flow root. A second in-flow root becomes a second flex * item, laid out in a row beside the first. * 2. Keep that root in flow, not `position: absolute` or `fixed`. An out-of-flow root * is not a flex item, so `flex-grow` and cross-axis stretch never reach it. * 3. Give the root ITSELF a non-`visible` computed `overflow`, so it is the scroll * container. The `min-*-size: 0` reset caps the root's box whatever its * `overflow` is; a `visible` root's content spills out of a correctly-sized box * instead of scrolling. * 4. Leave the block size `auto`, so cross-axis stretch can size it. * 5. Not pin the inline size above the cap, and not set `flex-shrink: 0`. * 6. Put the `overflow` on the root, not on a descendant. `& > *` reaches one level. * * `shouldFitViewport` is forwarded on both paths, so the container can own its * `overflow` (several in-tree containers key an `overflow: auto` branch off it). * * `shouldRenderToParent` is NOT forwarded on the top-layer path, by decision: * everything renders in the top layer, so the container receives `undefined`, and a * `(!shouldRenderToParent || shouldFitViewport)` branch reads as "fitting applies", * which is the intended reading there. */ export interface PopupComponentProps { /** * Children passed through by the parent popup. */ children: ReactNode; /** * Placement passed through by the parent popup. */ 'data-placement': Placement; /** * Test ID passed through by the parent popup. */ 'data-testid'?: string; /** * ID passed through by the parent popup. */ id?: string; /** * Style that should be assigned to the root element. */ style: CSSProperties; xcss?: StrictXCSSProp<'padding' | 'paddingInline' | 'paddingBlock' | 'width', never>; /** * Tab index passed through by the parent popup. */ tabIndex: number | undefined; /** * Controls whether the popup is rendered inline within its parent component or in a portal at the document root. * `true` renders the popup in the DOM node closest to the trigger; focus is not trapped inside the element. * `false` renders the popup in React.Portal and focus is trapped inside the element. * The default is `false`. */ shouldRenderToParent?: boolean; /** * This fits the popup width to its parent's width. * When set to `true`, the trigger and popup elements will be wrapped in a `div` with `position: relative`. * The popup will be rendered as a sibling to the trigger element, and will be full width. * The default is `false`. */ shouldFitContainer?: boolean; /** * Determines if the popup will have a `max-width` and `max-height` set to * constrain it to the viewport. */ shouldFitViewport?: boolean; /** * The "default" appearance is used for standard popups. * The "UNSAFE_modal-below-sm" appearance makes the popup appear as a modal when the viewport is smaller than "sm". * The appearance should only be set to "UNSAFE_modal-below-sm" if the provided popup includes a close button. */ appearance?: 'default' | 'UNSAFE_modal-below-sm'; /** * Class name to apply to the popup container element. */ className?: string; /** * Boolean to indicate if the reference element is hidden. */ isReferenceHidden?: boolean; /** * Use this to set the accessibility role for the popup. * We strongly recommend using only `menu` or `dialog`. */ role?: string; } interface BaseProps { /** * The "default" appearance is used for standard popups. * The "UNSAFE_modal-below-sm" appearance makes the popup appear as a modal when the viewport is smaller than "sm". */ appearance?: 'default' | 'UNSAFE_modal-below-sm'; /** * Bounded style overrides. */ xcss?: StrictXCSSProp<'padding' | 'paddingInline' | 'paddingBlock' | 'paddingInlineStart' | 'paddingInlineEnd' | 'paddingBlockStart' | 'paddingBlockEnd' | 'width', never>; /** * Use this to either show or hide the popup. * When set to `false` the popup will not render anything to the DOM. */ isOpen: boolean; /** * ID that is assigned to the popup container element. */ id?: string; /** * The distance the popup should be offset from the reference in the format of [along, away] (units in px). * The default is `[0, 8]`, which means the popup will be `8px` away from the edge of the reference specified * by the `placement` prop. * * @private * Note: when `platform-dst-top-layer` is enabled, both the `along` and `away` * values are passed through to the top-layer placement (via `fromLegacyPlacement`), * matching the legacy popper-js behaviour. */ offset?: [number, number]; /** * Placement of where the popup should be displayed relative to the trigger element. * The default is `"auto"`. */ placement?: Placement; /** * This is a list of backup placements for the popup to try. * When the preferred placement doesn't have enough space, * the modifier will test the ones provided in the list, and use the first suitable one. * If no fallback placements are suitable, it reverts back to the original placement. * * @private * @deprecated No-op when `platform-dst-top-layer` is enabled — CSS Anchor Positioning * generates fallbacks automatically via `position-try-fallbacks`. */ fallbackPlacements?: Placement[]; /** * The boundary element that the popup will check for overflow. * The default is `"clippingParents"` which are parent scroll containers, * but can be set to any element. * * @private * @deprecated No-op when `platform-dst-top-layer` is enabled — the viewport * is the natural boundary for top-layer elements. */ boundary?: 'clippingParents' | HTMLElement; /** * The root boundary that the popup will check for overflow. * The default is `"viewport"` but it can be set to `"document"`. * * @private * @deprecated No-op when `platform-dst-top-layer` is enabled — the viewport * is the natural boundary for top-layer elements. */ rootBoundary?: 'viewport' | 'document'; /** * Allows the popup to be placed on the opposite side of its trigger if it doesn't fit in the viewport. * The default is `true`. * * @private * @deprecated No-op when `platform-dst-top-layer` is enabled — CSS Anchor Positioning * handles flipping natively via `position-try-fallbacks`. */ shouldFlip?: boolean; /** * A `testId` prop is provided for specified elements, * which is a unique string that appears as a data attribute `data-testid` in the rendered code, * serving as a hook for automated tests. */ testId?: string; /** * Handler that is called when the popup wants to close itself. * This can happen when: * - the user clicks away from the popup * - the user presses the escape key * - the popup is closed programatically. In this case, the `event` argument will be `null`. * * You'll want to use this to set open state accordingly, and then pump it back into the `isOpen` prop. */ onClose?( /** * The event that triggered the close. * The argument value will be `null` when the popup is closed programatically and has no corresponding event. */ event: Event | React.MouseEvent | React.KeyboardEvent | null, currentLevel?: number | any): void; /** * The element that is shown when `isOpen` prop is `true`. * The result of the `content` prop will be placed as children here. * The default is an element with an elevation of `e200` with _no padding_. */ popupComponent?: ComponentType<PopupComponentProps> | React.ForwardRefExoticComponent<React.PropsWithoutRef<PopupComponentProps> & React.RefAttributes<HTMLDivElement>>; /** * This controls whether the popup takes focus when opening. * This changes the `popupComponent` component tabIndex to `null`. * The default is `true`. * * @deprecated No effect when `platform-dst-top-layer` is enabled — focus * follows `role`. `dialog`, `alertdialog`, `menu` and * `listbox` move focus into the popup on open; no role, or any other role, * keeps focus where it is. To keep focus where it is, do not use one of * those four roles. This prop will be removed when the flag is cleaned up. */ autoFocus?: boolean; /** * For asynchronous Entry Point wrappers only. Disables Popup's initial * autofocus so the wrapper can apply focus once its content is ready. * This is gated by `platform_dst-automated_focus` and must not be used by * Makers or ordinary Popup consumers. */ UNSAFE__disableAutoFocusForAsyncWrapper?: boolean; /** * This controls if the event which handles clicks outside the popup is be bound with * `capture: true`. */ shouldUseCaptureOnOutsideClick?: boolean; /** * Allows consumers to ignore specific close events, for example when an external overlay * should be treated as part of the popup interaction. */ shouldIgnoreCloseEvent?: (event: Event | React.MouseEvent | React.KeyboardEvent) => boolean; /** * The root element where the popup should be rendered. * Defaults to `false`. * * @private * @deprecated No-op when `platform-dst-top-layer` is enabled — content always * renders in the browser's top layer. */ shouldRenderToParent?: boolean; /** * This fits the popup width to its parent's width. * When set to `true`, the trigger and popup elements will be wrapped in a `div` with `position: relative`. * The popup will be rendered as a sibling to the trigger element, and will be full width. * The default is `false`. */ shouldFitContainer?: boolean; /** * This makes the popup close on Tab key press. It will only work when `shouldRenderToParent` is `true`. * The default is `false`. * * @private * @deprecated No-op when `platform-dst-top-layer` is enabled — focus trapping * is role-based in the top-layer path. */ shouldDisableFocusLock?: boolean; /** * This determines whether the popup trigger will be focused when the popup content closes. * The default is `true`. */ shouldReturnFocus?: boolean; /** * This controls the positioning strategy to use. Can vary between `absolute` and `fixed`. * The default is `fixed`. * * @private * @deprecated No-op when `platform-dst-top-layer` is enabled — CSS Anchor Positioning * replaces Popper's positioning strategy. */ strategy?: 'absolute' | 'fixed'; /** * Refers to an `aria-label` attribute. Sets an accessible name for the popup to announce it to users of assistive technology. * Usage of either this, or the `titleId` attribute is strongly recommended. */ label?: string; /** * Id referenced by the popup `aria-labelledby` attribute. * Usage of either this, or the `label` attribute is strongly recommended. */ titleId?: string; /** * Additional modifiers and modifier overwrites. * for more details - https://popper.js.org/docs/v1/#modifiers */ modifiers?: Partial<Modifier<string, object>>[]; /** * Determines if the popup will have a `max-width` and `max-height` set to * constrain it to the viewport. */ shouldFitViewport?: boolean; } interface InternalPopupProps extends BaseProps { /** * Render props used to anchor the popup to your content. * Make this an interactive element, * such as an `@atlaskit/button` component. */ trigger: (props: TriggerProps) => React.ReactNode; /** * How the popup dismisses when `platform-dst-top-layer` is enabled. Default `"auto"`. * * - `"auto"`: the browser light-dismisses it. A press outside it (including on the trigger) or * Escape closes it. * - `"manual"`: the browser never light-dismisses it, so a press on the trigger does not close * it. With `onClose`, Popup calls it on a press outside the popup and the trigger, or on * Escape. Without `onClose`, only `isOpen` closes it. * * **Warning:** a `"manual"` popup is outside the browser's auto popover stack. Opening another * auto popover (a menu, popup or tooltip) does not close it, it closes no other popover, and a * nested auto popover opened from outside it does not treat it as an ancestor. Use `"manual"` * only when a press on the trigger must not close the popup (for example a hover popup that pins * on click, or a search field that keeps its results open), or when it must stay open until a * state change (for example a field error). * * No effect when the flag is disabled: the legacy popup already ignores presses on the trigger * and closes only through `onClose`. */ mode?: 'auto' | 'manual'; /** * Z-index that the popup should be displayed in. * This is passed to the portal component. * The default is 400. * * @private * @deprecated No-op when `platform-dst-top-layer` is enabled — the browser's * top layer manages stacking without z-index. */ zIndex?: number; } type TFitContainer = { shouldFitContainer?: false; } | { shouldFitContainer: true; shouldRenderToParent?: true; strategy?: 'absolute'; }; type TAnyRoleContent = { /** * Use this to set the accessibility role for the popup. * We strongly recommend using only `menu` or `dialog`. * Must be used along with `label` or `titleId`. * * Only `role="dialog"` and `role="alertdialog"` (as literal types) give `content` * the `setInitialFocusRef` setter. */ role?: string; /** * Render props for content that is displayed inside the popup. */ content: (props: ContentProps) => React.ReactNode; }; type TDialogRoleContent = { /** * Use this to set the accessibility role for the popup. * Must be used along with `label` or `titleId`. * Required. `undefined` lets a role gated on `platform-dst-top-layer` keep the setter. */ role: TInitialFocusRole | undefined; /** * Render props for content that is displayed inside the popup. * A dialog popup also gets `setInitialFocusRef`. */ content: (props: DialogContentProps) => React.ReactNode; }; /** * Props for a popup with any role, or no role. `content` does not get `setInitialFocusRef`. */ export type PopupProps = InternalPopupProps & TFitContainer & TAnyRoleContent; /** * Props for a popup with `role="dialog"` or `role="alertdialog"`. * `content` gets `setInitialFocusRef`. */ export type DialogPopupProps = InternalPopupProps & TFitContainer & TDialogRoleContent; /** * The `Popup` component type. It has two call signatures: TypeScript tries the dialog * signature first, so only a role that is always `'dialog'` or `'alertdialog'` gets * `setInitialFocusRef`. Any other role (a `string`, a conditional role, or none) uses * the second signature. `ComponentProps<typeof Popup>` reads the last signature, * which is `PopupProps`. */ export interface TPopup { (props: DialogPopupProps): React.ReactNode; (props: PopupProps): React.ReactNode; } export interface PopperWrapperProps extends BaseProps { zIndex?: number; triggerRef: TriggerRef; role?: string; content: (props: DialogContentProps) => React.ReactNode; } export type CloseManagerHook = Pick<PopupProps, 'isOpen' | 'onClose' | 'shouldIgnoreCloseEvent'> & { popupRef: PopupRef; triggerRef: TriggerRef; shouldUseCaptureOnOutsideClick?: boolean; shouldCloseOnTab?: boolean; shouldDisableFocusTrap: boolean; shouldRenderToParent?: boolean; autoFocus: boolean; }; export type FocusManagerHook = { initialFocusRef: HTMLElement | null; popupRef: PopupRef; shouldCloseOnTab?: boolean; triggerRef: TriggerRef; autoFocus: boolean; shouldDisableFocusTrap: boolean; shouldReturnFocus: boolean; shouldRenderToParent?: boolean; }; export type RepositionOnUpdateProps = PropsWithChildren<{ update: PopperChildrenProps['update']; }>; export {};