Skip to content

ref(scraps): compactSelect Triggers must be a specific SelectTrigger#105040

Merged
TkDodo merged 12 commits intomasterfrom
tkdodo/ref/dedicated-select-trigger
Dec 17, 2025
Merged

ref(scraps): compactSelect Triggers must be a specific SelectTrigger#105040
TkDodo merged 12 commits intomasterfrom
tkdodo/ref/dedicated-select-trigger

Conversation

@TkDodo
Copy link
Collaborator

@TkDodo TkDodo commented Dec 16, 2025

ref: https://linear.app/getsentry/issue/DE-668/streamline-compactselect-triggers

This PR disallows passing arbitrary components to trigger of compactSelect on type level. Everything that isn’t SelectTrigger.Button (or future SelectTriggers that we can add here) will produce a type error. This has a couple of advantages:

  • SelectTrigger.Button is just a regular DropdownButton, however, we can ensure that we read from the SelectContext and therefore have access to the size, disabled and open state of the compactSelect. This ensures consistency, as we can’t forget to pass those along. Forgetting to pass isOpen leads to the chevron not being inverted correctly. Passing size explicitly is brittle. All these things work out of the box when using triggerProps, but not when passing a custom trigger. Note: The final goal here is to get rid of triggerProps and only use the trigger prop.
  • If we add more allowed triggers (like an Input), it’s hard to get the typings right, mostly because of refs. It’s actually impossible without a type assertions (we’d need fragment refs, which are not out yet). This is now hidden inside SelectTrigger.Button

The implications are:

  • In places where we used a Button instead of a DropdownButton, we now get the chevron shown unless we pass showChevron={false}. I did that for all the places I touched, it’s especially important for buttons that only have an icon where a second chevron icon would be out of place.
  • In places where we didn’t use a Button as a trigger at all, we would now get an error. However, we were already tied to Button because of the ref typings, so there was only one place, which already has a ts-expect-error: The BreadcrumbDropdown:

trigger={triggerProps => (
<MenuCrumb
crumbLabel={name || route.name}
menuHasHover={isHovered}
{...triggerProps}
// @ts-expect-error - TODO: Crumb component should be refactored to use a button element instead of a div
ref={triggerProps.ref}
/>
)}

I refactored this towards a button in:

We should merge that one first, and then I’ll fix the conflicts here.

  • There is one additional place I couldn’t fix because it uses a completely unstyled button, and t hat’s inside the searchQueryBuilder. I don’t know why there is only one element that uses compactSelect, as everything else seems to just use ListBox directly. I tried to replicate the current look with a transparent button but I couldn’t quite get it to look identical, so I added a ts-expect-error for that place.

Naming is up for debate: I didn’t want to attach it to CompactSelect.TriggerButton because we also use it for CompositeSelect.

@github-actions github-actions bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Dec 16, 2025
);
},

Input(props: InputTriggerProps) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this is mostly just a showcase, I can also delete it for now.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I deleted this for now and made the error message better by adding an invalid type to the union: 9234a5b

Screenshot 2025-12-17 at 09 42 47

trigger={triggerProps => (
<OverflowMenuTrigger
{...triggerProps}
size="sm"
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

size now gets inherited from CompactSelect, which gets the same size passed

Comment on lines -225 to -226
isOpen={isOpen}
size={selectProps.size}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

both props are now applied automatically from SelectContext

Comment on lines +47 to +52
size="sm"
disabled={isLoading}
trigger={triggerProps => (
<Button
<SelectTrigger.Button
{...triggerProps}
size="sm"
showChevron={false}
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I intentionally moved size up to CompositeSelect because it’s not consistent to have a sm button with a md menu. I checked in the product and it looks good.

options={LOGIC_OPERATOR_OPTIONS}
trigger={triggerProps => {
return (
// @ts-expect-error we don't allow arbitrary buttons as triggers
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I can take a stab in a follow-up to try and get this to work with our buttons. Maybe a SelectTrigger.Unstyled would be a good idea too if we need this more often ?

Comment on lines -9 to +13
export interface DropdownButtonProps
extends Omit<ButtonProps, 'type' | 'prefix' | 'onClick'> {
export type DropdownButtonProps = DistributedOmit<
ButtonProps,
'type' | 'prefix' | 'onClick'
> & {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Note: This is a classic type-level bug with Omit: ButtonProps is a discriminated union type, and Omit destroys that. So we had instances where we passed an icon, but no children and no aria-label, which the types tried to ensure.

DistributedOmit preserve the union type, but then again interfaces can’t extend union types, so I had to switch to types. This is the main reason why I think using type everywhere is better.

@TkDodo TkDodo marked this pull request as ready for review December 16, 2025 14:43
@TkDodo TkDodo requested a review from a team December 16, 2025 14:43
@TkDodo TkDodo requested review from a team as code owners December 16, 2025 14:43
@JonasBa
Copy link
Member

JonasBa commented Dec 16, 2025

@TkDodo mind creating a design eng ticket for this, and mark it as done? I'm going to compile a changelog for beginning of next year, and I want to make sure we don't miss this

Copy link
Member

@JonasBa JonasBa left a comment

Choose a reason for hiding this comment

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

I think this PR captures really well the notion of composability and level of restriction that a design system should be opinionated about. Awesome work, as always!

Comment on lines +11 to +21
export type SelectTriggerProps = React.HTMLAttributes<TriggerEl> & {
ref?: React.Ref<TriggerEl>;
};

export type ButtonTriggerProps = React.ComponentPropsWithoutRef<typeof DropdownButton> & {
ref?: React.Ref<TriggerEl>;
};

type InputTriggerProps = React.ComponentPropsWithoutRef<typeof Input> & {
ref?: React.Ref<TriggerEl>;
};
Copy link
Member

Choose a reason for hiding this comment

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

Can we export props or use an interface here? These are the kinds of types that can really slow down the type checker

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

can’t use an interface because ButtonProps is a union, which you can’t extend, and if I use DropdownButtonProps directly, I need to remove ref with DistributedOmit<DropdownButtonProps, 'ref'> which I don't think is better / faster.

* component.
*/
triggerProps?: DropdownButtonProps;
triggerProps?: Partial<DropdownButtonProps>;
Copy link
Member

Choose a reason for hiding this comment

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

This might not seem like much, but it makes these components so much friendlier to work with

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I mainly got some errors here because the union is now respected, some props like aria-label or children became required all of a sudden, which isn’t what we want here.

@linear
Copy link

linear bot commented Dec 17, 2025

by adding a union with a type that contains the descriptive error message
@TkDodo TkDodo merged commit b2da7c7 into master Dec 17, 2025
49 checks passed
@TkDodo TkDodo deleted the tkdodo/ref/dedicated-select-trigger branch December 17, 2025 09:30
@github-actions github-actions bot locked and limited conversation to collaborators Jan 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants