From 749ba4817d59e93f46ee626d805929762e518f2b Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Fri, 16 Jun 2023 17:39:57 +0100 Subject: [PATCH 01/10] Number input first pass --- libs/ui/index.ts | 1 + .../lib/number-input/NumberInput.stories.tsx | 37 +++++++ libs/ui/lib/number-input/NumberInput.tsx | 102 ++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 libs/ui/lib/number-input/NumberInput.stories.tsx create mode 100644 libs/ui/lib/number-input/NumberInput.tsx diff --git a/libs/ui/index.ts b/libs/ui/index.ts index c5466ad8a..a44aede06 100644 --- a/libs/ui/index.ts +++ b/libs/ui/index.ts @@ -23,6 +23,7 @@ export * from './lib/listbox/Listbox' export * from './lib/message/Message' export * from './lib/modal/Modal' 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' diff --git a/libs/ui/lib/number-input/NumberInput.stories.tsx b/libs/ui/lib/number-input/NumberInput.stories.tsx new file mode 100644 index 000000000..774d44f4c --- /dev/null +++ b/libs/ui/lib/number-input/NumberInput.stories.tsx @@ -0,0 +1,37 @@ +import { NumberInput } from './NumberInput' + +export const Default = () => ( +
+ +
+) + +export const WithUnit = () => ( +
+ +
+) + +export const StepValues = () => ( +
+
+
Step
+ +
+
+
Step + minValue
+ +
+
+
Step + minValue + maxValue
+ +
+
+) diff --git a/libs/ui/lib/number-input/NumberInput.tsx b/libs/ui/lib/number-input/NumberInput.tsx new file mode 100644 index 000000000..a13164229 --- /dev/null +++ b/libs/ui/lib/number-input/NumberInput.tsx @@ -0,0 +1,102 @@ +import cn from 'classnames' +import { useRef } from 'react' +import { + type AriaButtonProps, + type AriaNumberFieldProps, + useButton, + useLocale, + useNumberField, +} from 'react-aria' +import { useNumberFieldState } from 'react-stately' + +type NumberInputProps = { + className?: string + error?: boolean +} + +export const NumberInput = (props: AriaNumberFieldProps & NumberInputProps) => { + const { locale } = useLocale() + const state = useNumberFieldState({ ...props, locale }) + + const inputRef = useRef(null) + const { groupProps, inputProps, incrementButtonProps, decrementButtonProps } = + useNumberField(props, state, inputRef) + + return ( +
+ +
+ + + +
+ + + +
+
+ ) +} + +function IncrementButton(props: AriaButtonProps<'button'> & { className?: string }) { + const { children } = props + const ref = useRef(null) + const { buttonProps } = useButton( + { + ...props, + }, + ref + ) + + console.log(buttonProps.disabled) + + return ( + + ) +} + +const InputArrowIcon = ({ className }: { className?: string }) => ( + + + +) From 28777a9e9be8c2e9b35b1583e3d70d23bfdcaaf0 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Thu, 5 Oct 2023 14:19:35 +0100 Subject: [PATCH 02/10] Add number field --- app/components/form/fields/DiskSizeField.tsx | 4 +- app/components/form/fields/NumberField.tsx | 106 + libs/ui/lib/number-input/NumberInput.tsx | 22 +- package-lock.json | 4837 +++++++++++------- package.json | 4 +- 5 files changed, 3170 insertions(+), 1803 deletions(-) create mode 100644 app/components/form/fields/NumberField.tsx diff --git a/app/components/form/fields/DiskSizeField.tsx b/app/components/form/fields/DiskSizeField.tsx index 998232657..93d0b4964 100644 --- a/app/components/form/fields/DiskSizeField.tsx +++ b/app/components/form/fields/DiskSizeField.tsx @@ -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, @@ -24,7 +24,7 @@ export function DiskSizeField< TName extends FieldPath >({ required = true, name, minSize = 1, ...props }: DiskSizeProps) { return ( - +>({ + name, + label = capitalize(name), + units, + description, + helpText, + required, + ...props +}: Omit, 'id'>) { + // id is omitted from props because we generate it here + const id = useId() + return ( +
+
+ + {label} {units && ({units})} + + {helpText && ( + + {helpText} + + )} +
+ {/* passing the generated id is very important for a11y */} + +
+ ) +} + +/** + * 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 +>({ + name, + label = capitalize(name), + validate, + control, + description, + required, + id: idProp, + ...props +}: TextFieldProps & UINumberFieldProps) => { + const generatedId = useId() + const id = idProp || generatedId + + return ( + { + return ( + <> + { + onChange(val) + }} + {...fieldRest} + {...props} + /> + + + ) + }} + /> + ) +} diff --git a/libs/ui/lib/number-input/NumberInput.tsx b/libs/ui/lib/number-input/NumberInput.tsx index a13164229..a93a72c06 100644 --- a/libs/ui/lib/number-input/NumberInput.tsx +++ b/libs/ui/lib/number-input/NumberInput.tsx @@ -1,5 +1,5 @@ import cn from 'classnames' -import { useRef } from 'react' +import React, { useRef } from 'react' import { type AriaButtonProps, type AriaNumberFieldProps, @@ -7,14 +7,18 @@ import { useLocale, useNumberField, } from 'react-aria' +import { mergeRefs } from 'react-merge-refs' import { useNumberFieldState } from 'react-stately' -type NumberInputProps = { +export type NumberInputProps = { className?: string error?: boolean } -export const NumberInput = (props: AriaNumberFieldProps & NumberInputProps) => { +export const NumberInput = React.forwardRef< + HTMLInputElement, + AriaNumberFieldProps & NumberInputProps +>((props: AriaNumberFieldProps & NumberInputProps, forwardedRef) => { const { locale } = useLocale() const state = useNumberFieldState({ ...props, locale }) @@ -36,17 +40,17 @@ export const NumberInput = (props: AriaNumberFieldProps & NumberInputProps) => { > -
+
@@ -57,7 +61,7 @@ export const NumberInput = (props: AriaNumberFieldProps & NumberInputProps) => {
) -} +}) function IncrementButton(props: AriaButtonProps<'button'> & { className?: string }) { const { children } = props @@ -69,8 +73,6 @@ function IncrementButton(props: AriaButtonProps<'button'> & { className?: string ref ) - console.log(buttonProps.disabled) - return (
- {state.validationState === 'invalid' && ( + {state.isInvalid && (

Date is invalid

diff --git a/libs/ui/lib/date-picker/DateRangePicker.tsx b/libs/ui/lib/date-picker/DateRangePicker.tsx index cc28d1eee..e32955f9b 100644 --- a/libs/ui/lib/date-picker/DateRangePicker.tsx +++ b/libs/ui/lib/date-picker/DateRangePicker.tsx @@ -41,14 +41,17 @@ export function DateRangePicker(props: DateRangePickerProps) { hourCycle: 'h24', }) - const label = useMemo( - () => - formatter.formatRange( - state.dateRange.start.toDate(getLocalTimeZone()), - state.dateRange.end.toDate(getLocalTimeZone()) - ), - [state, formatter] - ) + const label = useMemo(() => { + // This is here to make TS happy. This should be impossible in practice + // 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' + + return formatter.formatRange( + state.dateRange.start.toDate(getLocalTimeZone()), + state.dateRange.end.toDate(getLocalTimeZone()) + ) + }, [state.dateRange, formatter]) return (
{label} - {state.validationState === 'invalid' && ( + {state.isInvalid && (
@@ -80,7 +83,7 @@ export function DateRangePicker(props: DateRangePickerProps) {
- {state.validationState === 'invalid' && ( + {state.isInvalid && (

Date range is invalid