fix(devex): Env switcher fix + combobox updates (#36176)
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 53 KiB |
BIN
frontend/__snapshots__/ui-combobox--in-popover--dark.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/__snapshots__/ui-combobox--in-popover--light.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||