Skip to content

Commit d24b959

Browse files
NicolappsConvex, Inc.
authored andcommitted
dashboard: Update to Headless UI 2 (#43136)
This PR updates the version of Headless UI that the dashboard uses from 1.7 to 2.1. GitOrigin-RevId: 82f09a75548e8d198eef680a09641f146178ebb4
1 parent 09f465b commit d24b959

File tree

35 files changed

+700
-371
lines changed

35 files changed

+700
-371
lines changed

npm-packages/@convex-dev/design-system/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
},
2323
"dependencies": {
2424
"@fontsource-variable/inter": "5.0.3",
25-
"@headlessui/react": "1.7.19",
25+
"@headlessui/react": "^2.2.9",
2626
"@radix-ui/react-tooltip": "~1.2.0",
2727
"classnames": "^2.3.2",
2828
"clsx": "2.1.1",
@@ -61,6 +61,7 @@
6161
"eslint-plugin-react-hooks": "^4.6.2",
6262
"jest": "^29.6.0",
6363
"jest-environment-jsdom": "^29.5.0",
64+
"jsdom-testing-mocks": "^1.16.0",
6465
"postcss": "^8.4.19",
6566
"prettier": "3.6.2",
6667
"prettier-plugin-tailwindcss": "~0.6.11",

npm-packages/@convex-dev/design-system/setupTests.ts

Lines changed: 0 additions & 7 deletions
This file was deleted.

npm-packages/@convex-dev/design-system/src/Combobox.tsx

Lines changed: 87 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
1+
import { omit, isEqual } from "lodash-es";
12
import { useState, useEffect } from "react";
2-
import { Combobox as HeadlessCombobox } from "@headlessui/react";
3+
import {
4+
Combobox as HeadlessCombobox,
5+
ComboboxButton as HeadlessComboboxButton,
6+
ComboboxInput as HeadlessComboboxInput,
7+
ComboboxOption as HeadlessComboboxOption,
8+
ComboboxOptions as HeadlessComboboxOptions,
9+
Label,
10+
} from "@headlessui/react";
311
import { ChevronDownIcon, MagnifyingGlassIcon } from "@radix-ui/react-icons";
412
import { cn } from "@ui/cn";
5-
import { isEqual } from "lodash-es";
613
import fuzzy from "fuzzy";
714
import { Button, ButtonProps } from "@ui/Button";
815
import { createPortal } from "react-dom";
916
import { usePopper } from "react-popper";
17+
import { Tooltip } from "./Tooltip";
1018

1119
const { test } = fuzzy;
1220

@@ -156,65 +164,84 @@ export function Combobox<T>({
156164

157165
return (
158166
<>
159-
<HeadlessCombobox.Label
167+
<Label
160168
hidden={labelHidden}
161169
className="text-left text-sm text-content-primary"
162170
>
163171
{label}
164-
</HeadlessCombobox.Label>
172+
</Label>
165173
<div className={cn("relative", className)}>
166174
<div
167175
ref={setReferenceElement}
168176
className={cn("relative flex w-60 items-center", buttonClasses)}
169177
>
170-
<HeadlessCombobox.Button
171-
as={Button}
172-
variant="unstyled"
173-
data-testid={`combobox-button-${label}`}
174-
className={cn(
175-
"group flex w-full items-center gap-1",
176-
"relative truncate rounded-md text-left text-content-primary disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-background-secondary",
177-
"border bg-background-secondary text-sm focus-visible:z-10 focus-visible:border-border-selected focus-visible:outline-hidden",
178-
"hover:bg-background-tertiary",
179-
"cursor-pointer",
180-
open && "z-10 border-border-selected",
181-
size === "sm" && "px-1.5 py-1 text-xs",
182-
size === "md" && "p-1.5",
183-
innerButtonClasses,
184-
)}
185-
{...buttonProps}
178+
<Tooltip
179+
tip={buttonProps?.tip}
180+
side={buttonProps?.tipSide}
181+
disableHoverableContent={
182+
buttonProps?.tipDisableHoverableContent
183+
}
184+
asChild
186185
>
187-
{icon}
188-
<div className="truncate">
189-
{!!Option && !!selectedOptionData ? (
190-
<Option
191-
inButton
192-
label={selectedOptionData.label}
193-
value={selectedOptionData.value}
194-
disabled={selectedOptionData.disabled}
195-
/>
196-
) : (
197-
selectedOptionData?.label || (
186+
<HeadlessComboboxButton
187+
as={Button}
188+
variant="unstyled"
189+
data-testid={`combobox-button-${label}`}
190+
className={cn(
191+
"group flex w-full items-center gap-1",
192+
"relative truncate rounded-md text-left text-content-primary disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-background-secondary",
193+
"border bg-background-secondary text-sm focus-visible:z-10 focus-visible:border-border-selected focus-visible:outline-hidden",
194+
"hover:bg-background-tertiary",
195+
"cursor-pointer",
196+
open && "z-10 border-border-selected",
197+
size === "sm" && "px-1.5 py-1 text-xs",
198+
size === "md" && "p-1.5",
199+
innerButtonClasses,
200+
)}
201+
{
202+
// <HeadlessComboboxButton as={Button} tip="…" /> causes a state update loop since Headless UI 2.0
203+
// (presumably because Headless UI and the tooltip both want to update the ref).
204+
// To circumvent this issue, we place the <Tooltip /> component as a parent of <HeadlessComboboxButton />
205+
...omit(
206+
buttonProps,
207+
"tip",
208+
"tipSide",
209+
"tipDisableHoverableContent",
210+
)
211+
}
212+
>
213+
{icon}
214+
<div className="truncate">
215+
{!!Option && !!selectedOptionData ? (
216+
<Option
217+
inButton
218+
label={selectedOptionData.label}
219+
value={selectedOptionData.value}
220+
disabled={selectedOptionData.disabled}
221+
/>
222+
) : (
223+
selectedOptionData?.label || (
224+
<span className="text-content-tertiary">
225+
{selectedOption && unknownLabel(selectedOption)}
226+
</span>
227+
)
228+
)}
229+
{!selectedOptionData && (
198230
<span className="text-content-tertiary">
199-
{selectedOption && unknownLabel(selectedOption)}
231+
{placeholder}
200232
</span>
201-
)
202-
)}
203-
{!selectedOptionData && (
204-
<span className="text-content-tertiary">
205-
{placeholder}
206-
</span>
207-
)}
208-
</div>
209-
{size === "md" && (
210-
<ChevronDownIcon
211-
className={cn(
212-
"ml-auto size-4 text-content-primary transition-all",
213-
open && "rotate-180",
214233
)}
215-
/>
216-
)}
217-
</HeadlessCombobox.Button>
234+
</div>
235+
{size === "md" && (
236+
<ChevronDownIcon
237+
className={cn(
238+
"ml-auto size-4 text-content-primary transition-all",
239+
open && "rotate-180",
240+
)}
241+
/>
242+
)}
243+
</HeadlessComboboxButton>
244+
</Tooltip>
218245
</div>
219246
{open &&
220247
createPortal(
@@ -227,7 +254,8 @@ export function Combobox<T>({
227254
{...attributes.popper}
228255
className="z-50"
229256
>
230-
<HeadlessCombobox.Options
257+
<HeadlessComboboxOptions
258+
modal={false}
231259
static
232260
className={cn(
233261
"mt-1 scrollbar max-h-[14.75rem] overflow-auto rounded-md border bg-background-secondary pb-1 text-xs shadow-sm",
@@ -243,7 +271,7 @@ export function Combobox<T>({
243271
{!disableSearch && (
244272
<div className="sticky top-0 z-10 flex w-full items-center gap-2 border-b bg-background-secondary px-3 pt-1">
245273
<MagnifyingGlassIcon className="text-content-secondary" />
246-
<HeadlessCombobox.Input
274+
<HeadlessComboboxInput
247275
onChange={(event) => setQuery(event.target.value)}
248276
value={query}
249277
autoFocus
@@ -256,14 +284,14 @@ export function Combobox<T>({
256284
</div>
257285
)}
258286
{displayedOptions.map((option, idx) => (
259-
<HeadlessCombobox.Option
287+
<HeadlessComboboxOption
260288
key={idx}
261289
value={option.value}
262290
disabled={option.disabled}
263-
className={({ active }) =>
291+
className={({ focus }) =>
264292
cn(
265293
"relative w-fit min-w-full cursor-pointer px-3 py-1.5 text-content-primary select-none",
266-
active && "bg-background-tertiary",
294+
focus && "bg-background-tertiary",
267295
option.disabled &&
268296
"cursor-not-allowed text-content-secondary opacity-75",
269297
)
@@ -287,7 +315,7 @@ export function Combobox<T>({
287315
)}
288316
</span>
289317
)}
290-
</HeadlessCombobox.Option>
318+
</HeadlessComboboxOption>
291319
))}
292320

293321
{hasMoreThanMaxOptions && (
@@ -301,16 +329,16 @@ export function Combobox<T>({
301329
{allowCustomValue &&
302330
query.length > 0 &&
303331
!filtered.some((x) => x.value === query) && (
304-
<HeadlessCombobox.Option
332+
<HeadlessComboboxOption
305333
value={query}
306-
className={({ active }) =>
334+
className={({ focus }) =>
307335
`text-content-primary relative cursor-pointer w-60 select-none py-1 px-3 text-xs ${
308-
active ? "bg-background-tertiary" : ""
336+
focus ? "bg-background-tertiary" : ""
309337
}`
310338
}
311339
>
312340
Unknown option: "{query}"
313-
</HeadlessCombobox.Option>
341+
</HeadlessComboboxOption>
314342
)}
315343

316344
{filtered.length === 0 && !allowCustomValue && (
@@ -319,7 +347,7 @@ export function Combobox<T>({
319347
</div>
320348
)}
321349
</div>
322-
</HeadlessCombobox.Options>
350+
</HeadlessComboboxOptions>
323351
</div>,
324352
document.body,
325353
)}

npm-packages/@convex-dev/design-system/src/Menu.tsx

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
import { Fragment, ReactNode, useState } from "react";
2-
import { Menu as HeadlessMenu, Portal } from "@headlessui/react";
1+
import { omit } from "lodash-es";
2+
import { ReactNode, useState } from "react";
3+
import {
4+
Menu as HeadlessMenu,
5+
MenuButton as HeadlessMenuButton,
6+
MenuItems as HeadlessMenuItems,
7+
MenuItem as HeadlessMenuItem,
8+
Portal,
9+
} from "@headlessui/react";
310
import { PopperChildrenProps, usePopper } from "react-popper";
411
import classNames from "classnames";
512
import { Button, ButtonProps } from "@ui/Button";
613
import { Key, KeyboardShortcut } from "@ui/KeyboardShortcut";
7-
import { TooltipSide } from "@ui/Tooltip";
14+
import { Tooltip, TooltipSide } from "@ui/Tooltip";
815

916
export type MenuProps = {
1017
children: React.ReactElement | (React.ReactElement | null)[];
@@ -21,7 +28,7 @@ export function Menu({
2128
}: MenuProps) {
2229
const [referenceElement, setReferenceElement] =
2330
useState<HTMLButtonElement | null>(null);
24-
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>();
31+
const [popperElement, setPopperElement] = useState<HTMLElement | null>();
2532
const { styles, attributes } = usePopper(referenceElement, popperElement, {
2633
placement,
2734
modifiers: offset
@@ -38,22 +45,38 @@ export function Menu({
3845
<HeadlessMenu>
3946
{({ open }) => (
4047
<>
41-
<HeadlessMenu.Button
42-
ref={setReferenceElement}
43-
as={Fragment}
44-
data-testid="open-menu"
48+
<Tooltip
49+
tip={buttonProps?.tip}
50+
side={buttonProps?.tipSide}
51+
disableHoverableContent={buttonProps?.tipDisableHoverableContent}
4552
>
46-
<Button {...buttonProps} focused={open} />
47-
</HeadlessMenu.Button>
53+
<HeadlessMenuButton
54+
ref={setReferenceElement}
55+
as={Button}
56+
data-testid="open-menu"
57+
{
58+
// <HeadlessMenuButton as={Button} tip="…" /> causes a state update loop since Headless UI 2.0
59+
// (presumably because Headless UI and the tooltip both want to update the ref).
60+
// To circumvent this issue, we place the <Tooltip /> component as a parent of <HeadlessMenuButton />
61+
...omit(
62+
buttonProps,
63+
"tip",
64+
"tipSide",
65+
"tipDisableHoverableContent",
66+
)
67+
}
68+
focused={open}
69+
/>
70+
</Tooltip>
4871
<Portal>
49-
<HeadlessMenu.Items
72+
<HeadlessMenuItems
5073
ref={setPopperElement}
5174
style={styles.popper}
5275
{...attributes.popper}
5376
className="z-50 flex max-h-[20rem] flex-col gap-1 overflow-auto rounded-lg border bg-background-secondary py-2 text-sm whitespace-nowrap shadow-md"
5477
>
5578
{children}
56-
</HeadlessMenu.Items>
79+
</HeadlessMenuItems>
5780
</Portal>
5881
</>
5982
)}
@@ -90,8 +113,8 @@ export function MenuItem({
90113
const actionProp = href ? { href } : { onClick: action };
91114

92115
return (
93-
<HeadlessMenu.Item>
94-
{({ active }) => (
116+
<HeadlessMenuItem>
117+
{({ focus }) => (
95118
<Button
96119
tip={tip}
97120
tipSide={tipSide}
@@ -101,7 +124,7 @@ export function MenuItem({
101124
disabled
102125
? "cursor-not-allowed fill-content-tertiary text-content-tertiary"
103126
: "hover:bg-background-tertiary",
104-
active && "bg-background-primary",
127+
focus && "bg-background-primary",
105128
!disabled && variant === "danger"
106129
? "text-content-errorSecondary"
107130
: "text-content-primary",
@@ -118,7 +141,7 @@ export function MenuItem({
118141
)}
119142
</Button>
120143
)}
121-
</HeadlessMenu.Item>
144+
</HeadlessMenuItem>
122145
);
123146
}
124147

@@ -137,8 +160,8 @@ export function MenuLink({
137160
target?: "_blank";
138161
}>) {
139162
return (
140-
<HeadlessMenu.Item disabled={disabled}>
141-
{({ active, close }) => (
163+
<HeadlessMenuItem disabled={disabled}>
164+
{({ focus, close }) => (
142165
<a
143166
href={href}
144167
target={target}
@@ -149,7 +172,7 @@ export function MenuLink({
149172
disabled &&
150173
"cursor-not-allowed fill-content-secondary bg-background-tertiary text-content-secondary",
151174

152-
active || selected
175+
focus || selected
153176
? "bg-background-primary"
154177
: "hover:bg-background-tertiary",
155178
)}
@@ -163,6 +186,6 @@ export function MenuLink({
163186
)}
164187
</a>
165188
)}
166-
</HeadlessMenu.Item>
189+
</HeadlessMenuItem>
167190
);
168191
}

0 commit comments

Comments
 (0)