Skip to content

feat: PopoverPanel + Dropdown deprecation#566

Merged
adrienzheng-cb merged 13 commits intomasterfrom
adrien/deprecate-dropdown
Apr 16, 2026
Merged

feat: PopoverPanel + Dropdown deprecation#566
adrienzheng-cb merged 13 commits intomasterfrom
adrien/deprecate-dropdown

Conversation

@adrienzheng-cb
Copy link
Copy Markdown
Contributor

@adrienzheng-cb adrienzheng-cb commented Mar 30, 2026

What changed? Why?

Popover (internal)

  • Replace Popper.js positioning with @floating-ui/react-dom: useFloating, autoUpdate,
    offset (skid/gap), flip + shift + limitShift for fixed placements, and autoPlacement
    for placement values that start with "auto".
  • Wrap the component in forwardRef and merge the consumer ref with the Floating UI
    reference ref (useMergeRefs).
  • Add optional style and className on the subject (trigger) root.

PopoverPanel / PopoverPanelContent

  • Add PopoverPanel: trigger + floating or phone modal layout, content as node or
    (closePopover) => node, optional enableMobileModal, sizing (panelWidth, min/max
    width/height), showOverlay, contentPosition, ref with openPopover/closePopover.
  • Add PopoverPanelContent: animated VStack surface for the panel body.
  • Wire useComponentConfig for PopoverPanel and PopoverPanelContent; register both in
    core/componentConfig.ts.
  • Export from packages/web overlays barrel and new package entry @coinbase/cds-web/popover
    (package.json exports).
  • Add Storybook stories, unit tests, and Docusaurus (docgen, examples, props table,
    styles explorer, sidebar, webMetadata).

useResponsivePanelMaxHeight (renamed from useResponsiveHeight in popover)

  • New module overlays/popover/useResponsivePanelMaxHeight.ts (canonical hook for
    PopoverPanel).
  • Rename the hook to useResponsivePanelMaxHeight; return value is panelMaxHeight
    (was dropdownHeight in the shared logic).
  • Rename the measured bounds argument to panelBounds (was dropdownBounds) for panel
    sizing; keep the same viewport / calc(100vh - …) responsive behavior.
  • Support ResponsiveProp with resolveResponsiveMaxHeight aligned to
    phone/tablet/desktop breakpoints via useBreakpoints.
  • Legacy packages/web/src/dropdown/useResponsiveHeight.ts remains for Dropdown only,
    with the original non-responsive maxHeight API; deprecate in favor of importing
    useResponsivePanelMaxHeight from @coinbase/cds-web/popover (removal v10) and add a
    short comment that new behavior lives under overlays/popover.

Dropdown deprecation

  • Deprecate Dropdown, DropdownContent, DropdownProps-related types, and docs
    Dropdown webMetadata warning: use PopoverPanel / PopoverPanelContent instead
    (expected removal v10).

Other

  • ModalWrapper: when hideOverlay is true, render a transparent fixed VStack that still
    receives overlay press so dismiss works without a visible Overlay.
  • Jest: extend @floating-ui/react-dom mock with limitShift.

Breaking / migration notes for consumers

  • Popover: possible subtle positioning and DOM structure differences vs Popper; new
    ref and subject style/className are additive.
  • Dropdown: deprecated; migrate to PopoverPanel.
  • Popover-adjacent hook: prefer useResponsivePanelMaxHeight from @coinbase/cds-web/popover;
    dropdown/useResponsiveHeight is legacy and deprecated.

UI changes

storybook
docs

Screen.Recording.2026-04-02.at.1.15.52.AM.mov

Testing

How has it been tested?

  • Unit tests
  • Interaction tests
  • Pseudo State tests
  • Manual - Web
  • Manual - Android (Emulator / Device)
  • Manual - iOS (Emulator / Device)

Testing instructions

Illustrations/Icons Checklist

Required if this PR changes files under packages/illustrations/** or packages/icons/**

  • verified visreg changes with Terran (include link to visreg run/approval)
  • all illustration/icons names have been reviewed by Dom and/or Terran

Change management

type=routine
risk=low
impact=sev5

automerge=false

Copy link
Copy Markdown
Contributor

@cb-ekuersch cb-ekuersch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before we can recommend switching to Popover we should at least:

  1. audit Popover to make sure it can handle any/all use cases that Dropdown can. Any storybook stories from Dropdown should be able to be effortlessly migrated to Popover stories
  2. add docsite pages for Popover

@adrienzheng-cb adrienzheng-cb force-pushed the adrien/deprecate-dropdown branch from ca7a9d9 to 1bcca8d Compare April 2, 2026 04:49
@adrienzheng-cb adrienzheng-cb marked this pull request as draft April 2, 2026 04:50
@adrienzheng-cb adrienzheng-cb force-pushed the adrien/deprecate-dropdown branch from 102cbda to 4f73334 Compare April 2, 2026 15:11
@adrienzheng-cb adrienzheng-cb changed the base branch from cds-v9 to master April 2, 2026 15:11
@cb-heimdall
Copy link
Copy Markdown
Collaborator

cb-heimdall commented Apr 2, 2026

✅ Heimdall Review Status

Requirement Status More Info
Reviews 1/1
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 1
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 1
Global minimum 0
Max 1
1
1 if commit is unverified 0
Sum 1
CODEOWNERS ✅ See below

⚠️ Ignored Reviews (2)

Reviewer Reason
cb-ekuersch Old or outdated review
cb-ekuersch Review missing MFA

CODEOWNERS

Code Owner Status Calculation
ui-systems-eng-team 1/1
Denominator calculation
Additional CODEOWNERS Requirement
Show calculation
Sum 0
0
From CODEOWNERS 1
Sum 1

@adrienzheng-cb adrienzheng-cb changed the title chore: deprecate Dropdown feat: PopoverPanel + Dropdown deprecation Apr 2, 2026
@adrienzheng-cb adrienzheng-cb force-pushed the adrien/deprecate-dropdown branch from 5246936 to 5aa2102 Compare April 2, 2026 18:59
@adrienzheng-cb adrienzheng-cb force-pushed the adrien/deprecate-dropdown branch from 5aa2102 to e99745a Compare April 2, 2026 19:00
import React, { memo, useCallback, useMemo } from 'react';
import React, { forwardRef, memo, useCallback, useMemo } from 'react';
import { zIndex } from '@coinbase/cds-common/tokens/zIndex';
import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will replace with mergeRefs after merging into v9

type Placement as FloatingPlacement,
shift,
useFloating,
} from '@floating-ui/react-dom';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

originally replacing with popper with floating UI in popover was done in v9 feature branch, but moved it here since we needed to have PopoverPanel before v9 and Popover is affected

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-breaking since all props are the same

/** Can optionally pass a maxHeight.
* @default 300
*/
maxPanelHeight?: React.CSSProperties['maxHeight'];
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxPanelWidth/Height instead of maxHeight to make it clear to which element dimension is set

onClick={!disableOverlayPress ? onOverlayPress : undefined}
testID="modal-overlay"
/>
) : (
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding this here so Modal without overlay can still be closed when clicking outside

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would moving this to the parent <Box> be a better alternative? Or does that not work?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried that at first, but the problem with that was clicking inside the dialog would also close the modal since the parent Box wraps both the overlay and the dialog.

/**
* @deprecated Import `useResponsiveHeight` from `@coinbase/cds-web/popover` instead. This will be removed in a future major release.
* @deprecationExpectedRemoval v10
*/
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a replacement under popover directory. the replacement handle can handle maxHeight with responsive value

onBlur?: (event?: React.FocusEvent) => void;
/** Callback fired when a mouse down event is fired on the subject */
onMouseDown?: (event: React.MouseEvent) => void;
/**
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added style can className support to Popover

Copy link
Copy Markdown
Contributor

@maximo-macchi-cb maximo-macchi-cb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently reviewed new PopoverPanel components in web. Will finish review tomorrow but saving here just in case.

title: 'Components/Overlay/PopoverPanel',
component: PopoverPanel,
parameters: {
a11y: { test: 'off' },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are a11y tests being disabled? I enabled them locally and in the Storybook UI and CLI all tests pass

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh my bad. I re-enabled it

Comment on lines +52 to +61
/**
* Width of the panel as a percentage string or number converted to pixels.
*/
panelWidth?: PopoverPanelContentBaseProps['width'];
/** Minimum width of the panel as a percentage string or number converted to pixels. */
minPanelWidth?: PopoverPanelContentBaseProps['minWidth'];
/** Maximum width of the panel as a percentage string or number converted to pixels. */
maxPanelWidth?: PopoverPanelContentBaseProps['maxWidth'];
/** Height of the panel as a percentage string or number converted to pixels. */
panelHeight?: PopoverPanelContentBaseProps['height'];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nit: since our other height / width props only are named as "height" and "width" I think it'd be easier for engineers to pick up on by keeping the naming convention consistent

Suggested change
/**
* Width of the panel as a percentage string or number converted to pixels.
*/
panelWidth?: PopoverPanelContentBaseProps['width'];
/** Minimum width of the panel as a percentage string or number converted to pixels. */
minPanelWidth?: PopoverPanelContentBaseProps['minWidth'];
/** Maximum width of the panel as a percentage string or number converted to pixels. */
maxPanelWidth?: PopoverPanelContentBaseProps['maxWidth'];
/** Height of the panel as a percentage string or number converted to pixels. */
panelHeight?: PopoverPanelContentBaseProps['height'];
/**
* Width of the panel as a percentage string or number converted to pixels.
*/
width?: PopoverPanelContentBaseProps['width'];
/** Minimum width of the panel as a percentage string or number converted to pixels. */
minWidth?: PopoverPanelContentBaseProps['minWidth'];
/** Maximum width of the panel as a percentage string or number converted to pixels. */
maxWidth?: PopoverPanelContentBaseProps['maxWidth'];
/** Height of the panel as a percentage string or number converted to pixels. */
height?: PopoverPanelContentBaseProps['height'];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the panel prefix on purpose so it is clear that they define dimensions of the popover panel not the popover trigger.

Comment on lines +180 to +186
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div
className={cx(block ? blockCss : triggerContainerCss, classNames?.triggerContainer)}
onBlur={onBlur}
onClick={disabled ? undefined : onOpen}
style={styles?.triggerContainer}
>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though this is meant for mobile devices, this could still render in a web browser on a desktop where the browser's window width is small, right? Two suggestions for this:

  1. An onKeyDown handler should be added here
  2. A prop for accessibilityRole with default value of "button" should be added to the prop API and used here
Suggested change
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div
className={cx(block ? blockCss : triggerContainerCss, classNames?.triggerContainer)}
onBlur={onBlur}
onClick={disabled ? undefined : onOpen}
style={styles?.triggerContainer}
>
<div
accessibilityRole={accessibilityRole}
className={cx(block ? blockCss : triggerContainerCss, classNames?.triggerContainer)}
onBlur={onBlur}
onClick={disabled ? undefined : onOpen}
// onKeyDown could handle the key event then call onOpen
onKeyDown={disabled ? undefined : onKeyDown}
style={styles?.triggerContainer}
>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the panel already opens when pressing enter while focusing on the children at the moment without onKeyDown and since this component handles the open and close logic internally and does not take onKeyDown prop, we don't need to pass onKeydown here.

for accessibilityRole, the child is most likely a button already, if we add additional role to the trigger wrapper here, then we end up with nested buttons.

I will mention in the docs that they should use always interactive element as the child(trigger) of PopoverPanel

hideOverlay={!showOverlay}
onClick={handleCaptureEvents}
onOverlayPress={onClose}
testID="popover-panel-modal"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make the testID a prop with default of "popover-panel-modal" so customers can configure this as they'd like

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added the testID prop but did not assign the default id because we are actually not using it and since this is a new component so no one else is selecting the component using this id.

Comment on lines +198 to +202
<FocusTrap
onEscPress={onClose}
respectNegativeTabIndex={respectNegativeTabIndex}
restoreFocusOnUnmount={restoreFocusOnUnmount}
>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think adding the following props from FocusTrapBaseProps would be beneficial for customers to configure:

  • disableAutoFocus
  • includeTriggerInFocusTrap
  • focusTabIndexElements
  • autoFocusDelay

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

Comment on lines +65 to +70
background="bg"
borderRadius={400}
className={cx(popoverPanelContentClassName, className)}
elevation={2}
minWidth={minWidth}
overflow="auto"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't these be configurable? At least background, borderRadius, and overflow

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

Copy link
Copy Markdown
Contributor

@cb-ekuersch cb-ekuersch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, but I have a couple of quesitons:

  1. What are the long term plans for the two popover directories: src/overlays/popover and src/popover?
  2. Are we planning to keep supporting Popover on its own? I thought PopoverPanel was meant to completely replace it rather than compose it

PageHeader?: ConfigResolver<PageHeaderBaseProps>;
Pagination?: ConfigResolver<PaginationBaseProps>;
PopoverPanel?: ConfigResolver<PopoverPanelBaseProps>;
PopoverPanelContent?: ConfigResolver<PopoverPanelContentBaseProps>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Customers never explicitly render a PopoverPanelContent component. It is an implementation detail of the content prop. I recommend we don't add "hidden" components to the component theme config

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got it. I'm gonna remove TooltipContent from the component theme config for consistency then.

}

export const Default: Story = {
render: () => <DefaultStory />,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think there is a shorter way to define these, take a moment to review: https://storybook.js.org/blog/storybook-csf3-is-here/

You can have a default export that defines a render and then export objects that change the props/args. Not sure if that setup would help you at all here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed this redundant code
exporting the DefaultStory component would do it.

import { DefaultThemeProvider } from '../../../utils/test';
import { PopoverPanel, type PopoverPanelRef } from '../PopoverPanel';

jest.mock('react-use-measure');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: i wonder if we should just centralize the mock of this if its always necessary to set up, similar to what we did for floating ui

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good suggestion! I will address this in a separate pr since we have a lot of tests that mock this hook.

Comment thread packages/web/src/overlays/index.ts Outdated
export * from './overlay/Overlay';
export * from './popover/Popover';
export * from './popover/PopoverPanel';
export * from './popover/PopoverPanelContent';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we really want to to encourage direct use by putting it in the barrel file? It seems like this component is more an implementation detail; a true sub-component

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch. I removed the PopoverPanelContent and made it strictly internal


export type PopoverPanelBaseProps = Pick<
PopoverBaseProps,
| 'children'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put children on props, not base props

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed!

| 'controlledElementAccessibilityProps'
| 'respectNegativeTabIndex'
> &
SharedProps &
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: put full types first in set

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed!


export type PopoverPanelProps = PopoverPanelBaseProps & {
style?: React.CSSProperties;
styles?: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use styles and classnames helper - maybe try the skill Hunter added to see how it performs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed!

visible: boolean;
};

export const POPOVER_PANEL_MAX_HEIGHT = 300;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it might make more sense to encapsulate this in the module for the max height hook

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up removing the max height hook. it is no longer necessary because floating-ui can already handle that for us.

placement: 'bottom-start',
};

function usePopoverPanelImperativeHandle(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is this separate hook necessary?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is separate because we use it both in Mobile(Modal) and FloatingPopoverPanel sub components.

Copy link
Copy Markdown
Contributor

@maximo-macchi-cb maximo-macchi-cb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review fully done! Left a couple additional comments

Comment thread packages/web/src/dropdown/Dropdown.tsx Outdated
);

/**
* @deprecated Use PopoverPanel instead. This will be removed in a future major release.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Select should be mentioned here too. Dropdown was very similar to Select. So while PopoverPanel may satisfy some customer's needs, others may be wanting a Select-like component and Select / SelectChip would be better for that than PopoverPanel

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated all deprecation messages to recommend Select for selectable use case

onClick={!disableOverlayPress ? onOverlayPress : undefined}
testID="modal-overlay"
/>
) : (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would moving this to the parent <Box> be a better alternative? Or does that not work?

- Add PopoverPanel, PopoverPanelContent, tests, stories, and @coinbase/cds-web/popover export
- Document PopoverPanel (docgen, sidebar, component docs)
- Deprecate Dropdown, DropdownProps-related types, and DropdownContent for removal in v10
- Popover: forwardRef with mergeRefs for Floating UI reference; flatten to one root div; add style/className to PopoverProps
- Re-export PopoverPanel from overlays index; add Dropdown docs warning
fix ModalWrapper not closing when clicked outside without overlay
@adrienzheng-cb
Copy link
Copy Markdown
Contributor Author

adrienzheng-cb commented Apr 16, 2026

@cb-ekuersch

What are the long term plans for the two popover directories: src/overlays/popover and src/popover?

Good catch! I removed the src/popover directory. It was added by mistake. PopoverPanel and Popover are only exported from overlays now.

Are we planning to keep supporting Popover on its own? I thought PopoverPanel was meant to completely replace it rather than compose it

yes, we still need to support Popover on its own because I have seen consumers using this component in many places and implemented their own PopoverPanel/Dropdown logic on top of it.

@adrienzheng-cb adrienzheng-cb merged commit 35d5eb4 into master Apr 16, 2026
33 of 34 checks passed
@adrienzheng-cb adrienzheng-cb deleted the adrien/deprecate-dropdown branch April 16, 2026 22:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

4 participants