Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/components/CapacityBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* Copyright Oxide Computer Company
*/

import type { JSX } from 'react'

import { BigNum } from '~/ui/lib/BigNum'
import { percentage, splitDecimal } from '~/util/math'

Expand Down
1 change: 1 addition & 0 deletions app/components/DocsPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import cn from 'classnames'
import type { JSX } from 'react'

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

Expand Down
2 changes: 1 addition & 1 deletion app/components/RefetchIntervalPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export function useIntervalPicker({ enabled, isLoading, fn }: Props) {
</button>
<Listbox
selected={enabled ? intervalPreset : 'Off'}
className="w-24 [&>button]:!rounded-l-none"
className="w-24 [&_button]:!rounded-l-none"
items={intervalItems}
onChange={setIntervalPreset}
disabled={!enabled}
Expand Down
1 change: 1 addition & 0 deletions app/components/TimeAgo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Copyright Oxide Computer Company
*/
import type { Placement } from '@floating-ui/react'
import type { JSX } from 'react'

import { Tooltip } from '~/ui/lib/Tooltip'
import { timeAgoAbbr, toLocaleDateTimeString } from '~/util/date'
Expand Down
4 changes: 3 additions & 1 deletion app/components/form/fields/DateTimeRangePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function DateTimeRangePicker({
return (
<form className="flex">
<Listbox
className="z-10 w-[10rem] border-r border-r-default [&>button]:!rounded-r-none [&>button]:!border-r-0"
className="z-10 w-[10rem] border-r border-r-default [&_button]:!rounded-r-none [&_button]:!border-r-0"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was needed to fix the border between the Listbox and the button after adding a wrapping div to work around the Headless bug. We can always change it back if want after they fix it.

image

name="preset"
selected={preset}
aria-label="Choose a time range preset"
Expand All @@ -119,6 +119,8 @@ export function DateTimeRangePicker({
label="Choose a date range"
value={range}
onChange={(range) => {
// early return should never happen because there's no way to clear the range
if (range === null) return
setRange(range)
setPreset('custom')
}}
Expand Down
6 changes: 4 additions & 2 deletions app/components/form/fields/RadioField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
} from 'react-hook-form'

import { FieldLabel } from '~/ui/lib/FieldLabel'
import { Radio } from '~/ui/lib/Radio'
import { Radio, type RadioProps } from '~/ui/lib/Radio'
import { RadioGroup, type RadioGroupProps } from '~/ui/lib/RadioGroup'
import { TextInputHint } from '~/ui/lib/TextInput'
import { capitalize } from '~/util/str'
Expand Down Expand Up @@ -97,11 +97,13 @@ export function RadioField<
)
}

type RadioElt = React.ReactElement<RadioProps>

export type RadioFieldDynProps<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>,
> = Omit<RadioFieldProps<TFieldValues, TName>, 'parseValue' | 'items'> & {
children: React.ReactElement | React.ReactElement[]
children: RadioElt | RadioElt[]
}

/**
Expand Down
2 changes: 1 addition & 1 deletion app/hooks/use-scroll-restoration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function setScrollPosition(key: string, pos: number) {
* so the same path navigated to at different points in the history stack will
* not share the same scroll position.
*/
export function useScrollRestoration(container: React.RefObject<HTMLElement>) {
export function useScrollRestoration(container: React.RefObject<HTMLElement | null>) {
const key = `scroll-position-${useLocation().key}`
const { state } = useNavigation()
useEffect(() => {
Expand Down
1 change: 1 addition & 0 deletions app/table/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/
import { flexRender, type Table as TableInstance } from '@tanstack/react-table'
import cn from 'classnames'
import type { JSX } from 'react'

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

Expand Down
138 changes: 70 additions & 68 deletions app/ui/lib/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
import cn from 'classnames'
import * as m from 'motion/react-m'
import { forwardRef, type MouseEventHandler, type ReactNode } from 'react'
import { type MouseEventHandler, type ReactNode } from 'react'

import { Spinner } from '~/ui/lib/Spinner'
import { Tooltip } from '~/ui/lib/Tooltip'
Expand Down Expand Up @@ -55,80 +55,82 @@ const noop: MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault()
}

export interface ButtonProps
extends React.ComponentPropsWithRef<'button'>,
ButtonStyleProps {
export interface ButtonProps extends React.ComponentProps<'button'>, ButtonStyleProps {
innerClassName?: string
loading?: boolean
disabledReason?: ReactNode
}

// Use `forwardRef` so the ref points to the DOM element (not the React Component)
// so it can be focused using the DOM API (eg. this.buttonRef.current.focus())
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
type = 'button',
children,
size,
variant,
className,
loading,
innerClassName,
disabled,
onClick,
disabledReason,
// needs to be a spread because we sometimes get passed arbitrary <button>
// props by the parent
...rest
},
ref
) => {
const isDisabled = disabled || loading
return (
<Wrap
when={isDisabled && disabledReason}
with={<Tooltip content={disabledReason} ref={ref} placement="bottom" />}
// The ref situation is a little confusing. We need a ref prop for the button
// (and to pass it through to <button> so it actually does something) so we can
// focus to the button programmatically. There is an example in TlsCertsField
// in the silo create form: when there are no certs added, the validation error
// on submit focuses and scrolls to the add TLS cert button. All of that is
// normal. The confusing part is that when the button is disabled and wrapped
// in a tooltip, the tooltip component wants to add its own ref to the button
// so it can figure out where to place the tooltip. In order to make both refs
// work at the same time (so that, for example, in theory, a button could be
// simultaneously disabled with a tooltip *and* be focused programmatically [I
// tested this]), we merge the two refs inside Tooltip, using child.props.ref to
// get the original ref on the button.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important comment added


export const Button = ({
type = 'button',
children,
size,
variant,
className,
loading,
innerClassName,
disabled,
onClick,
disabledReason,
// needs to be a spread because we sometimes get passed arbitrary <button>
// props by the parent
...rest
}: ButtonProps) => {
const isDisabled = disabled || loading
return (
<Wrap
when={isDisabled && disabledReason}
with={<Tooltip content={disabledReason} placement="bottom" />}
>
<button
className={cn(
buttonStyle({ size, variant }),
className,
{ 'visually-disabled': isDisabled },
'overflow-hidden'
)}
/* eslint-disable-next-line react/button-has-type */
type={type}
onMouseDown={isDisabled ? noop : undefined}
onClick={isDisabled ? noop : onClick}
aria-disabled={isDisabled}
/* this includes the ref. that's important. see big comment above */
{...rest}
>
<button
className={cn(
buttonStyle({ size, variant }),
className,
{
'visually-disabled': isDisabled,
},
'overflow-hidden'
)}
ref={ref}
/* eslint-disable-next-line react/button-has-type */
type={type}
onMouseDown={isDisabled ? noop : undefined}
onClick={isDisabled ? noop : onClick}
aria-disabled={isDisabled}
{...rest}
>
{loading && (
<m.span
animate={{ opacity: 1, y: '-50%', x: '-50%' }}
initial={{ opacity: 0, y: 'calc(-50% - 25px)', x: '-50%' }}
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
className="absolute left-1/2 top-1/2"
>
<Spinner variant={variant} />
</m.span>
)}
{loading && (
<m.span
className={cn('flex items-center', innerClassName)}
animate={{
opacity: loading ? 0 : 1,
y: loading ? 25 : 0,
}}
animate={{ opacity: 1, y: '-50%', x: '-50%' }}
initial={{ opacity: 0, y: 'calc(-50% - 25px)', x: '-50%' }}
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
className="absolute left-1/2 top-1/2"
>
{children}
<Spinner variant={variant} />
</m.span>
</button>
</Wrap>
)
}
)
)}
<m.span
className={cn('flex items-center', innerClassName)}
animate={{
opacity: loading ? 0 : 1,
y: loading ? 25 : 0,
}}
transition={{ type: 'spring', duration: 0.3, bounce: 0 }}
>
{children}
</m.span>
</button>
</Wrap>
)
}
6 changes: 5 additions & 1 deletion app/ui/lib/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ export const Checkbox = ({
<input
className={cn(inputStyle, className)}
type="checkbox"
ref={(el) => el && (el.indeterminate = !!indeterminate)}
ref={(el) => {
if (el) {
el.indeterminate = !!indeterminate
}
}}
{...inputProps}
/>
{inputProps.checked && !indeterminate && <Check />}
Expand Down
4 changes: 2 additions & 2 deletions app/ui/lib/Combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export const Combobox = ({
{...props}
>
{({ open }) => (
<>
<div>
{label && (
// TODO: FieldLabel needs a real ID
<div className="mb-2">
Expand Down Expand Up @@ -277,7 +277,7 @@ export const Combobox = ({
)}
</ComboboxOptions>
)}
</>
</div>
)}
</HCombobox>
)
Expand Down
11 changes: 3 additions & 8 deletions app/ui/lib/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* Copyright Oxide Computer Company
*/
import { getLocalTimeZone, type DateValue } from '@internationalized/date'
import type { TimeValue } from '@react-types/datepicker'
import cn from 'classnames'
import { useMemo, useRef } from 'react'
import { useButton, useDateFormatter, useDatePicker } from 'react-aria'
Expand Down Expand Up @@ -45,12 +44,6 @@ export function DatePicker(props: DatePickerProps) {
: ''
}, [state, formatter])

const handleSetTime = (v: TimeValue) => {
if (v !== null) {
state.setTimeValue(v)
}
}

return (
<div
aria-label={props.label}
Expand Down Expand Up @@ -93,7 +86,9 @@ export function DatePicker(props: DatePickerProps) {
<div className="flex items-center space-x-2 border-t p-4 border-t-secondary">
<TimeField
value={state.timeValue}
onChange={handleSetTime}
onChange={(v) => {
if (v !== null) state.setTimeValue(v)
}}
hourCycle={24}
className="grow"
/>
Expand Down
7 changes: 4 additions & 3 deletions app/ui/lib/DateRangePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* Copyright Oxide Computer Company
*/
import { getLocalTimeZone } from '@internationalized/date'
import type { TimeValue } from '@react-types/datepicker'
import cn from 'classnames'
import { useMemo, useRef } from 'react'
import { useButton, useDateFormatter, useDateRangePicker } from 'react-aria'
Expand Down Expand Up @@ -44,6 +43,8 @@ export function DateRangePicker(props: DateRangePickerProps) {
// because we always pass a value to this component and there is no way to
// unset the value through the UI.
if (!state.dateRange) return 'No range selected'
if (!state.dateRange.start) return 'No start date selected'
if (!state.dateRange.end) return 'No end date selected'

return formatter.formatRange(
state.dateRange.start.toDate(getLocalTimeZone()),
Expand Down Expand Up @@ -94,15 +95,15 @@ export function DateRangePicker(props: DateRangePickerProps) {
<TimeField
label="Start time"
value={state.timeRange?.start || null}
onChange={(v: TimeValue) => state.setTime('start', v)}
onChange={(v) => state.setTime('start', v)}
hourCycle={24}
className="shrink-0 grow basis-0"
/>
<div className="text-quaternary">–</div>
<TimeField
label="End time"
value={state.timeRange?.end || null}
onChange={(v: TimeValue) => state.setTime('end', v)}
onChange={(v) => state.setTime('end', v)}
hourCycle={24}
className="shrink-0 grow basis-0"
/>
Expand Down
4 changes: 2 additions & 2 deletions app/ui/lib/Listbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const Listbox = <Value extends string = string>({
disabled={isDisabled || isLoading}
>
{({ open }) => (
<>
<div>
{label && (
<div className="mb-2 max-w-lg">
<FieldLabel
Expand Down Expand Up @@ -163,7 +163,7 @@ export const Listbox = <Value extends string = string>({
</ListboxOption>
))}
</ListboxOptions>
</>
</div>
)}
</HListbox>
</div>
Expand Down
9 changes: 7 additions & 2 deletions app/ui/lib/RadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,17 @@ import React from 'react'

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

import type { RadioProps } from './Radio'

export const RadioGroupHint = classed.p`text-base text-default text-sans-sm max-w-3xl`

// need to specify that we have these props because we rely on them in the cloneElement call
type RadioElt = React.ReactElement<RadioProps>

export type RadioGroupProps = {
// gets passed to all the radios. this is what defines them as a group
name: string
children: React.ReactElement | React.ReactElement[]
children: RadioElt | RadioElt[]
// gets passed to all the radios (technically only needs to be on one)
required?: boolean
// gets passed to all the radios
Expand Down Expand Up @@ -92,7 +97,7 @@ export const RadioGroup = ({
name,
required,
disabled,
defaultChecked: radio.props.value === defaultChecked ? 'true' : undefined,
defaultChecked: radio.props.value === defaultChecked ? true : undefined,
})
)}
</div>
Expand Down
Loading
Loading