Skip to content

Commit ba3ab97

Browse files
authored
React 19 (#2699)
* react 19 * upgrade design-system (canary) and other libs to make install work * upgrade @types libs too * fix the easier type errors * bump design-system again * fix the two failing e2es * patch the right copy of react-remove-scroll * fix some more type errors * elaborate rework of Button and Tooltip ref situation * upgrade zustand to v5 to fix some last warnings I went through the v5 migration guide and confirmed there was nothing we actually needed to change. https://github.com/pmndrs/zustand/blob/c9330941c8bad6add1c95bb69099c39606151abd/docs/migrations/migrating-to-v5.md * work around headless bug: change fragment to div * good old design-system 2.2.2
1 parent c14b4af commit ba3ab97

31 files changed

+1115
-1418
lines changed

app/components/CapacityBar.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
* Copyright Oxide Computer Company
77
*/
88

9+
import type { JSX } from 'react'
10+
911
import { BigNum } from '~/ui/lib/BigNum'
1012
import { percentage, splitDecimal } from '~/util/math'
1113

app/components/DocsPopover.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
1010
import cn from 'classnames'
11+
import type { JSX } from 'react'
1112

1213
import { Info16Icon, OpenLink12Icon } from '@oxide/design-system/icons/react'
1314

app/components/RefetchIntervalPicker.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export function useIntervalPicker({ enabled, isLoading, fn }: Props) {
7575
</button>
7676
<Listbox
7777
selected={enabled ? intervalPreset : 'Off'}
78-
className="w-24 [&>button]:!rounded-l-none"
78+
className="w-24 [&_button]:!rounded-l-none"
7979
items={intervalItems}
8080
onChange={setIntervalPreset}
8181
disabled={!enabled}

app/components/TimeAgo.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Copyright Oxide Computer Company
77
*/
88
import type { Placement } from '@floating-ui/react'
9+
import type { JSX } from 'react'
910

1011
import { Tooltip } from '~/ui/lib/Tooltip'
1112
import { timeAgoAbbr, toLocaleDateTimeString } from '~/util/date'

app/components/form/fields/DateTimeRangePicker.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export function DateTimeRangePicker({
103103
return (
104104
<form className="flex">
105105
<Listbox
106-
className="z-10 w-[10rem] border-r border-r-default [&>button]:!rounded-r-none [&>button]:!border-r-0"
106+
className="z-10 w-[10rem] border-r border-r-default [&_button]:!rounded-r-none [&_button]:!border-r-0"
107107
name="preset"
108108
selected={preset}
109109
aria-label="Choose a time range preset"
@@ -119,6 +119,8 @@ export function DateTimeRangePicker({
119119
label="Choose a date range"
120120
value={range}
121121
onChange={(range) => {
122+
// early return should never happen because there's no way to clear the range
123+
if (range === null) return
122124
setRange(range)
123125
setPreset('custom')
124126
}}

app/components/form/fields/RadioField.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from 'react-hook-form'
1717

1818
import { FieldLabel } from '~/ui/lib/FieldLabel'
19-
import { Radio } from '~/ui/lib/Radio'
19+
import { Radio, type RadioProps } from '~/ui/lib/Radio'
2020
import { RadioGroup, type RadioGroupProps } from '~/ui/lib/RadioGroup'
2121
import { TextInputHint } from '~/ui/lib/TextInput'
2222
import { capitalize } from '~/util/str'
@@ -97,11 +97,13 @@ export function RadioField<
9797
)
9898
}
9999

100+
type RadioElt = React.ReactElement<RadioProps>
101+
100102
export type RadioFieldDynProps<
101103
TFieldValues extends FieldValues,
102104
TName extends FieldPath<TFieldValues>,
103105
> = Omit<RadioFieldProps<TFieldValues, TName>, 'parseValue' | 'items'> & {
104-
children: React.ReactElement | React.ReactElement[]
106+
children: RadioElt | RadioElt[]
105107
}
106108

107109
/**

app/hooks/use-scroll-restoration.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ function setScrollPosition(key: string, pos: number) {
2424
* so the same path navigated to at different points in the history stack will
2525
* not share the same scroll position.
2626
*/
27-
export function useScrollRestoration(container: React.RefObject<HTMLElement>) {
27+
export function useScrollRestoration(container: React.RefObject<HTMLElement | null>) {
2828
const key = `scroll-position-${useLocation().key}`
2929
const { state } = useNavigation()
3030
useEffect(() => {

app/table/Table.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88
import { flexRender, type Table as TableInstance } from '@tanstack/react-table'
99
import cn from 'classnames'
10+
import type { JSX } from 'react'
1011

1112
import { Table as UITable } from '~/ui/lib/Table'
1213

app/ui/lib/Button.tsx

+70-68
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88
import cn from 'classnames'
99
import * as m from 'motion/react-m'
10-
import { forwardRef, type MouseEventHandler, type ReactNode } from 'react'
10+
import { type MouseEventHandler, type ReactNode } from 'react'
1111

1212
import { Spinner } from '~/ui/lib/Spinner'
1313
import { Tooltip } from '~/ui/lib/Tooltip'
@@ -55,80 +55,82 @@ const noop: MouseEventHandler<HTMLButtonElement> = (e) => {
5555
e.preventDefault()
5656
}
5757

58-
export interface ButtonProps
59-
extends React.ComponentPropsWithRef<'button'>,
60-
ButtonStyleProps {
58+
export interface ButtonProps extends React.ComponentProps<'button'>, ButtonStyleProps {
6159
innerClassName?: string
6260
loading?: boolean
6361
disabledReason?: ReactNode
6462
}
6563

66-
// Use `forwardRef` so the ref points to the DOM element (not the React Component)
67-
// so it can be focused using the DOM API (eg. this.buttonRef.current.focus())
68-
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
69-
(
70-
{
71-
type = 'button',
72-
children,
73-
size,
74-
variant,
75-
className,
76-
loading,
77-
innerClassName,
78-
disabled,
79-
onClick,
80-
disabledReason,
81-
// needs to be a spread because we sometimes get passed arbitrary <button>
82-
// props by the parent
83-
...rest
84-
},
85-
ref
86-
) => {
87-
const isDisabled = disabled || loading
88-
return (
89-
<Wrap
90-
when={isDisabled && disabledReason}
91-
with={<Tooltip content={disabledReason} ref={ref} placement="bottom" />}
64+
// The ref situation is a little confusing. We need a ref prop for the button
65+
// (and to pass it through to <button> so it actually does something) so we can
66+
// focus to the button programmatically. There is an example in TlsCertsField
67+
// in the silo create form: when there are no certs added, the validation error
68+
// on submit focuses and scrolls to the add TLS cert button. All of that is
69+
// normal. The confusing part is that when the button is disabled and wrapped
70+
// in a tooltip, the tooltip component wants to add its own ref to the button
71+
// so it can figure out where to place the tooltip. In order to make both refs
72+
// work at the same time (so that, for example, in theory, a button could be
73+
// simultaneously disabled with a tooltip *and* be focused programmatically [I
74+
// tested this]), we merge the two refs inside Tooltip, using child.props.ref to
75+
// get the original ref on the button.
76+
77+
export const Button = ({
78+
type = 'button',
79+
children,
80+
size,
81+
variant,
82+
className,
83+
loading,
84+
innerClassName,
85+
disabled,
86+
onClick,
87+
disabledReason,
88+
// needs to be a spread because we sometimes get passed arbitrary <button>
89+
// props by the parent
90+
...rest
91+
}: ButtonProps) => {
92+
const isDisabled = disabled || loading
93+
return (
94+
<Wrap
95+
when={isDisabled && disabledReason}
96+
with={<Tooltip content={disabledReason} placement="bottom" />}
97+
>
98+
<button
99+
className={cn(
100+
buttonStyle({ size, variant }),
101+
className,
102+
{ 'visually-disabled': isDisabled },
103+
'overflow-hidden'
104+
)}
105+
/* eslint-disable-next-line react/button-has-type */
106+
type={type}
107+
onMouseDown={isDisabled ? noop : undefined}
108+
onClick={isDisabled ? noop : onClick}
109+
aria-disabled={isDisabled}
110+
/* this includes the ref. that's important. see big comment above */
111+
{...rest}
92112
>
93-
<button
94-
className={cn(
95-
buttonStyle({ size, variant }),
96-
className,
97-
{
98-
'visually-disabled': isDisabled,
99-
},
100-
'overflow-hidden'
101-
)}
102-
ref={ref}
103-
/* eslint-disable-next-line react/button-has-type */
104-
type={type}
105-
onMouseDown={isDisabled ? noop : undefined}
106-
onClick={isDisabled ? noop : onClick}
107-
aria-disabled={isDisabled}
108-
{...rest}
109-
>
110-
{loading && (
111-
<m.span
112-
animate={{ opacity: 1, y: '-50%', x: '-50%' }}
113-
initial={{ opacity: 0, y: 'calc(-50% - 25px)', x: '-50%' }}
114-
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
115-
className="absolute left-1/2 top-1/2"
116-
>
117-
<Spinner variant={variant} />
118-
</m.span>
119-
)}
113+
{loading && (
120114
<m.span
121-
className={cn('flex items-center', innerClassName)}
122-
animate={{
123-
opacity: loading ? 0 : 1,
124-
y: loading ? 25 : 0,
125-
}}
115+
animate={{ opacity: 1, y: '-50%', x: '-50%' }}
116+
initial={{ opacity: 0, y: 'calc(-50% - 25px)', x: '-50%' }}
126117
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
118+
className="absolute left-1/2 top-1/2"
127119
>
128-
{children}
120+
<Spinner variant={variant} />
129121
</m.span>
130-
</button>
131-
</Wrap>
132-
)
133-
}
134-
)
122+
)}
123+
<m.span
124+
className={cn('flex items-center', innerClassName)}
125+
animate={{
126+
opacity: loading ? 0 : 1,
127+
y: loading ? 25 : 0,
128+
}}
129+
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
130+
>
131+
{children}
132+
</m.span>
133+
</button>
134+
</Wrap>
135+
)
136+
}

app/ui/lib/Checkbox.tsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ export const Checkbox = ({
5151
<input
5252
className={cn(inputStyle, className)}
5353
type="checkbox"
54-
ref={(el) => el && (el.indeterminate = !!indeterminate)}
54+
ref={(el) => {
55+
if (el) {
56+
el.indeterminate = !!indeterminate
57+
}
58+
}}
5559
{...inputProps}
5660
/>
5761
{inputProps.checked && !indeterminate && <Check />}

app/ui/lib/Combobox.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export const Combobox = ({
159159
{...props}
160160
>
161161
{({ open }) => (
162-
<>
162+
<div>
163163
{label && (
164164
// TODO: FieldLabel needs a real ID
165165
<div className="mb-2">
@@ -277,7 +277,7 @@ export const Combobox = ({
277277
)}
278278
</ComboboxOptions>
279279
)}
280-
</>
280+
</div>
281281
)}
282282
</HCombobox>
283283
)

app/ui/lib/DatePicker.tsx

+3-8
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { getLocalTimeZone, type DateValue } from '@internationalized/date'
9-
import type { TimeValue } from '@react-types/datepicker'
109
import cn from 'classnames'
1110
import { useMemo, useRef } from 'react'
1211
import { useButton, useDateFormatter, useDatePicker } from 'react-aria'
@@ -45,12 +44,6 @@ export function DatePicker(props: DatePickerProps) {
4544
: ''
4645
}, [state, formatter])
4746

48-
const handleSetTime = (v: TimeValue) => {
49-
if (v !== null) {
50-
state.setTimeValue(v)
51-
}
52-
}
53-
5447
return (
5548
<div
5649
aria-label={props.label}
@@ -93,7 +86,9 @@ export function DatePicker(props: DatePickerProps) {
9386
<div className="flex items-center space-x-2 border-t p-4 border-t-secondary">
9487
<TimeField
9588
value={state.timeValue}
96-
onChange={handleSetTime}
89+
onChange={(v) => {
90+
if (v !== null) state.setTimeValue(v)
91+
}}
9792
hourCycle={24}
9893
className="grow"
9994
/>

app/ui/lib/DateRangePicker.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
* Copyright Oxide Computer Company
77
*/
88
import { getLocalTimeZone } from '@internationalized/date'
9-
import type { TimeValue } from '@react-types/datepicker'
109
import cn from 'classnames'
1110
import { useMemo, useRef } from 'react'
1211
import { useButton, useDateFormatter, useDateRangePicker } from 'react-aria'
@@ -44,6 +43,8 @@ export function DateRangePicker(props: DateRangePickerProps) {
4443
// because we always pass a value to this component and there is no way to
4544
// unset the value through the UI.
4645
if (!state.dateRange) return 'No range selected'
46+
if (!state.dateRange.start) return 'No start date selected'
47+
if (!state.dateRange.end) return 'No end date selected'
4748

4849
return formatter.formatRange(
4950
state.dateRange.start.toDate(getLocalTimeZone()),
@@ -94,15 +95,15 @@ export function DateRangePicker(props: DateRangePickerProps) {
9495
<TimeField
9596
label="Start time"
9697
value={state.timeRange?.start || null}
97-
onChange={(v: TimeValue) => state.setTime('start', v)}
98+
onChange={(v) => state.setTime('start', v)}
9899
hourCycle={24}
99100
className="shrink-0 grow basis-0"
100101
/>
101102
<div className="text-quaternary"></div>
102103
<TimeField
103104
label="End time"
104105
value={state.timeRange?.end || null}
105-
onChange={(v: TimeValue) => state.setTime('end', v)}
106+
onChange={(v) => state.setTime('end', v)}
106107
hourCycle={24}
107108
className="shrink-0 grow basis-0"
108109
/>

app/ui/lib/Listbox.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const Listbox = <Value extends string = string>({
8080
disabled={isDisabled || isLoading}
8181
>
8282
{({ open }) => (
83-
<>
83+
<div>
8484
{label && (
8585
<div className="mb-2 max-w-lg">
8686
<FieldLabel
@@ -163,7 +163,7 @@ export const Listbox = <Value extends string = string>({
163163
</ListboxOption>
164164
))}
165165
</ListboxOptions>
166-
</>
166+
</div>
167167
)}
168168
</HListbox>
169169
</div>

app/ui/lib/RadioGroup.tsx

+7-2
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,17 @@ import React from 'react'
5252

5353
import { classed } from '~/util/classed'
5454

55+
import type { RadioProps } from './Radio'
56+
5557
export const RadioGroupHint = classed.p`text-base text-default text-sans-sm max-w-3xl`
5658

59+
// need to specify that we have these props because we rely on them in the cloneElement call
60+
type RadioElt = React.ReactElement<RadioProps>
61+
5762
export type RadioGroupProps = {
5863
// gets passed to all the radios. this is what defines them as a group
5964
name: string
60-
children: React.ReactElement | React.ReactElement[]
65+
children: RadioElt | RadioElt[]
6166
// gets passed to all the radios (technically only needs to be on one)
6267
required?: boolean
6368
// gets passed to all the radios
@@ -92,7 +97,7 @@ export const RadioGroup = ({
9297
name,
9398
required,
9499
disabled,
95-
defaultChecked: radio.props.value === defaultChecked ? 'true' : undefined,
100+
defaultChecked: radio.props.value === defaultChecked ? true : undefined,
96101
})
97102
)}
98103
</div>

0 commit comments

Comments
 (0)