UNPKG

@atlaskit/select

Version:

Select allows users to make a single selection or multiple selections from a list of options.

342 lines (329 loc) 12.9 kB
/* popup-select-top-layer.tsx generated by @compiled/babel-plugin v3.0.2 */ import _extends from "@babel/runtime/helpers/extends"; import "./popup-select-top-layer.compiled.css"; import * as React from 'react'; import { ax, ix } from "@compiled/react/runtime"; import { Fragment, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { bind } from 'bind-event-listener'; import { mergeStyles } from '@atlaskit/react-select/styles'; import { fromLegacyPlacement } from '@atlaskit/top-layer/placement-map/index'; import { Popover } from '@atlaskit/top-layer/popover/popover'; import { PopoverSurface } from '@atlaskit/top-layer/popover-surface'; import { useAnchoredPopover } from '@atlaskit/top-layer/use-anchored-popover'; import { usePopoverId } from '@atlaskit/top-layer/use-popover-id'; import Select from '../select'; import { defaultComponents } from './components'; import { DummyControl } from './dummy-control'; const menuDialogStyles = null; /** * Top-layer implementation of PopupSelect. * * Replaces the legacy PopupSelect rendering pipeline * (react-popper + createPortal + react-focus-lock + @atlaskit/layering + z-index) * with the native Popover API + CSS Anchor Positioning via @atlaskit/top-layer. * * Gated behind the `platform-dst-top-layer` feature flag. */ function PopupSelectTopLayerInner({ // PopupSelect-specific props. closeMenuOnSelect = true, shouldCloseMenuOnTab = true, footer, searchThreshold = 5, maxMenuWidth = 440, minMenuWidth = 220, maxMenuHeight = 300, target, label, placeholder, testId, isOpen: controlledIsOpen, defaultIsOpen, spacing: _spacing, options = [], isSearchable = true, components: consumerComponents, styles: consumerStyles, onChange, onKeyDown, // Lifecycle callbacks. onOpen, onClose, onMenuOpen, onMenuClose, // No-op props in top-layer path. // eslint-disable-next-line @typescript-eslint/no-unused-vars popperProps, // eslint-disable-next-line @typescript-eslint/no-unused-vars shouldPreventEscapePropagation: _shouldPreventEscapePropagation, // Remaining props spread to Select. ...selectProps }, forwardedRef) { var _popperProps$placemen, _getLabel; // State. const isControlled = controlledIsOpen !== undefined; const [internalIsOpen, setInternalIsOpen] = useState(isControlled ? Boolean(controlledIsOpen) : Boolean(defaultIsOpen)); const isOpen = isControlled ? Boolean(controlledIsOpen) : internalIsOpen; const selectRef = useRef(null); const triggerRef = useRef(null); const menuRef = useRef(null); const popoverRef = useRef(null); const popoverId = usePopoverId(); // Placement. const legacyPlacement = (_popperProps$placemen = popperProps === null || popperProps === void 0 ? void 0 : popperProps.placement) !== null && _popperProps$placemen !== void 0 ? _popperProps$placemen : 'bottom-start'; // `popup-select` historically defaulted to an `[along, away] = [0, 8]` popper offset // modifier; consumers can override it via `popperProps.modifiers`. Honour both here so // the top-layer rendering matches the legacy popper output. const legacyOffset = useMemo(() => { var _modifiers$find, _modifiers$find$optio; const modifiers = popperProps === null || popperProps === void 0 ? void 0 : popperProps.modifiers; const value = modifiers === null || modifiers === void 0 ? void 0 : (_modifiers$find = modifiers.find(modifier => (modifier === null || modifier === void 0 ? void 0 : modifier.name) === 'offset')) === null || _modifiers$find === void 0 ? void 0 : (_modifiers$find$optio = _modifiers$find.options) === null || _modifiers$find$optio === void 0 ? void 0 : _modifiers$find$optio.offset; if (Array.isArray(value) && value.length === 2) { return [Number(value[0]) || 0, Number(value[1]) || 0]; } return [0, 8]; }, [popperProps === null || popperProps === void 0 ? void 0 : popperProps.modifiers]); const topLayerPlacement = useMemo(() => fromLegacyPlacement({ legacy: legacyPlacement, offset: legacyOffset }), [legacyPlacement, legacyOffset]); useAnchoredPopover({ anchorRef: triggerRef, popoverRef, placement: topLayerPlacement, isOpen }); // Merged components. const mergedComponents = useMemo(() => ({ ...defaultComponents, ...consumerComponents }), [consumerComponents]); // Default styles. const defaultStyles = useMemo(() => ({ groupHeading: provided => ({ ...provided, color: "var(--ds-text-subtlest, #6B6E76)" }) }), []); // Utils. const getItemCount = useCallback(() => options.reduce((count, groupOrOption) => { const group = groupOrOption; if (group.options) { return count + group.options.length; } return count + 1; }, 0), [options]); const showSearchControl = isSearchable && getItemCount() > searchThreshold; const getMaxHeight = useCallback(() => { var _selectRef$current$se; if (!selectRef.current) { return maxMenuHeight; } const controlRef = (_selectRef$current$se = selectRef.current.select) === null || _selectRef$current$se === void 0 ? void 0 : _selectRef$current$se.controlRef; const offsetHeight = showSearchControl && controlRef ? controlRef.offsetHeight : 0; return maxMenuHeight - offsetHeight; }, [maxMenuHeight, showSearchControl]); const getLabel = useCallback(() => { if (label) { return label; } if (typeof placeholder === 'string') { return placeholder; } return undefined; }, [label, placeholder]); // Open / Close. const open = useCallback(() => { if (!isControlled) { setInternalIsOpen(true); } onOpen === null || onOpen === void 0 ? void 0 : onOpen(); onMenuOpen === null || onMenuOpen === void 0 ? void 0 : onMenuOpen(); }, [isControlled, onOpen, onMenuOpen]); const close = useCallback(() => { if (!isControlled) { setInternalIsOpen(false); } onClose === null || onClose === void 0 ? void 0 : onClose(); onMenuClose === null || onMenuClose === void 0 ? void 0 : onMenuClose(); }, [isControlled, onClose, onMenuClose]); /** * For backwards compatibility */ useImperativeHandle(forwardedRef, () => ({ open, close, get selectRef() { return selectRef.current; }, get menuRef() { return menuRef.current; }, get targetRef() { return triggerRef.current; } }), [close, open]); // Sync controlled isOpen. useEffect(() => { if (!isControlled) { return; } if (controlledIsOpen) { onOpen === null || onOpen === void 0 ? void 0 : onOpen(); onMenuOpen === null || onMenuOpen === void 0 ? void 0 : onMenuOpen(); } else { onClose === null || onClose === void 0 ? void 0 : onClose(); onMenuClose === null || onMenuClose === void 0 ? void 0 : onMenuClose(); } // Only react to changes in the controlled value // eslint-disable-next-line react-hooks/exhaustive-deps }, [controlledIsOpen]); // Open the react-select menu when isOpen becomes true. useEffect(() => { if (isOpen && selectRef.current) { var _selectRef$current$se2; (_selectRef$current$se2 = selectRef.current.select) === null || _selectRef$current$se2 === void 0 ? void 0 : _selectRef$current$se2.openMenu('first'); } }, [isOpen]); // Event Handlers. const handleTargetKeyDown = useCallback(event => { if (event.key === 'ArrowDown') { open(); } }, [open]); // Open the menu when the trigger is clicked. Mirrors the legacy // `PopupSelect` global-click listener: consumers are not required to // spread an `onClick`, so we walk up from the event target to the // trigger ref instead. useEffect(() => { if (typeof window === 'undefined') { return; } return bind(window, { type: 'click', listener: event => { const { target } = event; if (!(target instanceof Element)) { return; } const trigger = triggerRef.current; if (!trigger || !trigger.contains(target)) { return; } if (isOpen) { close(); return; } open(); }, options: { capture: true } }); }, [isOpen, open, close]); // Focus restoration on close is handled automatically by Popover // based on the content role (role="dialog" -> auto-restore). const handleOnClose = useCallback(({ reason: _reason }) => { if (isControlled) { // For controlled mode, notify consumer but do not change internal state onClose === null || onClose === void 0 ? void 0 : onClose(); onMenuClose === null || onMenuClose === void 0 ? void 0 : onMenuClose(); return; } close(); }, [close, isControlled, onClose, onMenuClose]); const handleSelectChange = useCallback((value, actionMeta) => { if (closeMenuOnSelect && actionMeta.action !== 'clear') { var _triggerRef$current; close(); // Return focus to trigger after selection (_triggerRef$current = triggerRef.current) === null || _triggerRef$current === void 0 ? void 0 : _triggerRef$current.focus(); } onChange === null || onChange === void 0 ? void 0 : onChange(value, actionMeta); }, [closeMenuOnSelect, close, onChange]); const handleKeyDown = useCallback(event => { const tabEvent = event.key === 'Tab'; if (shouldCloseMenuOnTab && tabEvent) { var _triggerRef$current2; close(); (_triggerRef$current2 = triggerRef.current) === null || _triggerRef$current2 === void 0 ? void 0 : _triggerRef$current2.focus(); } onKeyDown === null || onKeyDown === void 0 ? void 0 : onKeyDown(event); }, [shouldCloseMenuOnTab, close, onKeyDown]); // Select components. // Type assertion needed because defaultComponents uses concrete OptionType/boolean // but Select expects the generic Option/IsMulti parameters. The components are // structurally compatible regardless of the generic parameters. const selectComponentsMerged = useMemo(() => ({ ...mergedComponents, Control: showSearchControl ? mergedComponents.Control : DummyControl }), [mergedComponents, showSearchControl]); const getSelectRef = useCallback(ref => { selectRef.current = ref; }, []); const triggerProps = { ref: node => { triggerRef.current = node; }, // No `onClick` on `triggerProps`: the trigger click is observed via // the capture-phase global `click` listener in the effect above, // matching the legacy `PopupSelect` contract for consumers that do // not spread `triggerProps` onto their target. onKeyDown: handleTargetKeyDown, // Maintain existing aria attribute format for backwards compatibility. // Should technically be 'dialog' instead of 'true', but preserving // legacy behavior. See go/DSP-22283 'aria-haspopup': 'true', 'aria-expanded': isOpen, // Always emit aria-controls referencing the controlled element. // Matches the behavior of `getAriaForTrigger` (see // `@atlaskit/top-layer/get-aria-for-trigger`) and aligns with the // WAI-ARIA spec recommendation. 'aria-controls': popoverId, isOpen }; return /*#__PURE__*/React.createElement(Fragment, null, target === null || target === void 0 ? void 0 : target(triggerProps), /*#__PURE__*/React.createElement(Popover, { ref: popoverRef, id: popoverId, role: "dialog", label: (_getLabel = getLabel()) !== null && _getLabel !== void 0 ? _getLabel : 'Popup select', isOpen: isOpen, onClose: handleOnClose, shouldAnimate: true, placement: topLayerPlacement, testId: testId && `${testId}--content` }, /*#__PURE__*/React.createElement(PopoverSurface, null, /*#__PURE__*/React.createElement("div", { ref: menuRef, // eslint-disable-next-line @atlaskit/ui-styling-standard/enforce-style-prop style: { maxWidth: maxMenuWidth, minWidth: minMenuWidth }, "data-testid": testId && `${testId}--menu`, className: ax(["_2rko1mok _bfhk1bhr _16qs130s"]) }, /*#__PURE__*/React.createElement(Select, _extends({ label: getLabel(), backspaceRemovesValue: false, controlShouldRenderValue: false, isClearable: false, tabSelectsValue: false, placeholder: placeholder, ref: getSelectRef // eslint-disable-next-line @repo/internal/react/no-unsafe-spread-props -- preserving legacy PopupSelect API during migration }, selectProps, { menuRenderMode: "inline", options: options, isSearchable: showSearchControl, styles: mergeStyles(defaultStyles, consumerStyles || {}), maxMenuHeight: getMaxHeight(), components: selectComponentsMerged, onChange: handleSelectChange, onKeyDown: handleKeyDown, testId: testId })), footer)))); } export const PopupSelectTopLayer = /*#__PURE__*/forwardRef(PopupSelectTopLayerInner);