Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Number input #1582

Merged
merged 11 commits into from
Oct 5, 2023
4 changes: 2 additions & 2 deletions app/components/form/fields/DiskSizeField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import type { FieldPath, FieldValues } from 'react-hook-form'

import { MAX_DISK_SIZE_GiB } from '@oxide/api'

import { NumberField } from './NumberField'
import type { TextFieldProps } from './TextField'
import { TextField } from './TextField'

interface DiskSizeProps<
TFieldValues extends FieldValues,
Expand All @@ -24,7 +24,7 @@ export function DiskSizeField<
TName extends FieldPath<TFieldValues>
>({ required = true, name, minSize = 1, ...props }: DiskSizeProps<TFieldValues, TName>) {
return (
<TextField
<NumberField
units="GiB"
type="number"
required={required}
Expand Down
106 changes: 106 additions & 0 deletions app/components/form/fields/NumberField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import cn from 'classnames'
import { useId } from 'react'
import type { FieldPath, FieldValues } from 'react-hook-form'
import { Controller } from 'react-hook-form'

import type { NumberInputProps as UINumberFieldProps } from '@oxide/ui'
import { FieldLabel, TextInputHint, NumberInput as UINumberField } from '@oxide/ui'
import { capitalize } from '@oxide/util'

import { ErrorMessage } from './ErrorMessage'
import type { TextFieldProps } from './TextField'

export function NumberField<
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>
>({
name,
label = capitalize(name),
units,
description,
helpText,
required,
...props
}: Omit<TextFieldProps<TFieldValues, TName>, 'id'>) {
// id is omitted from props because we generate it here
const id = useId()
return (
<div className="max-w-lg">
<div className="mb-2">
<FieldLabel id={`${id}-label`} tip={description} optional={!required}>
{label} {units && <span className="ml-1 text-secondary">({units})</span>}
</FieldLabel>
{helpText && (
<TextInputHint id={`${id}-help-text`} className="mb-2">
{helpText}
</TextInputHint>
)}
</div>
{/* passing the generated id is very important for a11y */}
<NumberFieldInner name={name} {...props} id={id} />
</div>
)
}

/**
* Primarily exists for `NumberField`, but we occasionally also need a plain field
* without a label on it.
*
* Note that `id` is an allowed prop, unlike in `TextField`, where it is always
* generated from `name`. This is because we need to pass the generated ID in
* from there to here. For the case where `TextFieldInner` is used
* independently, we also generate an ID for use only if none is passed in.
*/
export const NumberFieldInner = <
TFieldValues extends FieldValues,
TName extends FieldPath<TFieldValues>
>({
name,
label = capitalize(name),
validate,
control,
description,
required,
id: idProp,
...props
}: TextFieldProps<TFieldValues, TName> & UINumberFieldProps) => {
const generatedId = useId()
const id = idProp || generatedId

return (
<Controller
name={name}
control={control}
rules={{ required, validate }}
render={({ field: { onChange, value, ...fieldRest }, fieldState: { error } }) => {
return (
<>
<UINumberField
id={id}
title={label}
error={!!error}
aria-labelledby={cn(`${id}-label`, {
[`${id}-help-text`]: !!description,
})}
aria-describedby={description ? `${id}-label-tip` : undefined}
defaultValue={value}

Check failure on line 93 in app/components/form/fields/NumberField.tsx

View workflow job for this annotation

GitHub Actions / ci

Type 'string | number | readonly string[]' is not assignable to type 'number | undefined'.
onChange={(val) => {

Check failure on line 94 in app/components/form/fields/NumberField.tsx

View workflow job for this annotation

GitHub Actions / ci

Type 'ChangeEventHandler<HTMLInputElement> | ((val: number) => void)' is not assignable to type '((value: number) => void) | undefined'.
onChange(val)
}}
{...fieldRest}
{...props}
/>
<ErrorMessage error={error} label={label} />
</>
)
}}
/>
)
}
1 change: 1 addition & 0 deletions libs/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export * from './lib/message/Message'
export * from './lib/modal/Modal'
export * from './lib/ModalLinks'
export * as MiniTable from './lib/mini-table/MiniTable'
export * from './lib/number-input/NumberInput'
export * from './lib/page-header/PageHeader'
export * from './lib/pagination/Pagination'
export * from './lib/progress/Progress'
Expand Down
37 changes: 37 additions & 0 deletions libs/ui/lib/number-input/NumberInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NumberInput } from './NumberInput'
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved

export const Default = () => (
<div className="max-w-lg">
<NumberInput defaultValue={6} />
</div>
)

export const WithUnit = () => (
<div className="max-w-lg">
<NumberInput
defaultValue={6}
formatOptions={{
style: 'unit',
unit: 'inch',
unitDisplay: 'long',
}}
/>
</div>
)

export const StepValues = () => (
<div className="max-w-lg space-y-4 text-sans-md children:space-y-2">
<div>
<div>Step</div>
<NumberInput step={10} />
</div>
<div>
<div>Step + minValue</div>
<NumberInput minValue={2} step={2} />
</div>
<div>
<div>Step + minValue + maxValue</div>
<NumberInput minValue={2} maxValue={20} step={2} />
</div>
</div>
)
104 changes: 104 additions & 0 deletions libs/ui/lib/number-input/NumberInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import cn from 'classnames'
benjaminleonard marked this conversation as resolved.
Show resolved Hide resolved
import React, { useRef } from 'react'
import {
type AriaButtonProps,
type AriaNumberFieldProps,
useButton,
useLocale,
useNumberField,
} from 'react-aria'
import { mergeRefs } from 'react-merge-refs'
import { useNumberFieldState } from 'react-stately'

export type NumberInputProps = {
className?: string
error?: boolean
}

export const NumberInput = React.forwardRef<
HTMLInputElement,
AriaNumberFieldProps & NumberInputProps
>((props: AriaNumberFieldProps & NumberInputProps, forwardedRef) => {
const { locale } = useLocale()
const state = useNumberFieldState({ ...props, locale })

const inputRef = useRef(null)
const { groupProps, inputProps, incrementButtonProps, decrementButtonProps } =
useNumberField(props, state, inputRef)

return (
<div
className={cn(
'relative flex rounded border',
props.error
? 'border-error-secondary hover:border-error'
: 'border-default hover:border-hover',
props.isDisabled && '!border-default',
props.className
)}
{...groupProps}
>
<input
{...inputProps}
ref={mergeRefs([forwardedRef, inputRef])}
className={cn(
`w-full rounded border-none px-3
py-[0.6875rem] !outline-offset-1 text-sans-md
text-default bg-default placeholder:text-quaternary
focus:outline-none disabled:cursor-not-allowed disabled:text-tertiary disabled:bg-disabled`,
props.error && 'focus-error',
props.isDisabled && 'text-disabled bg-disabled'
)}
/>
<div className="absolute bottom-0 right-0 top-0 flex flex-col border-l border-default">
<IncrementButton {...incrementButtonProps}>
<InputArrowIcon />
</IncrementButton>
<div className="h-[1px] w-full border-t border-t-default" />
<IncrementButton {...decrementButtonProps}>
<InputArrowIcon className="rotate-180" />
</IncrementButton>
</div>
</div>
)
})

function IncrementButton(props: AriaButtonProps<'button'> & { className?: string }) {
const { children } = props
const ref = useRef(null)
const { buttonProps } = useButton(
{
...props,
},
ref
)

return (
<button
{...buttonProps}
className={cn(
'flex h-1/2 w-8 items-center justify-center hover:bg-hover',
buttonProps.disabled ? 'text-quaternary bg-disabled' : 'bg-default'
)}
ref={ref}
>
{children}
</button>
)
}

const InputArrowIcon = ({ className }: { className?: string }) => (
<svg
width="6"
height="6"
viewBox="0 0 6 6"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
d="M2.67844 0.535946C2.82409 0.293194 3.17591 0.293194 3.32156 0.535946L5.65924 4.43208C5.80921 4.68202 5.62917 5.00001 5.33768 5.00001L0.662322 5.00001C0.370837 5.00001 0.190795 4.68202 0.340763 4.43208L2.67844 0.535946Z"
fill="currentColor"
/>
</svg>
)
Loading
Loading