From 0de65a9b650e82be32ec60a4b1ac4ad3d9441ae0 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 3 Nov 2026 08:55:40 +0100 Subject: [PATCH 01/13] fix(settings): Widen the project select menu in settings-v3 The project CompactSelect in the settings navigation pinned its menu to the width of the sidebar trigger, so longer project slugs were ellipsified. Use a 320px menu min-width instead so the menu can grow wider than the trigger. --- static/app/views/settings/components/settingsNavigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx index dc29b6854552..248ada9137e0 100644 --- a/static/app/views/settings/components/settingsNavigation.tsx +++ b/static/app/views/settings/components/settingsNavigation.tsx @@ -113,7 +113,7 @@ function SettingsProjectSelector() { {...containerProps} size="sm" position="bottom-start" - menuWidth="100%" + menuMinWidth={320} preventOverflowOptions={{boundary: 'clippingAncestors'}} value={projectId} trigger={triggerProps => ( From 633a30919bc09c279e9eabdae0be1b306b7cf307 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 3 Nov 2026 08:58:42 +0100 Subject: [PATCH 02/13] ref(settings): Use 300px min-width for project select menu --- static/app/views/settings/components/settingsNavigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx index 248ada9137e0..0a62d9345ba7 100644 --- a/static/app/views/settings/components/settingsNavigation.tsx +++ b/static/app/views/settings/components/settingsNavigation.tsx @@ -113,7 +113,7 @@ function SettingsProjectSelector() { {...containerProps} size="sm" position="bottom-start" - menuMinWidth={320} + menuMinWidth={300} preventOverflowOptions={{boundary: 'clippingAncestors'}} value={projectId} trigger={triggerProps => ( From 4a02f666cfd9591527b72179b379b170fa4d90e7 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 3 Nov 2026 08:59:29 +0100 Subject: [PATCH 03/13] ref(settings): Match project page filter min menu width --- static/app/views/settings/components/settingsNavigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx index 0a62d9345ba7..db6387e4b05a 100644 --- a/static/app/views/settings/components/settingsNavigation.tsx +++ b/static/app/views/settings/components/settingsNavigation.tsx @@ -113,7 +113,7 @@ function SettingsProjectSelector() { {...containerProps} size="sm" position="bottom-start" - menuMinWidth={300} + menuMinWidth="22em" preventOverflowOptions={{boundary: 'clippingAncestors'}} value={projectId} trigger={triggerProps => ( From 57f22d78621f77d5b2868defe3d6427d96e2ed50 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 3 Nov 2026 09:52:37 +0100 Subject: [PATCH 04/13] ref(settings): Share menu width helper between project filter and settings select --- .../components/core/compactSelect/index.tsx | 1 + .../core/compactSelect/utils.spec.tsx | 29 +++++++++++++++++++ .../components/core/compactSelect/utils.tsx | 29 +++++++++++++++++++ .../pageFilters/project/projectPageFilter.tsx | 25 ++++++++-------- .../components/settingsNavigation.tsx | 14 +++++++-- 5 files changed, 83 insertions(+), 15 deletions(-) create mode 100644 static/app/components/core/compactSelect/utils.spec.tsx diff --git a/static/app/components/core/compactSelect/index.tsx b/static/app/components/core/compactSelect/index.tsx index f55f6e071427..3f803e1fd6c6 100644 --- a/static/app/components/core/compactSelect/index.tsx +++ b/static/app/components/core/compactSelect/index.tsx @@ -40,6 +40,7 @@ export { itemIsSectionWithKey, SectionToggle, getEscapedKey, + getMenuWidthForLabels, } from './utils'; export {useVirtualizedItems} from './useVirtualizedItems'; diff --git a/static/app/components/core/compactSelect/utils.spec.tsx b/static/app/components/core/compactSelect/utils.spec.tsx new file mode 100644 index 000000000000..71311c3d5c1a --- /dev/null +++ b/static/app/components/core/compactSelect/utils.spec.tsx @@ -0,0 +1,29 @@ +import {getMenuWidthForLabels} from './utils'; + +describe('getMenuWidthForLabels', () => { + const options = {extraEm: 12, minEm: 22, maxEm: 28}; + + it('returns the minimum width when there are no labels', () => { + expect(getMenuWidthForLabels([], options)).toBe('22em'); + }); + + it('returns the minimum width for short labels', () => { + expect(getMenuWidthForLabels(['app', 'web'], options)).toBe('22em'); + }); + + it('grows with the longest label', () => { + // 20 * 0.6 + 12 = 24 + expect(getMenuWidthForLabels(['short', 'a'.repeat(20)], options)).toBe('24em'); + }); + + it('caps the width at the maximum', () => { + expect(getMenuWidthForLabels(['a'.repeat(100)], options)).toBe('28em'); + }); + + it('respects the extra width', () => { + // 30 * 0.6 + 8 = 26 + expect(getMenuWidthForLabels(['a'.repeat(30)], {...options, extraEm: 8})).toBe( + '26em' + ); + }); +}); diff --git a/static/app/components/core/compactSelect/utils.tsx b/static/app/components/core/compactSelect/utils.tsx index 4d01cbf0c6f7..e6b52c620a25 100644 --- a/static/app/components/core/compactSelect/utils.tsx +++ b/static/app/components/core/compactSelect/utils.tsx @@ -509,3 +509,32 @@ export function getDuplicateOptionKeysInfo( collect(items); return {duplicateOptionKeys: [...duplicates], hasSections, optionCount}; } + +interface MenuWidthOptions { + /** + * Width in em reserved for everything that is not label text: padding, leading + * items such as avatars or checkboxes, and trailing buttons. + */ + extraEm: number; + /** Upper bound for the returned width, in em. */ + maxEm: number; + /** Lower bound for the returned width, in em. */ + minEm: number; +} + +/** + * Computes a menu width (in em) that expands to fit the longest label, clamped + * between `minEm` and `maxEm`. Each character is estimated to take ~0.6em, so this + * is a heuristic based on character count, not a measured width. + */ +export function getMenuWidthForLabels( + labels: readonly string[], + {extraEm, maxEm, minEm}: MenuWidthOptions +): string { + const longestLabelLength = labels.reduce( + (longest, label) => Math.max(longest, label.length), + 0 + ); + + return `${Math.max(minEm, Math.min(maxEm, longestLabelLength * 0.6 + extraEm))}em`; +} diff --git a/static/app/components/pageFilters/project/projectPageFilter.tsx b/static/app/components/pageFilters/project/projectPageFilter.tsx index ad6adc04e486..02b5c6d07aed 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.tsx @@ -11,7 +11,11 @@ import sortBy from 'lodash/sortBy'; import xor from 'lodash/xor'; import {LinkButton} from '@sentry/scraps/button'; -import {CompactSelect, MenuComponents} from '@sentry/scraps/compactSelect'; +import { + CompactSelect, + getMenuWidthForLabels, + MenuComponents, +} from '@sentry/scraps/compactSelect'; import type {MultipleSelectProps, SelectOption} from '@sentry/scraps/compactSelect'; import {InfoTip} from '@sentry/scraps/info'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; @@ -839,18 +843,13 @@ function toURLSelection({ } function computeMenuWidth(options: Array>): string { - // ProjectPageFilter will try to expand to accommodate the longest project slug - const longestSlugLength = options.reduce((acc, cur) => { - const length = cur.textValue?.length ?? 0; - return length > acc ? length : acc; - }, 0); - - // Calculate an appropriate width for the menu. It should be between 22 and 28em. - // Within that range, the width is a function of the length of the longest slug. - // The project slugs take up to (longestSlugLength * 0.6)em of horizontal space - // (each character occupies roughly 0.6em). - // We also need to add 12em to account for padding, trailing buttons, and the checkbox. - return `${Math.max(22, Math.min(28, longestSlugLength * 0.6 + 12))}em`; + // ProjectPageFilter will try to expand to accommodate the longest project slug. + // The menu is between 22 and 28em. We add 12em to account for padding, trailing + // buttons, and the checkbox. + return getMenuWidthForLabels( + options.map(option => option.textValue ?? ''), + {extraEm: 12, minEm: 22, maxEm: 28} + ); } // Helper functions for data selection diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx index db6387e4b05a..1416fc79b389 100644 --- a/static/app/views/settings/components/settingsNavigation.tsx +++ b/static/app/views/settings/components/settingsNavigation.tsx @@ -4,7 +4,11 @@ import {mergeProps} from '@react-aria/utils'; import {IconAdd} from '@sentry/icons/add'; import {ProjectAvatar} from '@sentry/scraps/avatar'; -import {CompactSelect, MenuComponents} from '@sentry/scraps/compactSelect'; +import { + CompactSelect, + getMenuWidthForLabels, + MenuComponents, +} from '@sentry/scraps/compactSelect'; import {Container} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {Text} from '@sentry/scraps/text'; @@ -105,6 +109,12 @@ function SettingsProjectSelector() { const selectedProject = projects.find(project => project.slug === projectId); const organization = useOrganization(); const canCreateProject = useCanCreateProject(); + // Same range as the project page filter, with less reserved space since there is + // no checkbox or trailing button, only an avatar. + const menuWidth = getMenuWidthForLabels( + projects.map(project => project.slug), + {extraEm: 8, minEm: 22, maxEm: 28} + ); return ( @@ -113,7 +123,7 @@ function SettingsProjectSelector() { {...containerProps} size="sm" position="bottom-start" - menuMinWidth="22em" + menuWidth={menuWidth} preventOverflowOptions={{boundary: 'clippingAncestors'}} value={projectId} trigger={triggerProps => ( From 8a573e51a6b129329fe6f1c777749a5d7b926f09 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 3 Nov 2026 09:57:22 +0100 Subject: [PATCH 05/13] ref(settings): Simplify shared menu width helper --- .../core/compactSelect/utils.spec.tsx | 17 +++--------- .../components/core/compactSelect/utils.tsx | 26 +++++-------------- .../pageFilters/project/projectPageFilter.tsx | 9 ++----- .../components/settingsNavigation.tsx | 7 +---- 4 files changed, 13 insertions(+), 46 deletions(-) diff --git a/static/app/components/core/compactSelect/utils.spec.tsx b/static/app/components/core/compactSelect/utils.spec.tsx index 71311c3d5c1a..a890110af12a 100644 --- a/static/app/components/core/compactSelect/utils.spec.tsx +++ b/static/app/components/core/compactSelect/utils.spec.tsx @@ -1,29 +1,20 @@ import {getMenuWidthForLabels} from './utils'; describe('getMenuWidthForLabels', () => { - const options = {extraEm: 12, minEm: 22, maxEm: 28}; - it('returns the minimum width when there are no labels', () => { - expect(getMenuWidthForLabels([], options)).toBe('22em'); + expect(getMenuWidthForLabels([])).toBe('22em'); }); it('returns the minimum width for short labels', () => { - expect(getMenuWidthForLabels(['app', 'web'], options)).toBe('22em'); + expect(getMenuWidthForLabels(['app', 'web'])).toBe('22em'); }); it('grows with the longest label', () => { // 20 * 0.6 + 12 = 24 - expect(getMenuWidthForLabels(['short', 'a'.repeat(20)], options)).toBe('24em'); + expect(getMenuWidthForLabels(['short', 'a'.repeat(20)])).toBe('24em'); }); it('caps the width at the maximum', () => { - expect(getMenuWidthForLabels(['a'.repeat(100)], options)).toBe('28em'); - }); - - it('respects the extra width', () => { - // 30 * 0.6 + 8 = 26 - expect(getMenuWidthForLabels(['a'.repeat(30)], {...options, extraEm: 8})).toBe( - '26em' - ); + expect(getMenuWidthForLabels(['a'.repeat(100)])).toBe('28em'); }); }); diff --git a/static/app/components/core/compactSelect/utils.tsx b/static/app/components/core/compactSelect/utils.tsx index e6b52c620a25..9a50a5e8c408 100644 --- a/static/app/components/core/compactSelect/utils.tsx +++ b/static/app/components/core/compactSelect/utils.tsx @@ -510,31 +510,17 @@ export function getDuplicateOptionKeysInfo( return {duplicateOptionKeys: [...duplicates], hasSections, optionCount}; } -interface MenuWidthOptions { - /** - * Width in em reserved for everything that is not label text: padding, leading - * items such as avatars or checkboxes, and trailing buttons. - */ - extraEm: number; - /** Upper bound for the returned width, in em. */ - maxEm: number; - /** Lower bound for the returned width, in em. */ - minEm: number; -} - /** - * Computes a menu width (in em) that expands to fit the longest label, clamped - * between `minEm` and `maxEm`. Each character is estimated to take ~0.6em, so this - * is a heuristic based on character count, not a measured width. + * Computes a menu width that expands to fit the longest label. The width is + * between 22 and 28em. Each character is estimated to take ~0.6em, and 12em is + * added to account for padding, trailing buttons, and the checkbox. This is a + * heuristic based on character count, not a measured width. */ -export function getMenuWidthForLabels( - labels: readonly string[], - {extraEm, maxEm, minEm}: MenuWidthOptions -): string { +export function getMenuWidthForLabels(labels: readonly string[]): string { const longestLabelLength = labels.reduce( (longest, label) => Math.max(longest, label.length), 0 ); - return `${Math.max(minEm, Math.min(maxEm, longestLabelLength * 0.6 + extraEm))}em`; + return `${Math.max(22, Math.min(28, longestLabelLength * 0.6 + 12))}em`; } diff --git a/static/app/components/pageFilters/project/projectPageFilter.tsx b/static/app/components/pageFilters/project/projectPageFilter.tsx index 02b5c6d07aed..8017fa0e6e2a 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.tsx @@ -843,13 +843,8 @@ function toURLSelection({ } function computeMenuWidth(options: Array>): string { - // ProjectPageFilter will try to expand to accommodate the longest project slug. - // The menu is between 22 and 28em. We add 12em to account for padding, trailing - // buttons, and the checkbox. - return getMenuWidthForLabels( - options.map(option => option.textValue ?? ''), - {extraEm: 12, minEm: 22, maxEm: 28} - ); + // ProjectPageFilter will try to expand to accommodate the longest project slug + return getMenuWidthForLabels(options.map(option => option.textValue ?? '')); } // Helper functions for data selection diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx index 1416fc79b389..3535fccf268d 100644 --- a/static/app/views/settings/components/settingsNavigation.tsx +++ b/static/app/views/settings/components/settingsNavigation.tsx @@ -109,12 +109,7 @@ function SettingsProjectSelector() { const selectedProject = projects.find(project => project.slug === projectId); const organization = useOrganization(); const canCreateProject = useCanCreateProject(); - // Same range as the project page filter, with less reserved space since there is - // no checkbox or trailing button, only an avatar. - const menuWidth = getMenuWidthForLabels( - projects.map(project => project.slug), - {extraEm: 8, minEm: 22, maxEm: 28} - ); + const menuWidth = getMenuWidthForLabels(projects.map(project => project.slug)); return ( From c99ff9481f5466fbea459da54c6e1b6eae1caaee Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:18:30 +0000 Subject: [PATCH 06/13] ref(compactSelect): Make menu width helper bounds configurable Accept minWidth, maxWidth, and extraWidth options in getMenuWidthForLabels so it is not tied to the project page filter's sizing. Use it in EnvironmentPageFilter with its existing 16-24em bounds and 6em extra width, and memoize the width in the settings project selector. Co-Authored-By: Priscila Andre de Oliveira <29228205+priscilawebdev@users.noreply.github.com> --- .../core/compactSelect/utils.spec.tsx | 8 +++++ .../components/core/compactSelect/utils.tsx | 30 ++++++++++++++---- .../environment/environmentPageFilter.tsx | 31 +++++++++---------- .../components/settingsNavigation.tsx | 7 +++-- 4 files changed, 51 insertions(+), 25 deletions(-) diff --git a/static/app/components/core/compactSelect/utils.spec.tsx b/static/app/components/core/compactSelect/utils.spec.tsx index a890110af12a..7e51d4b51b4d 100644 --- a/static/app/components/core/compactSelect/utils.spec.tsx +++ b/static/app/components/core/compactSelect/utils.spec.tsx @@ -17,4 +17,12 @@ describe('getMenuWidthForLabels', () => { it('caps the width at the maximum', () => { expect(getMenuWidthForLabels(['a'.repeat(100)])).toBe('28em'); }); + + it('accepts custom bounds and extra width', () => { + const options = {minWidth: 16, maxWidth: 24, extraWidth: 6}; + expect(getMenuWidthForLabels([], options)).toBe('16em'); + // 20 * 0.6 + 6 = 18 + expect(getMenuWidthForLabels(['a'.repeat(20)], options)).toBe('18em'); + expect(getMenuWidthForLabels(['a'.repeat(100)], options)).toBe('24em'); + }); }); diff --git a/static/app/components/core/compactSelect/utils.tsx b/static/app/components/core/compactSelect/utils.tsx index 9a50a5e8c408..655fdfab65a6 100644 --- a/static/app/components/core/compactSelect/utils.tsx +++ b/static/app/components/core/compactSelect/utils.tsx @@ -510,17 +510,35 @@ export function getDuplicateOptionKeysInfo( return {duplicateOptionKeys: [...duplicates], hasSections, optionCount}; } +interface MenuWidthForLabelsOptions { + /** + * Space in em reserved for everything except the label text, such as + * padding, leading checkboxes or avatars, and trailing buttons. + */ + extraWidth?: number; + /** + * Maximum menu width in em. + */ + maxWidth?: number; + /** + * Minimum menu width in em. + */ + minWidth?: number; +} + /** - * Computes a menu width that expands to fit the longest label. The width is - * between 22 and 28em. Each character is estimated to take ~0.6em, and 12em is - * added to account for padding, trailing buttons, and the checkbox. This is a - * heuristic based on character count, not a measured width. + * Computes a menu width that expands to fit the longest label, clamped between + * `minWidth` and `maxWidth`. Each character is estimated to take ~0.6em. This + * is a heuristic based on character count, not a measured width. */ -export function getMenuWidthForLabels(labels: readonly string[]): string { +export function getMenuWidthForLabels( + labels: readonly string[], + {minWidth = 22, maxWidth = 28, extraWidth = 12}: MenuWidthForLabelsOptions = {} +): string { const longestLabelLength = labels.reduce( (longest, label) => Math.max(longest, label.length), 0 ); - return `${Math.max(22, Math.min(28, longestLabelLength * 0.6 + 12))}em`; + return `${Math.max(minWidth, Math.min(maxWidth, longestLabelLength * 0.6 + extraWidth))}em`; } diff --git a/static/app/components/pageFilters/environment/environmentPageFilter.tsx b/static/app/components/pageFilters/environment/environmentPageFilter.tsx index 006b003a723b..d2ac1cb22abe 100644 --- a/static/app/components/pageFilters/environment/environmentPageFilter.tsx +++ b/static/app/components/pageFilters/environment/environmentPageFilter.tsx @@ -3,7 +3,11 @@ import {useMatches} from 'react-router'; import {isAppleDevice} from '@react-aria/utils'; import isEqual from 'lodash/isEqual'; -import {CompactSelect, MenuComponents} from '@sentry/scraps/compactSelect'; +import { + CompactSelect, + getMenuWidthForLabels, + MenuComponents, +} from '@sentry/scraps/compactSelect'; import type {MultipleSelectProps} from '@sentry/scraps/compactSelect'; import {InfoTip} from '@sentry/scraps/info'; import {Flex} from '@sentry/scraps/layout'; @@ -181,22 +185,15 @@ export function EnvironmentPageFilter({ [environments] ); - const defaultMenuWidth = useMemo(() => { - // EnvironmentPageFilter will try to expand to accommodate the longest env slug - const longestSlugLength = options - .slice(0, 25) - .reduce( - (acc, cur) => (String(cur.label).length > acc ? String(cur.label).length : acc), - 0 - ); - - // Calculate an appropriate width for the menu. It should be between 16 and 24em. - // Within that range, the width is a function of the length of the longest slug. - // The environment slugs take up to (longestSlugLength * 0.6)em of horizontal space - // (each character occupies roughly 0.6em). We also need to add 6em to account for - // the checkbox and menu paddings. - return `${Math.max(16, Math.min(24, longestSlugLength * 0.6 + 6))}em`; - }, [options]); + // EnvironmentPageFilter will try to expand to accommodate the longest env slug. + const defaultMenuWidth = useMemo( + () => + getMenuWidthForLabels( + options.slice(0, 25).map(option => String(option.label)), + {minWidth: 16, maxWidth: 24, extraWidth: 6} + ), + [options] + ); const stagedSelect = useStagedCompactSelect({ value, diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx index 3535fccf268d..50eeedf5a304 100644 --- a/static/app/views/settings/components/settingsNavigation.tsx +++ b/static/app/views/settings/components/settingsNavigation.tsx @@ -1,4 +1,4 @@ -import {cloneElement, Fragment} from 'react'; +import {cloneElement, Fragment, useMemo} from 'react'; import {matchPath} from 'react-router'; import {mergeProps} from '@react-aria/utils'; import {IconAdd} from '@sentry/icons/add'; @@ -109,7 +109,10 @@ function SettingsProjectSelector() { const selectedProject = projects.find(project => project.slug === projectId); const organization = useOrganization(); const canCreateProject = useCanCreateProject(); - const menuWidth = getMenuWidthForLabels(projects.map(project => project.slug)); + const menuWidth = useMemo( + () => getMenuWidthForLabels(projects.map(project => project.slug)), + [projects] + ); return ( From 291461dd3424416ca8e8160aeec31b9706df4469 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:21:08 +0000 Subject: [PATCH 07/13] ref(settings): Drop manual useMemo for project select menu width React Compiler is enabled for application code, so the explicit memo is redundant. Co-Authored-By: Priscila Andre de Oliveira <29228205+priscilawebdev@users.noreply.github.com> --- .../app/views/settings/components/settingsNavigation.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx index 50eeedf5a304..3535fccf268d 100644 --- a/static/app/views/settings/components/settingsNavigation.tsx +++ b/static/app/views/settings/components/settingsNavigation.tsx @@ -1,4 +1,4 @@ -import {cloneElement, Fragment, useMemo} from 'react'; +import {cloneElement, Fragment} from 'react'; import {matchPath} from 'react-router'; import {mergeProps} from '@react-aria/utils'; import {IconAdd} from '@sentry/icons/add'; @@ -109,10 +109,7 @@ function SettingsProjectSelector() { const selectedProject = projects.find(project => project.slug === projectId); const organization = useOrganization(); const canCreateProject = useCanCreateProject(); - const menuWidth = useMemo( - () => getMenuWidthForLabels(projects.map(project => project.slug)), - [projects] - ); + const menuWidth = getMenuWidthForLabels(projects.map(project => project.slug)); return ( From d4d1a3d774a4e8c94d60da8bdbff179d75b656ad Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:22:16 +0000 Subject: [PATCH 08/13] ref(pageFilters): Inline project page filter menu width computation Co-Authored-By: Priscila Andre de Oliveira <29228205+priscilawebdev@users.noreply.github.com> --- .../pageFilters/project/projectPageFilter.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/static/app/components/pageFilters/project/projectPageFilter.tsx b/static/app/components/pageFilters/project/projectPageFilter.tsx index 8017fa0e6e2a..152464520197 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.tsx @@ -572,7 +572,10 @@ export function ProjectPageFilter({ // React Compiler could not prove this memoization is preserved; it bails out on // code this callback depends on. Revisit once those bailouts are fixed. // oxlint-disable-next-line react/preserve-manual-memoization - const defaultMenuWidth = useMemo(() => computeMenuWidth(options), [options]); + const defaultMenuWidth = useMemo( + () => getMenuWidthForLabels(options.map(option => option.textValue ?? '')), + [options] + ); const canCreateProject = useCanCreateProject(); @@ -842,11 +845,6 @@ function toURLSelection({ return value; } -function computeMenuWidth(options: Array>): string { - // ProjectPageFilter will try to expand to accommodate the longest project slug - return getMenuWidthForLabels(options.map(option => option.textValue ?? '')); -} - // Helper functions for data selection function memberProjects(projects: Project[]): Project[] { return projects.filter(project => project.isMember); From 254efde81260ecd7af4c9dfecccd0f8f802eea30 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:29:10 +0000 Subject: [PATCH 09/13] ref(compactSelect): Require explicit bounds in getMenuWidthForLabels The 22-28em defaults were sized for the project page filter, so pass them at each call site instead of baking them into the shared helper. Co-Authored-By: Priscila Andre de Oliveira <29228205+priscilawebdev@users.noreply.github.com> --- .../core/compactSelect/utils.spec.tsx | 18 ++++++------------ .../components/core/compactSelect/utils.tsx | 8 ++++---- .../pageFilters/project/projectPageFilter.tsx | 6 +++++- .../settings/components/settingsNavigation.tsx | 5 ++++- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/static/app/components/core/compactSelect/utils.spec.tsx b/static/app/components/core/compactSelect/utils.spec.tsx index 7e51d4b51b4d..a9e1fe499f0e 100644 --- a/static/app/components/core/compactSelect/utils.spec.tsx +++ b/static/app/components/core/compactSelect/utils.spec.tsx @@ -1,28 +1,22 @@ import {getMenuWidthForLabels} from './utils'; describe('getMenuWidthForLabels', () => { + const options = {minWidth: 16, maxWidth: 24, extraWidth: 6}; + it('returns the minimum width when there are no labels', () => { - expect(getMenuWidthForLabels([])).toBe('22em'); + expect(getMenuWidthForLabels([], options)).toBe('16em'); }); it('returns the minimum width for short labels', () => { - expect(getMenuWidthForLabels(['app', 'web'])).toBe('22em'); + expect(getMenuWidthForLabels(['app', 'web'], options)).toBe('16em'); }); it('grows with the longest label', () => { - // 20 * 0.6 + 12 = 24 - expect(getMenuWidthForLabels(['short', 'a'.repeat(20)])).toBe('24em'); + // 20 * 0.6 + 6 = 18 + expect(getMenuWidthForLabels(['short', 'a'.repeat(20)], options)).toBe('18em'); }); it('caps the width at the maximum', () => { - expect(getMenuWidthForLabels(['a'.repeat(100)])).toBe('28em'); - }); - - it('accepts custom bounds and extra width', () => { - const options = {minWidth: 16, maxWidth: 24, extraWidth: 6}; - expect(getMenuWidthForLabels([], options)).toBe('16em'); - // 20 * 0.6 + 6 = 18 - expect(getMenuWidthForLabels(['a'.repeat(20)], options)).toBe('18em'); expect(getMenuWidthForLabels(['a'.repeat(100)], options)).toBe('24em'); }); }); diff --git a/static/app/components/core/compactSelect/utils.tsx b/static/app/components/core/compactSelect/utils.tsx index 655fdfab65a6..e636e63546d7 100644 --- a/static/app/components/core/compactSelect/utils.tsx +++ b/static/app/components/core/compactSelect/utils.tsx @@ -515,15 +515,15 @@ interface MenuWidthForLabelsOptions { * Space in em reserved for everything except the label text, such as * padding, leading checkboxes or avatars, and trailing buttons. */ - extraWidth?: number; + extraWidth: number; /** * Maximum menu width in em. */ - maxWidth?: number; + maxWidth: number; /** * Minimum menu width in em. */ - minWidth?: number; + minWidth: number; } /** @@ -533,7 +533,7 @@ interface MenuWidthForLabelsOptions { */ export function getMenuWidthForLabels( labels: readonly string[], - {minWidth = 22, maxWidth = 28, extraWidth = 12}: MenuWidthForLabelsOptions = {} + {minWidth, maxWidth, extraWidth}: MenuWidthForLabelsOptions ): string { const longestLabelLength = labels.reduce( (longest, label) => Math.max(longest, label.length), diff --git a/static/app/components/pageFilters/project/projectPageFilter.tsx b/static/app/components/pageFilters/project/projectPageFilter.tsx index 152464520197..dd5332ec2697 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.tsx @@ -573,7 +573,11 @@ export function ProjectPageFilter({ // code this callback depends on. Revisit once those bailouts are fixed. // oxlint-disable-next-line react/preserve-manual-memoization const defaultMenuWidth = useMemo( - () => getMenuWidthForLabels(options.map(option => option.textValue ?? '')), + () => + getMenuWidthForLabels( + options.map(option => option.textValue ?? ''), + {minWidth: 22, maxWidth: 28, extraWidth: 12} + ), [options] ); diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx index 3535fccf268d..7c38fdb6d18a 100644 --- a/static/app/views/settings/components/settingsNavigation.tsx +++ b/static/app/views/settings/components/settingsNavigation.tsx @@ -109,7 +109,10 @@ function SettingsProjectSelector() { const selectedProject = projects.find(project => project.slug === projectId); const organization = useOrganization(); const canCreateProject = useCanCreateProject(); - const menuWidth = getMenuWidthForLabels(projects.map(project => project.slug)); + const menuWidth = getMenuWidthForLabels( + projects.map(project => project.slug), + {minWidth: 22, maxWidth: 28, extraWidth: 12} + ); return ( From 33281377d22622c4bccc2c9819fe6e2773c08f1a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:31:41 +0000 Subject: [PATCH 10/13] ref(compactSelect): Use one fixed sizing in getMenuWidthForLabels Every caller now gets the same 22-28em range with 12em extra width, so the helper takes only the labels. This widens the environment page filter menu, which previously used 16-24em with 6em extra width. Co-Authored-By: Priscila Andre de Oliveira <29228205+priscilawebdev@users.noreply.github.com> --- .../core/compactSelect/utils.spec.tsx | 12 ++++---- .../components/core/compactSelect/utils.tsx | 30 ++++--------------- .../environment/environmentPageFilter.tsx | 6 +--- .../pageFilters/project/projectPageFilter.tsx | 6 +--- .../components/settingsNavigation.tsx | 5 +--- 5 files changed, 14 insertions(+), 45 deletions(-) diff --git a/static/app/components/core/compactSelect/utils.spec.tsx b/static/app/components/core/compactSelect/utils.spec.tsx index a9e1fe499f0e..a890110af12a 100644 --- a/static/app/components/core/compactSelect/utils.spec.tsx +++ b/static/app/components/core/compactSelect/utils.spec.tsx @@ -1,22 +1,20 @@ import {getMenuWidthForLabels} from './utils'; describe('getMenuWidthForLabels', () => { - const options = {minWidth: 16, maxWidth: 24, extraWidth: 6}; - it('returns the minimum width when there are no labels', () => { - expect(getMenuWidthForLabels([], options)).toBe('16em'); + expect(getMenuWidthForLabels([])).toBe('22em'); }); it('returns the minimum width for short labels', () => { - expect(getMenuWidthForLabels(['app', 'web'], options)).toBe('16em'); + expect(getMenuWidthForLabels(['app', 'web'])).toBe('22em'); }); it('grows with the longest label', () => { - // 20 * 0.6 + 6 = 18 - expect(getMenuWidthForLabels(['short', 'a'.repeat(20)], options)).toBe('18em'); + // 20 * 0.6 + 12 = 24 + expect(getMenuWidthForLabels(['short', 'a'.repeat(20)])).toBe('24em'); }); it('caps the width at the maximum', () => { - expect(getMenuWidthForLabels(['a'.repeat(100)], options)).toBe('24em'); + expect(getMenuWidthForLabels(['a'.repeat(100)])).toBe('28em'); }); }); diff --git a/static/app/components/core/compactSelect/utils.tsx b/static/app/components/core/compactSelect/utils.tsx index e636e63546d7..e71bf011a00d 100644 --- a/static/app/components/core/compactSelect/utils.tsx +++ b/static/app/components/core/compactSelect/utils.tsx @@ -510,35 +510,17 @@ export function getDuplicateOptionKeysInfo( return {duplicateOptionKeys: [...duplicates], hasSections, optionCount}; } -interface MenuWidthForLabelsOptions { - /** - * Space in em reserved for everything except the label text, such as - * padding, leading checkboxes or avatars, and trailing buttons. - */ - extraWidth: number; - /** - * Maximum menu width in em. - */ - maxWidth: number; - /** - * Minimum menu width in em. - */ - minWidth: number; -} - /** - * Computes a menu width that expands to fit the longest label, clamped between - * `minWidth` and `maxWidth`. Each character is estimated to take ~0.6em. This - * is a heuristic based on character count, not a measured width. + * Computes a menu width that expands to fit the longest label. The width is + * between 22 and 28em. Each character is estimated to take ~0.6em, and 12em is + * added to account for padding, leading checkboxes or avatars, and trailing + * buttons. This is a heuristic based on character count, not a measured width. */ -export function getMenuWidthForLabels( - labels: readonly string[], - {minWidth, maxWidth, extraWidth}: MenuWidthForLabelsOptions -): string { +export function getMenuWidthForLabels(labels: readonly string[]): string { const longestLabelLength = labels.reduce( (longest, label) => Math.max(longest, label.length), 0 ); - return `${Math.max(minWidth, Math.min(maxWidth, longestLabelLength * 0.6 + extraWidth))}em`; + return `${Math.max(22, Math.min(28, longestLabelLength * 0.6 + 12))}em`; } diff --git a/static/app/components/pageFilters/environment/environmentPageFilter.tsx b/static/app/components/pageFilters/environment/environmentPageFilter.tsx index d2ac1cb22abe..41bc50f34758 100644 --- a/static/app/components/pageFilters/environment/environmentPageFilter.tsx +++ b/static/app/components/pageFilters/environment/environmentPageFilter.tsx @@ -187,11 +187,7 @@ export function EnvironmentPageFilter({ // EnvironmentPageFilter will try to expand to accommodate the longest env slug. const defaultMenuWidth = useMemo( - () => - getMenuWidthForLabels( - options.slice(0, 25).map(option => String(option.label)), - {minWidth: 16, maxWidth: 24, extraWidth: 6} - ), + () => getMenuWidthForLabels(options.slice(0, 25).map(option => String(option.label))), [options] ); diff --git a/static/app/components/pageFilters/project/projectPageFilter.tsx b/static/app/components/pageFilters/project/projectPageFilter.tsx index dd5332ec2697..152464520197 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.tsx @@ -573,11 +573,7 @@ export function ProjectPageFilter({ // code this callback depends on. Revisit once those bailouts are fixed. // oxlint-disable-next-line react/preserve-manual-memoization const defaultMenuWidth = useMemo( - () => - getMenuWidthForLabels( - options.map(option => option.textValue ?? ''), - {minWidth: 22, maxWidth: 28, extraWidth: 12} - ), + () => getMenuWidthForLabels(options.map(option => option.textValue ?? '')), [options] ); diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx index 7c38fdb6d18a..3535fccf268d 100644 --- a/static/app/views/settings/components/settingsNavigation.tsx +++ b/static/app/views/settings/components/settingsNavigation.tsx @@ -109,10 +109,7 @@ function SettingsProjectSelector() { const selectedProject = projects.find(project => project.slug === projectId); const organization = useOrganization(); const canCreateProject = useCanCreateProject(); - const menuWidth = getMenuWidthForLabels( - projects.map(project => project.slug), - {minWidth: 22, maxWidth: 28, extraWidth: 12} - ); + const menuWidth = getMenuWidthForLabels(projects.map(project => project.slug)); return ( From 6c874406e6525d4c9cc8e3c1f1ceafd818f6b48f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:34:14 +0000 Subject: [PATCH 11/13] ref(compactSelect): Size menus by whether options have trailing actions Options with only a leading item and a label, like the environment filter and the settings project selector, get 16-24em with 6em extra width. The project page filter has bookmark and settings buttons on each option, so it opts into 22-28em with 12em extra width. Co-Authored-By: Priscila Andre de Oliveira <29228205+priscilawebdev@users.noreply.github.com> --- .../core/compactSelect/utils.spec.tsx | 20 ++++++++------ .../components/core/compactSelect/utils.tsx | 26 ++++++++++++++----- .../pageFilters/project/projectPageFilter.tsx | 6 ++++- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/static/app/components/core/compactSelect/utils.spec.tsx b/static/app/components/core/compactSelect/utils.spec.tsx index a890110af12a..cb393ed1603b 100644 --- a/static/app/components/core/compactSelect/utils.spec.tsx +++ b/static/app/components/core/compactSelect/utils.spec.tsx @@ -2,19 +2,23 @@ import {getMenuWidthForLabels} from './utils'; describe('getMenuWidthForLabels', () => { it('returns the minimum width when there are no labels', () => { - expect(getMenuWidthForLabels([])).toBe('22em'); - }); - - it('returns the minimum width for short labels', () => { - expect(getMenuWidthForLabels(['app', 'web'])).toBe('22em'); + expect(getMenuWidthForLabels([])).toBe('16em'); }); it('grows with the longest label', () => { - // 20 * 0.6 + 12 = 24 - expect(getMenuWidthForLabels(['short', 'a'.repeat(20)])).toBe('24em'); + // 20 * 0.6 + 6 = 18 + expect(getMenuWidthForLabels(['short', 'a'.repeat(20)])).toBe('18em'); }); it('caps the width at the maximum', () => { - expect(getMenuWidthForLabels(['a'.repeat(100)])).toBe('28em'); + expect(getMenuWidthForLabels(['a'.repeat(100)])).toBe('24em'); + }); + + it('reserves room for trailing actions', () => { + const options = {hasTrailingActions: true}; + expect(getMenuWidthForLabels([], options)).toBe('22em'); + // 20 * 0.6 + 12 = 24 + expect(getMenuWidthForLabels(['a'.repeat(20)], options)).toBe('24em'); + expect(getMenuWidthForLabels(['a'.repeat(100)], options)).toBe('28em'); }); }); diff --git a/static/app/components/core/compactSelect/utils.tsx b/static/app/components/core/compactSelect/utils.tsx index e71bf011a00d..4cf519f2d41c 100644 --- a/static/app/components/core/compactSelect/utils.tsx +++ b/static/app/components/core/compactSelect/utils.tsx @@ -510,17 +510,31 @@ export function getDuplicateOptionKeysInfo( return {duplicateOptionKeys: [...duplicates], hasSections, optionCount}; } +interface MenuWidthForLabelsOptions { + /** + * Reserve extra room for trailing action buttons on each option, such as + * the bookmark star and settings link in the project page filter. + */ + hasTrailingActions?: boolean; +} + /** - * Computes a menu width that expands to fit the longest label. The width is - * between 22 and 28em. Each character is estimated to take ~0.6em, and 12em is - * added to account for padding, leading checkboxes or avatars, and trailing - * buttons. This is a heuristic based on character count, not a measured width. + * Computes a menu width that expands to fit the longest label. Each character + * is estimated to take ~0.6em, plus room for padding and leading items such as + * a checkbox or avatar. This is a heuristic based on character count, not a + * measured width. */ -export function getMenuWidthForLabels(labels: readonly string[]): string { +export function getMenuWidthForLabels( + labels: readonly string[], + {hasTrailingActions = false}: MenuWidthForLabelsOptions = {} +): string { const longestLabelLength = labels.reduce( (longest, label) => Math.max(longest, label.length), 0 ); + const [minWidth, maxWidth, extraWidth] = hasTrailingActions + ? [22, 28, 12] + : [16, 24, 6]; - return `${Math.max(22, Math.min(28, longestLabelLength * 0.6 + 12))}em`; + return `${Math.max(minWidth, Math.min(maxWidth, longestLabelLength * 0.6 + extraWidth))}em`; } diff --git a/static/app/components/pageFilters/project/projectPageFilter.tsx b/static/app/components/pageFilters/project/projectPageFilter.tsx index 152464520197..1462988340ed 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.tsx @@ -573,7 +573,11 @@ export function ProjectPageFilter({ // code this callback depends on. Revisit once those bailouts are fixed. // oxlint-disable-next-line react/preserve-manual-memoization const defaultMenuWidth = useMemo( - () => getMenuWidthForLabels(options.map(option => option.textValue ?? '')), + () => + getMenuWidthForLabels( + options.map(option => option.textValue ?? ''), + {hasTrailingActions: true} + ), [options] ); From 249e4642db3a295a4bd6f823804020a0cdf49765 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:43:24 +0000 Subject: [PATCH 12/13] ref(compactSelect): Derive menu width from select options Replace getMenuWidthForLabels and its hasTrailingActions flag with getMenuWidthForOptions, which reads label text and trailingItems from the options themselves. Options with trailing items get the wider 22-28em sizing. Co-Authored-By: Priscila Andre de Oliveira <29228205+priscilawebdev@users.noreply.github.com> --- .../components/core/compactSelect/index.tsx | 2 +- .../core/compactSelect/utils.spec.tsx | 32 +++++++++++------ .../components/core/compactSelect/utils.tsx | 36 ++++++++----------- .../environment/environmentPageFilter.tsx | 4 +-- .../pageFilters/project/projectPageFilter.tsx | 11 ++---- .../components/settingsNavigation.tsx | 16 ++++----- 6 files changed, 48 insertions(+), 53 deletions(-) diff --git a/static/app/components/core/compactSelect/index.tsx b/static/app/components/core/compactSelect/index.tsx index 3f803e1fd6c6..40c809993e88 100644 --- a/static/app/components/core/compactSelect/index.tsx +++ b/static/app/components/core/compactSelect/index.tsx @@ -40,7 +40,7 @@ export { itemIsSectionWithKey, SectionToggle, getEscapedKey, - getMenuWidthForLabels, + getMenuWidthForOptions, } from './utils'; export {useVirtualizedItems} from './useVirtualizedItems'; diff --git a/static/app/components/core/compactSelect/utils.spec.tsx b/static/app/components/core/compactSelect/utils.spec.tsx index cb393ed1603b..409e2d0b1764 100644 --- a/static/app/components/core/compactSelect/utils.spec.tsx +++ b/static/app/components/core/compactSelect/utils.spec.tsx @@ -1,24 +1,34 @@ -import {getMenuWidthForLabels} from './utils'; +import {getMenuWidthForOptions} from './utils'; -describe('getMenuWidthForLabels', () => { - it('returns the minimum width when there are no labels', () => { - expect(getMenuWidthForLabels([])).toBe('16em'); +function makeOptions(labels: string[], extra: {trailingItems?: React.ReactNode} = {}) { + return labels.map(label => ({value: label, label, ...extra})); +} + +describe('getMenuWidthForOptions', () => { + it('returns the minimum width when there are no options', () => { + expect(getMenuWidthForOptions([])).toBe('16em'); }); it('grows with the longest label', () => { // 20 * 0.6 + 6 = 18 - expect(getMenuWidthForLabels(['short', 'a'.repeat(20)])).toBe('18em'); + expect(getMenuWidthForOptions(makeOptions(['short', 'a'.repeat(20)]))).toBe('18em'); }); it('caps the width at the maximum', () => { - expect(getMenuWidthForLabels(['a'.repeat(100)])).toBe('24em'); + expect(getMenuWidthForOptions(makeOptions(['a'.repeat(100)]))).toBe('24em'); + }); + + it('prefers textValue over the label', () => { + expect( + getMenuWidthForOptions([{value: 'a', label: , textValue: 'a'.repeat(20)}]) + ).toBe('18em'); }); - it('reserves room for trailing actions', () => { - const options = {hasTrailingActions: true}; - expect(getMenuWidthForLabels([], options)).toBe('22em'); + it('reserves room for trailing items', () => { + const trailing = {trailingItems: }; + expect(getMenuWidthForOptions(makeOptions(['short'], trailing))).toBe('22em'); // 20 * 0.6 + 12 = 24 - expect(getMenuWidthForLabels(['a'.repeat(20)], options)).toBe('24em'); - expect(getMenuWidthForLabels(['a'.repeat(100)], options)).toBe('28em'); + expect(getMenuWidthForOptions(makeOptions(['a'.repeat(20)], trailing))).toBe('24em'); + expect(getMenuWidthForOptions(makeOptions(['a'.repeat(100)], trailing))).toBe('28em'); }); }); diff --git a/static/app/components/core/compactSelect/utils.tsx b/static/app/components/core/compactSelect/utils.tsx index 4cf519f2d41c..543255a37314 100644 --- a/static/app/components/core/compactSelect/utils.tsx +++ b/static/app/components/core/compactSelect/utils.tsx @@ -510,31 +510,23 @@ export function getDuplicateOptionKeysInfo( return {duplicateOptionKeys: [...duplicates], hasSections, optionCount}; } -interface MenuWidthForLabelsOptions { - /** - * Reserve extra room for trailing action buttons on each option, such as - * the bookmark star and settings link in the project page filter. - */ - hasTrailingActions?: boolean; -} - /** - * Computes a menu width that expands to fit the longest label. Each character - * is estimated to take ~0.6em, plus room for padding and leading items such as - * a checkbox or avatar. This is a heuristic based on character count, not a - * measured width. + * Computes a menu width that expands to fit the longest option label. Each + * character is estimated to take ~0.6em, plus room for padding and a leading + * item such as a checkbox or avatar. Options with trailing items, such as + * bookmark or settings buttons, get more room. This is a heuristic based on + * character count, not a measured width. */ -export function getMenuWidthForLabels( - labels: readonly string[], - {hasTrailingActions = false}: MenuWidthForLabelsOptions = {} +export function getMenuWidthForOptions( + options: ReadonlyArray> ): string { - const longestLabelLength = labels.reduce( - (longest, label) => Math.max(longest, label.length), - 0 - ); - const [minWidth, maxWidth, extraWidth] = hasTrailingActions - ? [22, 28, 12] - : [16, 24, 6]; + const longestLabelLength = options.reduce((longest, option) => { + const text = + option.textValue ?? (typeof option.label === 'string' ? option.label : ''); + return Math.max(longest, text.length); + }, 0); + const hasTrailingItems = options.some(option => defined(option.trailingItems)); + const [minWidth, maxWidth, extraWidth] = hasTrailingItems ? [22, 28, 12] : [16, 24, 6]; return `${Math.max(minWidth, Math.min(maxWidth, longestLabelLength * 0.6 + extraWidth))}em`; } diff --git a/static/app/components/pageFilters/environment/environmentPageFilter.tsx b/static/app/components/pageFilters/environment/environmentPageFilter.tsx index 41bc50f34758..4f40a1f65bd6 100644 --- a/static/app/components/pageFilters/environment/environmentPageFilter.tsx +++ b/static/app/components/pageFilters/environment/environmentPageFilter.tsx @@ -5,7 +5,7 @@ import isEqual from 'lodash/isEqual'; import { CompactSelect, - getMenuWidthForLabels, + getMenuWidthForOptions, MenuComponents, } from '@sentry/scraps/compactSelect'; import type {MultipleSelectProps} from '@sentry/scraps/compactSelect'; @@ -187,7 +187,7 @@ export function EnvironmentPageFilter({ // EnvironmentPageFilter will try to expand to accommodate the longest env slug. const defaultMenuWidth = useMemo( - () => getMenuWidthForLabels(options.slice(0, 25).map(option => String(option.label))), + () => getMenuWidthForOptions(options.slice(0, 25)), [options] ); diff --git a/static/app/components/pageFilters/project/projectPageFilter.tsx b/static/app/components/pageFilters/project/projectPageFilter.tsx index 1462988340ed..b8efbce50e20 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.tsx @@ -13,7 +13,7 @@ import xor from 'lodash/xor'; import {LinkButton} from '@sentry/scraps/button'; import { CompactSelect, - getMenuWidthForLabels, + getMenuWidthForOptions, MenuComponents, } from '@sentry/scraps/compactSelect'; import type {MultipleSelectProps, SelectOption} from '@sentry/scraps/compactSelect'; @@ -572,14 +572,7 @@ export function ProjectPageFilter({ // React Compiler could not prove this memoization is preserved; it bails out on // code this callback depends on. Revisit once those bailouts are fixed. // oxlint-disable-next-line react/preserve-manual-memoization - const defaultMenuWidth = useMemo( - () => - getMenuWidthForLabels( - options.map(option => option.textValue ?? ''), - {hasTrailingActions: true} - ), - [options] - ); + const defaultMenuWidth = useMemo(() => getMenuWidthForOptions(options), [options]); const canCreateProject = useCanCreateProject(); diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx index 3535fccf268d..fee8f142aa4f 100644 --- a/static/app/views/settings/components/settingsNavigation.tsx +++ b/static/app/views/settings/components/settingsNavigation.tsx @@ -6,7 +6,7 @@ import {IconAdd} from '@sentry/icons/add'; import {ProjectAvatar} from '@sentry/scraps/avatar'; import { CompactSelect, - getMenuWidthForLabels, + getMenuWidthForOptions, MenuComponents, } from '@sentry/scraps/compactSelect'; import {Container} from '@sentry/scraps/layout'; @@ -109,7 +109,11 @@ function SettingsProjectSelector() { const selectedProject = projects.find(project => project.slug === projectId); const organization = useOrganization(); const canCreateProject = useCanCreateProject(); - const menuWidth = getMenuWidthForLabels(projects.map(project => project.slug)); + const options = projects.map(project => ({ + value: project.slug, + label: project.slug, + leadingItems: , + })); return ( @@ -118,7 +122,7 @@ function SettingsProjectSelector() { {...containerProps} size="sm" position="bottom-start" - menuWidth={menuWidth} + menuWidth={getMenuWidthForOptions(options)} preventOverflowOptions={{boundary: 'clippingAncestors'}} value={projectId} trigger={triggerProps => ( @@ -157,11 +161,7 @@ function SettingsProjectSelector() { {t('Create Project')} } - options={projects.map(project => ({ - value: project.slug, - label: project.slug, - leadingItems: , - }))} + options={options} onChange={selection => navigate( normalizeUrl( From ba6d75bcb68f8eef25367d1675f1458c717d0a63 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 3 Nov 2026 11:13:43 +0100 Subject: [PATCH 13/13] ref(settings): Use a fixed menu width for the settings project select Drop the shared menu width helper and give the settings project select a fixed 24em menu, matching the environment page filter's max width. --- .../components/core/compactSelect/index.tsx | 1 - .../core/compactSelect/utils.spec.tsx | 34 ------------------- .../components/core/compactSelect/utils.tsx | 21 ------------ .../environment/environmentPageFilter.tsx | 27 +++++++++------ .../pageFilters/project/projectPageFilter.tsx | 23 +++++++++---- .../components/settingsNavigation.tsx | 20 +++++------ 6 files changed, 42 insertions(+), 84 deletions(-) delete mode 100644 static/app/components/core/compactSelect/utils.spec.tsx diff --git a/static/app/components/core/compactSelect/index.tsx b/static/app/components/core/compactSelect/index.tsx index 40c809993e88..f55f6e071427 100644 --- a/static/app/components/core/compactSelect/index.tsx +++ b/static/app/components/core/compactSelect/index.tsx @@ -40,7 +40,6 @@ export { itemIsSectionWithKey, SectionToggle, getEscapedKey, - getMenuWidthForOptions, } from './utils'; export {useVirtualizedItems} from './useVirtualizedItems'; diff --git a/static/app/components/core/compactSelect/utils.spec.tsx b/static/app/components/core/compactSelect/utils.spec.tsx deleted file mode 100644 index 409e2d0b1764..000000000000 --- a/static/app/components/core/compactSelect/utils.spec.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import {getMenuWidthForOptions} from './utils'; - -function makeOptions(labels: string[], extra: {trailingItems?: React.ReactNode} = {}) { - return labels.map(label => ({value: label, label, ...extra})); -} - -describe('getMenuWidthForOptions', () => { - it('returns the minimum width when there are no options', () => { - expect(getMenuWidthForOptions([])).toBe('16em'); - }); - - it('grows with the longest label', () => { - // 20 * 0.6 + 6 = 18 - expect(getMenuWidthForOptions(makeOptions(['short', 'a'.repeat(20)]))).toBe('18em'); - }); - - it('caps the width at the maximum', () => { - expect(getMenuWidthForOptions(makeOptions(['a'.repeat(100)]))).toBe('24em'); - }); - - it('prefers textValue over the label', () => { - expect( - getMenuWidthForOptions([{value: 'a', label: , textValue: 'a'.repeat(20)}]) - ).toBe('18em'); - }); - - it('reserves room for trailing items', () => { - const trailing = {trailingItems: }; - expect(getMenuWidthForOptions(makeOptions(['short'], trailing))).toBe('22em'); - // 20 * 0.6 + 12 = 24 - expect(getMenuWidthForOptions(makeOptions(['a'.repeat(20)], trailing))).toBe('24em'); - expect(getMenuWidthForOptions(makeOptions(['a'.repeat(100)], trailing))).toBe('28em'); - }); -}); diff --git a/static/app/components/core/compactSelect/utils.tsx b/static/app/components/core/compactSelect/utils.tsx index 543255a37314..4d01cbf0c6f7 100644 --- a/static/app/components/core/compactSelect/utils.tsx +++ b/static/app/components/core/compactSelect/utils.tsx @@ -509,24 +509,3 @@ export function getDuplicateOptionKeysInfo( collect(items); return {duplicateOptionKeys: [...duplicates], hasSections, optionCount}; } - -/** - * Computes a menu width that expands to fit the longest option label. Each - * character is estimated to take ~0.6em, plus room for padding and a leading - * item such as a checkbox or avatar. Options with trailing items, such as - * bookmark or settings buttons, get more room. This is a heuristic based on - * character count, not a measured width. - */ -export function getMenuWidthForOptions( - options: ReadonlyArray> -): string { - const longestLabelLength = options.reduce((longest, option) => { - const text = - option.textValue ?? (typeof option.label === 'string' ? option.label : ''); - return Math.max(longest, text.length); - }, 0); - const hasTrailingItems = options.some(option => defined(option.trailingItems)); - const [minWidth, maxWidth, extraWidth] = hasTrailingItems ? [22, 28, 12] : [16, 24, 6]; - - return `${Math.max(minWidth, Math.min(maxWidth, longestLabelLength * 0.6 + extraWidth))}em`; -} diff --git a/static/app/components/pageFilters/environment/environmentPageFilter.tsx b/static/app/components/pageFilters/environment/environmentPageFilter.tsx index 4f40a1f65bd6..006b003a723b 100644 --- a/static/app/components/pageFilters/environment/environmentPageFilter.tsx +++ b/static/app/components/pageFilters/environment/environmentPageFilter.tsx @@ -3,11 +3,7 @@ import {useMatches} from 'react-router'; import {isAppleDevice} from '@react-aria/utils'; import isEqual from 'lodash/isEqual'; -import { - CompactSelect, - getMenuWidthForOptions, - MenuComponents, -} from '@sentry/scraps/compactSelect'; +import {CompactSelect, MenuComponents} from '@sentry/scraps/compactSelect'; import type {MultipleSelectProps} from '@sentry/scraps/compactSelect'; import {InfoTip} from '@sentry/scraps/info'; import {Flex} from '@sentry/scraps/layout'; @@ -185,11 +181,22 @@ export function EnvironmentPageFilter({ [environments] ); - // EnvironmentPageFilter will try to expand to accommodate the longest env slug. - const defaultMenuWidth = useMemo( - () => getMenuWidthForOptions(options.slice(0, 25)), - [options] - ); + const defaultMenuWidth = useMemo(() => { + // EnvironmentPageFilter will try to expand to accommodate the longest env slug + const longestSlugLength = options + .slice(0, 25) + .reduce( + (acc, cur) => (String(cur.label).length > acc ? String(cur.label).length : acc), + 0 + ); + + // Calculate an appropriate width for the menu. It should be between 16 and 24em. + // Within that range, the width is a function of the length of the longest slug. + // The environment slugs take up to (longestSlugLength * 0.6)em of horizontal space + // (each character occupies roughly 0.6em). We also need to add 6em to account for + // the checkbox and menu paddings. + return `${Math.max(16, Math.min(24, longestSlugLength * 0.6 + 6))}em`; + }, [options]); const stagedSelect = useStagedCompactSelect({ value, diff --git a/static/app/components/pageFilters/project/projectPageFilter.tsx b/static/app/components/pageFilters/project/projectPageFilter.tsx index b8efbce50e20..ad6adc04e486 100644 --- a/static/app/components/pageFilters/project/projectPageFilter.tsx +++ b/static/app/components/pageFilters/project/projectPageFilter.tsx @@ -11,11 +11,7 @@ import sortBy from 'lodash/sortBy'; import xor from 'lodash/xor'; import {LinkButton} from '@sentry/scraps/button'; -import { - CompactSelect, - getMenuWidthForOptions, - MenuComponents, -} from '@sentry/scraps/compactSelect'; +import {CompactSelect, MenuComponents} from '@sentry/scraps/compactSelect'; import type {MultipleSelectProps, SelectOption} from '@sentry/scraps/compactSelect'; import {InfoTip} from '@sentry/scraps/info'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; @@ -572,7 +568,7 @@ export function ProjectPageFilter({ // React Compiler could not prove this memoization is preserved; it bails out on // code this callback depends on. Revisit once those bailouts are fixed. // oxlint-disable-next-line react/preserve-manual-memoization - const defaultMenuWidth = useMemo(() => getMenuWidthForOptions(options), [options]); + const defaultMenuWidth = useMemo(() => computeMenuWidth(options), [options]); const canCreateProject = useCanCreateProject(); @@ -842,6 +838,21 @@ function toURLSelection({ return value; } +function computeMenuWidth(options: Array>): string { + // ProjectPageFilter will try to expand to accommodate the longest project slug + const longestSlugLength = options.reduce((acc, cur) => { + const length = cur.textValue?.length ?? 0; + return length > acc ? length : acc; + }, 0); + + // Calculate an appropriate width for the menu. It should be between 22 and 28em. + // Within that range, the width is a function of the length of the longest slug. + // The project slugs take up to (longestSlugLength * 0.6)em of horizontal space + // (each character occupies roughly 0.6em). + // We also need to add 12em to account for padding, trailing buttons, and the checkbox. + return `${Math.max(22, Math.min(28, longestSlugLength * 0.6 + 12))}em`; +} + // Helper functions for data selection function memberProjects(projects: Project[]): Project[] { return projects.filter(project => project.isMember); diff --git a/static/app/views/settings/components/settingsNavigation.tsx b/static/app/views/settings/components/settingsNavigation.tsx index fee8f142aa4f..a2ad32970740 100644 --- a/static/app/views/settings/components/settingsNavigation.tsx +++ b/static/app/views/settings/components/settingsNavigation.tsx @@ -4,11 +4,7 @@ import {mergeProps} from '@react-aria/utils'; import {IconAdd} from '@sentry/icons/add'; import {ProjectAvatar} from '@sentry/scraps/avatar'; -import { - CompactSelect, - getMenuWidthForOptions, - MenuComponents, -} from '@sentry/scraps/compactSelect'; +import {CompactSelect, MenuComponents} from '@sentry/scraps/compactSelect'; import {Container} from '@sentry/scraps/layout'; import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; import {Text} from '@sentry/scraps/text'; @@ -109,11 +105,6 @@ function SettingsProjectSelector() { const selectedProject = projects.find(project => project.slug === projectId); const organization = useOrganization(); const canCreateProject = useCanCreateProject(); - const options = projects.map(project => ({ - value: project.slug, - label: project.slug, - leadingItems: , - })); return ( @@ -122,7 +113,8 @@ function SettingsProjectSelector() { {...containerProps} size="sm" position="bottom-start" - menuWidth={getMenuWidthForOptions(options)} + // Same max width as the environment page filter menu + menuWidth="24em" preventOverflowOptions={{boundary: 'clippingAncestors'}} value={projectId} trigger={triggerProps => ( @@ -161,7 +153,11 @@ function SettingsProjectSelector() { {t('Create Project')} } - options={options} + options={projects.map(project => ({ + value: project.slug, + label: project.slug, + leadingItems: , + }))} onChange={selection => navigate( normalizeUrl(