Skip to content

Commit b254b37

Browse files
JonasBawedamija
authored andcommitted
ref(compactSelect) add prebuilt compactSelect menu components (#109171)
Adds the prebuild components to CompactSelect. These were previously only defined in HybridFilters, but they should actually exist at a lower lever given their wide use. I am open to better naming, MenuComponents does not feel like the best choice, maybe something like CompactSelectComponents would have been better and more in line with the CompactSelect component
1 parent 5d62244 commit b254b37

File tree

8 files changed

+240
-155
lines changed

8 files changed

+240
-155
lines changed

static/app/components/core/compactSelect/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export {CompositeSelect, type CompositeSelectProps} from './composite';
1313

1414
export {ControlContext} from './control';
1515

16+
export {MenuComponents} from './menuComponents';
17+
1618
export {
1719
LeadWrap,
1820
SectionSeparator,
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import {useContext} from 'react';
2+
import styled from '@emotion/styled';
3+
import type {DistributedOmit} from 'type-fest';
4+
5+
import {Alert, type AlertProps} from '@sentry/scraps/alert';
6+
import {
7+
Button,
8+
LinkButton,
9+
type ButtonProps,
10+
type LinkButtonProps,
11+
} from '@sentry/scraps/button';
12+
import {Checkbox, type CheckboxProps} from '@sentry/scraps/checkbox';
13+
14+
import {t} from 'sentry/locale';
15+
16+
import {ControlContext} from './control';
17+
18+
export const MenuComponents = {
19+
/**
20+
* A button sized and styled to sit in `menuHeaderTrailingItems`. Inherits the
21+
* header's font size and renders in secondary text color so it blends with the
22+
* title rather than competing with it.
23+
*
24+
* Use this for lightweight, immediate actions in the header — e.g. "Reset",
25+
* "Clear", "Invite Member", or "Sync". These actions typically take effect
26+
* immediately and close the menu. For navigation actions use `LinkButton`
27+
* instead. For prominent footer actions use `CTAButton`.
28+
*
29+
* `priority` and `size` are locked to keep the header visually consistent.
30+
*/
31+
ResetButton(props: DistributedOmit<ButtonProps, 'priority' | 'size' | 'children'>) {
32+
const controlContext = useContext(ControlContext);
33+
34+
return (
35+
<HeaderButton
36+
size="zero"
37+
priority="transparent"
38+
{...props}
39+
onClick={e => {
40+
props.onClick?.(e);
41+
controlContext.overlayState?.close();
42+
}}
43+
>
44+
{t('Reset')}
45+
</HeaderButton>
46+
);
47+
},
48+
49+
ClearButton(props: DistributedOmit<ButtonProps, 'priority' | 'size' | 'children'>) {
50+
const controlContext = useContext(ControlContext);
51+
52+
return (
53+
<HeaderButton
54+
size="zero"
55+
priority="transparent"
56+
{...props}
57+
onClick={e => {
58+
props.onClick?.(e);
59+
controlContext.overlayState?.close();
60+
}}
61+
>
62+
{t('Clear')}
63+
</HeaderButton>
64+
);
65+
},
66+
67+
/**
68+
* A button sized for `menuFooter` call-to-action slots. Larger and more
69+
* visually prominent than `HeaderButton`, making it suitable for primary footer
70+
* actions that don't navigate — e.g. "Invite Member" or "Create".
71+
*
72+
* Use this in `menuFooter` for standalone actions that aren't part of a staged
73+
* selection workflow. For staged selection (deferred apply/cancel) use
74+
* `ApplyButton` and `CancelButton` instead. For navigation actions use
75+
* `CTALinkButton`.
76+
*
77+
* `priority` and `size` are locked to keep footer actions visually consistent.
78+
*/
79+
CTAButton(props: DistributedOmit<ButtonProps, 'priority' | 'size'>) {
80+
return <Button size="xs" {...props} />;
81+
},
82+
CTALinkButton(props: DistributedOmit<LinkButtonProps, 'priority' | 'size'>) {
83+
return <LinkButton size="xs" {...props} />;
84+
},
85+
86+
/**
87+
* A primary "Apply" button for use in `menuFooter` in staged selection
88+
* workflows, where changes are held locally and only committed when the user
89+
* explicitly confirms. Automatically closes the menu after the `onClick`
90+
* handler runs.
91+
*
92+
* Always pair with `CancelButton`. The typical pattern is:
93+
*
94+
* ```tsx
95+
* menuFooter={
96+
* stagedSelect.hasStagedChanges ? (
97+
* <Flex gap="md" justify="end">
98+
* <MenuComponents.CancelButton onClick={() => stagedSelect.removeStagedChanges()} />
99+
* <MenuComponents.ApplyButton onClick={() => stagedSelect.commit(stagedSelect.stagedValue)} />
100+
* </Flex>
101+
* ) : null
102+
* }
103+
* ```
104+
*
105+
* `priority` (`primary`) and `size` are locked to keep staged selection UIs consistent.
106+
*/
107+
ApplyButton(props: DistributedOmit<ButtonProps, 'priority' | 'size' | 'children'>) {
108+
const controlContext = useContext(ControlContext);
109+
return (
110+
<Button
111+
size="xs"
112+
priority="primary"
113+
{...props}
114+
onClick={e => {
115+
props.onClick?.(e);
116+
controlContext.overlayState?.close();
117+
}}
118+
>
119+
{t('Apply')}
120+
</Button>
121+
);
122+
},
123+
124+
/**
125+
* A "Cancel" button for use in `menuFooter` in staged selection workflows,
126+
* where changes are held locally and discarded when the user cancels.
127+
* Automatically closes the menu after the `onClick` handler runs.
128+
*
129+
* Always pair with `ApplyButton`. See `ApplyButton` for the full usage pattern.
130+
*
131+
* `priority` and `size` are locked to keep staged selection UIs consistent.
132+
*/
133+
CancelButton(props: DistributedOmit<ButtonProps, 'priority' | 'size' | 'children'>) {
134+
const controlContext = useContext(ControlContext);
135+
return (
136+
<Button
137+
size="xs"
138+
priority="transparent"
139+
{...props}
140+
onClick={e => {
141+
props.onClick?.(e);
142+
controlContext.overlayState?.close();
143+
}}
144+
>
145+
{t('Cancel')}
146+
</Button>
147+
);
148+
},
149+
150+
/**
151+
* A condensed alert for use in `menuFooter` to surface contextual warnings or
152+
* information without leaving the menu — e.g. "You've reached the selection
153+
* limit" or "Some items are unavailable".
154+
*
155+
* Accepts all Alert `variant` values (`warning`, `info`, `error`, `success`).
156+
* `system` is locked to `false` to prevent the full-bleed layout from
157+
* breaking the menu's padding, and `showIcon` is locked to `false` to keep
158+
* the alert compact.
159+
*/
160+
Alert(props: DistributedOmit<AlertProps, 'system' | 'showIcon'>) {
161+
return <StyledAlert {...props} system={false} showIcon={false} />;
162+
},
163+
164+
Checkbox(props: DistributedOmit<CheckboxProps, 'size'>) {
165+
return <Checkbox size="sm" {...props} />;
166+
},
167+
};
168+
169+
const StyledAlert = styled(Alert)`
170+
padding: ${p => p.theme.space.xs} ${p => p.theme.space.lg};
171+
text-wrap: balance;
172+
`;
173+
174+
const HeaderButton = styled(Button)`
175+
font-size: inherit; /* Inherit font size from MenuHeader */
176+
font-weight: ${p => p.theme.font.weight.sans.regular};
177+
color: ${p => p.theme.tokens.content.secondary};
178+
padding: 0 ${p => p.theme.space.xs};
179+
margin: -${p => p.theme.space.sm} -${p => p.theme.space.xs};
180+
`;

static/app/components/pageFilters/environment/environmentPageFilter.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {isAppleDevice} from '@react-aria/utils';
33
import isEqual from 'lodash/isEqual';
44
import sortBy from 'lodash/sortBy';
55

6+
import {MenuComponents} from '@sentry/scraps/compactSelect';
67
import {InfoTip} from '@sentry/scraps/info';
78
import {Flex} from '@sentry/scraps/layout';
89
import {Text} from '@sentry/scraps/text';
@@ -16,7 +17,6 @@ import {
1617
import type {HybridFilterProps} from 'sentry/components/pageFilters/hybridFilter';
1718
import {
1819
HybridFilter,
19-
HybridFilterComponents,
2020
useStagedCompactSelect,
2121
type HybridFilterRef,
2222
} from 'sentry/components/pageFilters/hybridFilter';
@@ -187,7 +187,7 @@ export function EnvironmentPageFilter({
187187
value: env,
188188
label: env,
189189
leadingItems: ({isSelected}: {isSelected: boolean}) => (
190-
<HybridFilterComponents.Checkbox
190+
<MenuComponents.Checkbox
191191
checked={isSelected}
192192
onChange={() => hybridFilterRef.current?.toggleOption(env)}
193193
aria-label={t('Select %s', env)}
@@ -255,19 +255,17 @@ export function EnvironmentPageFilter({
255255
menuWidth={menuWidth ?? defaultMenuWidth}
256256
menuHeaderTrailingItems={
257257
stagedSelect.shouldShowReset ? (
258-
<HybridFilterComponents.ResetButton
259-
onClick={() => stagedSelect.handleReset()}
260-
/>
258+
<MenuComponents.ResetButton onClick={() => stagedSelect.handleReset()} />
261259
) : null
262260
}
263261
menuFooter={
264262
stagedSelect.hasStagedChanges ? (
265263
<Flex gap="md" align="center" justify="end">
266-
<HybridFilterComponents.CancelButton
264+
<MenuComponents.CancelButton
267265
disabled={!stagedSelect.hasStagedChanges}
268266
onClick={() => stagedSelect.removeStagedChanges()}
269267
/>
270-
<HybridFilterComponents.ApplyButton
268+
<MenuComponents.ApplyButton
271269
onClick={() => stagedSelect.commit(stagedSelect.stagedValue)}
272270
/>
273271
</Flex>

0 commit comments

Comments
 (0)