fix(devex): Env switcher fix + combobox updates (#36176)

This commit is contained in:
Adam Leith
2025-08-06 09:07:52 +01:00
committed by GitHub
parent 3eb52a0c9f
commit 37b231360d
22 changed files with 160 additions and 56 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -159,6 +159,18 @@ export function EnvironmentSwitcherOverlay({
const projectNameWithoutEmoji = projectName.replace(EMOJI_INITIAL_REGEX, '').trim()
const projectNameEmojiMatch = projectName.match(EMOJI_INITIAL_REGEX)?.[1]
// Add "Other projects" label just once, before any other projects are added
if (projectTeams.length > 0 && otherProjectsItems.length === 0) {
otherProjectsItems.push(
<>
<Label intent="menu" className="px-2">
Other projects
</Label>
<div className="-mx-1.5 my-1 h-px bg-border-primary shrink-0" />
</>
)
}
otherProjectsItems.push(
<>
<Combobox.Group value={[projectName]} key={projectId}>
@@ -206,17 +218,6 @@ export function EnvironmentSwitcherOverlay({
</Combobox.Group>
</>
)
// Only show the other projects label if there are other projects
if (otherProjectsItems.length > 0) {
otherProjectsItems.splice(
0,
0,
<Label intent="menu" className="px-2" key="other-projects-label">
Other projects
</Label>,
<div className="-mx-1 my-1 h-px bg-border-primary shrink-0" />
)
}
for (const team of projectTeams) {
otherProjectsItems.push(convertTeamToMenuItem(team, currentTeam))
}

View File

@@ -1,7 +1,15 @@
import type { Meta } from '@storybook/react'
import { ButtonPrimitive } from '../Button/ButtonPrimitives'
import { ButtonGroupPrimitive, ButtonPrimitive } from '../Button/ButtonPrimitives'
import { Combobox } from './Combobox'
import { Link } from 'lib/lemon-ui/Link'
import { IconGear, IconPlusSmall } from '@posthog/icons'
import {
PopoverPrimitive,
PopoverPrimitiveContent,
PopoverPrimitiveTrigger,
} from '../PopoverPrimitive/PopoverPrimitive'
import { DropdownMenuOpenIndicator } from '../DropdownMenu/DropdownMenu'
const meta = {
title: 'UI/Combobox',
@@ -11,34 +19,122 @@ const meta = {
export default meta
function RenderCombobox(): JSX.Element {
return (
<Combobox>
<Combobox.Search placeholder="Search this list..." autoFocus />
{/* For styling the list items */}
<Combobox.Content className="max-w-[300px] max-h-none">
<Combobox.Empty>No searchable groups match</Combobox.Empty>
{/* responsible for filtering the list items */}
{/* can pass in an array of values to filter by */}
<Combobox.Group value={['Pineapple', 'belongs on pizza']}>
{/* what we actually get as focus */}
{/* eslint-disable-next-line no-console */}
<Combobox.Item asChild onClick={() => console.log('clicked Pineapple')}>
<ButtonPrimitive menuItem>Searchable: Pineapple</ButtonPrimitive>
</Combobox.Item>
</Combobox.Group>
{/* Groups with no value are "static" and don't affect Empty state */}
<Combobox.Group>
<div className="-mx-1 my-1 h-px bg-border-primary shrink-0" />
</Combobox.Group>
<Combobox.Group value={['Banana']}>
{/* eslint-disable-next-line no-console */}
<Combobox.Item asChild onClick={() => console.log('clicked Banana')}>
<ButtonPrimitive menuItem>Searchable: Banana</ButtonPrimitive>
</Combobox.Item>
</Combobox.Group>
<div className="-mx-1 my-1 h-px bg-border-primary shrink-0" />
<Combobox.Group value={['projectName']}>
<ButtonGroupPrimitive fullWidth className="[&>span]:contents">
<Combobox.Item asChild>
<ButtonPrimitive menuItem hasSideActionRight className="pr-12" disabled>
<span className="truncate">Disabled main button</span>
</ButtonPrimitive>
</Combobox.Item>
<Combobox.Item asChild>
<Link
buttonProps={{
iconOnly: true,
isSideActionRight: true,
}}
tooltip="Visit posthog's website"
tooltipPlacement="right"
to="https://posthog.com"
>
<IconGear className="text-tertiary" />
</Link>
</Combobox.Item>
</ButtonGroupPrimitive>
</Combobox.Group>
<Combobox.Item asChild onClick={() => alert('clicked')}>
<ButtonPrimitive menuItem className="shrink-0">
<IconPlusSmall className="text-tertiary" />
Static: Add item
</ButtonPrimitive>
</Combobox.Item>
</Combobox.Content>
</Combobox>
)
}
export function Default(): JSX.Element {
return (
<div className="flex gap-4">
<Combobox>
<Combobox.Search placeholder="Search this list..." />
<RenderCombobox />
<Combobox.Empty>No results found</Combobox.Empty>
{/* For styling the list items */}
<Combobox.Content>
{/* responsible for filtering the list items */}
{/* can pass in an array of values to filter by */}
<Combobox.Group value={['Pineapple', 'belongs on pizza']}>
{/* what we actually get as focus */}
{/* eslint-disable-next-line no-console */}
<Combobox.Item asChild onClick={() => console.log('clicked Pineapple')}>
<ButtonPrimitive menuItem>Pineapple</ButtonPrimitive>
</Combobox.Item>
</Combobox.Group>
<Combobox.Group value={['Banana']}>
{/* eslint-disable-next-line no-console */}
<Combobox.Item asChild onClick={() => console.log('clicked Banana')}>
<ButtonPrimitive menuItem>Banana</ButtonPrimitive>
</Combobox.Item>
</Combobox.Group>
</Combobox.Content>
</Combobox>
<div className="max-w-[500px]">
<p className="font-semibold">This is a combo box</p>
<p className="text-sm text-tertiary mb-2">
Try searching for something that doesn't match "Pineapple" or "Banana" to see the Empty state
</p>
<ul className="list-disc pl-4">
<li>When focused, focus never leaves the search when pressing up or down</li>
<li>Combobox.Group is for grouped searchable values, does not hold focus</li>
<li>
Combobox.Item holds no value, but each Combobox.Item will be available via up down keys, attach
your listeners or use as a link
</li>
<li>
Groups with no <code>value</code> prop are considered "static" and always show but don't prevent
Empty state
</li>
<li>Combobox.Empty now shows when no searchable groups match, even if static groups are visible</li>
<li>
You can see in the last combobox group that the "side action" can get focus via keyboard because
it's wrapped in a Combobox.Item
</li>
<li>
Available keyboard listeners: <kbd>down</kbd>, <kbd>up</kbd>, <kbd>home</kbd>, & <kbd>end</kbd>{' '}
(provided via <code>Listbox.tsx</code>)
</li>
</ul>
</div>
</div>
)
}
export function InPopover(): JSX.Element {
return (
<div className="flex gap-4">
<PopoverPrimitive>
<PopoverPrimitiveTrigger asChild>
<ButtonPrimitive data-attr="environment-switcher-button" size="sm">
Trigger popover
<DropdownMenuOpenIndicator />
</ButtonPrimitive>
</PopoverPrimitiveTrigger>
<PopoverPrimitiveContent align="start">
<RenderCombobox />
</PopoverPrimitiveContent>
</PopoverPrimitive>
</div>
)
}

View File

@@ -21,9 +21,9 @@ import { ScrollableShadows } from 'lib/components/ScrollableShadows/ScrollableSh
interface ComboboxContextType {
searchValue: string
setSearchValue: (value: string) => void
registerGroup: (id: string, visible: boolean) => void
registerGroup: (id: string, visible: boolean, isSearchable?: boolean) => void
unregisterGroup: (id: string) => void
getVisibleGroupCount: () => number
getVisibleSearchableGroupCount: () => number
}
const ComboboxContext = createContext<ComboboxContextType | null>(null)
@@ -37,16 +37,21 @@ const InnerCombobox = forwardRef<ListBoxHandle, ComboboxProps>(({ children, clas
const [searchValue, setSearchValue] = useState('')
// Pure-react group visibility state
type Action = { type: 'register'; id: string; visible: boolean } | { type: 'unregister'; id: string }
type Action =
| { type: 'register'; id: string; visible: boolean; isSearchable?: boolean }
| { type: 'unregister'; id: string }
type State = Map<string, boolean>
type State = Map<string, { visible: boolean; isSearchable: boolean }>
const groupReducer = (state: State, action: Action): State => {
const newState = new Map(state)
switch (action.type) {
case 'register': {
newState.set(action.id, action.visible)
newState.set(action.id, {
visible: action.visible,
isSearchable: action.isSearchable ?? true,
})
return newState
}
case 'unregister': {
@@ -60,16 +65,16 @@ const InnerCombobox = forwardRef<ListBoxHandle, ComboboxProps>(({ children, clas
const [groupVisibility, dispatch] = useReducer(groupReducer, new Map())
const registerGroup = useCallback((id: string, visible: boolean): void => {
dispatch({ type: 'register', id, visible })
const registerGroup = useCallback((id: string, visible: boolean, isSearchable = true): void => {
dispatch({ type: 'register', id, visible, isSearchable })
}, [])
const unregisterGroup = useCallback((id: string): void => {
dispatch({ type: 'unregister', id })
}, [])
const getVisibleGroupCount = useCallback((): number => {
return Array.from(groupVisibility.values()).filter(Boolean).length
const getVisibleSearchableGroupCount = useCallback((): number => {
return Array.from(groupVisibility.values()).filter((group) => group.visible && group.isSearchable).length
}, [groupVisibility])
const contextValue = useMemo(
@@ -78,9 +83,9 @@ const InnerCombobox = forwardRef<ListBoxHandle, ComboboxProps>(({ children, clas
setSearchValue,
registerGroup,
unregisterGroup,
getVisibleGroupCount,
getVisibleSearchableGroupCount,
}),
[searchValue, registerGroup, unregisterGroup, getVisibleGroupCount]
[searchValue, registerGroup, unregisterGroup, getVisibleSearchableGroupCount]
)
useImperativeHandle(ref, () => ({
@@ -150,7 +155,7 @@ const Search = ({ placeholder = 'Search...', className, autoFocus = true }: Sear
let groupIdCounter = 0
interface GroupProps {
value: string[]
value?: string[]
children: ReactNode
}
@@ -164,15 +169,16 @@ const Group = ({ value, children }: GroupProps): JSX.Element | null => {
const idRef = useRef<string>(`group-${groupIdCounter++}`)
const lowerSearch = searchValue.toLowerCase()
const match = value.some((v) => v.toLowerCase().includes(lowerSearch))
const match = !value?.length || value.some((v) => v.toLowerCase().includes(lowerSearch))
const isSearchable = !!value?.length
useEffect(() => {
const id = idRef.current
registerGroup(id, match)
registerGroup(id, match, isSearchable)
return () => {
unregisterGroup(id)
}
}, [match, registerGroup, unregisterGroup])
}, [match, isSearchable, registerGroup, unregisterGroup])
if (!match) {
return null
@@ -191,8 +197,8 @@ const Empty = ({ children }: EmptyProps): JSX.Element | null => {
throw new Error('Combobox.Empty must be used inside Combobox')
}
return context.getVisibleGroupCount() === 0 ? (
<ButtonPrimitive className="text-tertiary text-center" role="alert">
return context.getVisibleSearchableGroupCount() === 0 ? (
<ButtonPrimitive className="text-tertiary text-center" role="alert" menuItem inert>
{children}
</ButtonPrimitive>
) : null
@@ -207,14 +213,15 @@ const Content = ({ className, children }: ContentProps): JSX.Element => {
return (
<div
className={cn(
'primitive-menu-content max-h-[calc(var(--radix-popover-content-available-height)-var(--combobox-search-height)-var(--radix-popper-anchor-height))] max-w-none border-transparent',
'max-h-[calc(var(--radix-popover-content-available-height)-var(--combobox-search-height)-var(--radix-popper-anchor-height))] h-full max-w-none border-transparent',
className
)}
>
<ScrollableShadows
direction="vertical"
styledScrollbars
innerClassName="primitive-menu-content-inner flex flex-col gap-px"
className="h-full"
innerClassName="flex flex-col gap-px p-1"
>
{children}
</ScrollableShadows>

View File

@@ -688,7 +688,7 @@
--lettermark-1-text: #572e5e;
--lettermark-2-bg: #ffc4b2;
--lettermark-2-text: #3e5891;
--lettermark-3-bg: #fdedc9;
--lettermark-3-bg: #b1985d;
--lettermark-3-text: #3e5891;
--lettermark-4-bg: #3e5891;
--lettermark-4-text: #ffc4b2;