Skip to content

Commit a57a86d

Browse files
Text field input cleanup
1 parent 887a2ca commit a57a86d

File tree

5 files changed

+79
-123
lines changed

5 files changed

+79
-123
lines changed

app/components/form/fields/TextField.tsx

Lines changed: 17 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
import cn from 'classnames'
89
import { useId } from 'react'
910
import {
1011
useController,
@@ -30,6 +31,7 @@ export interface TextFieldProps<
3031
TFieldValues extends FieldValues,
3132
TName extends FieldPath<TFieldValues>,
3233
> extends UITextFieldProps {
34+
variant?: 'default' | 'inline'
3335
name: TName
3436
/** HTML type attribute, defaults to text */
3537
type?: 'text' | 'password'
@@ -54,18 +56,27 @@ export function TextField<
5456
TFieldValues extends FieldValues,
5557
TName extends FieldPath<TFieldValues>,
5658
>({
59+
variant = 'default',
5760
name,
61+
type = 'text',
5862
label = capitalize(name),
5963
units,
6064
description,
6165
required,
66+
control,
67+
validate,
68+
transform,
6269
...props
6370
}: Omit<TextFieldProps<TFieldValues, TName>, 'id'> & UITextAreaProps) {
64-
// id is omitted from props because we generate it here
6571
const id = useId()
72+
const {
73+
field: { onChange, ...fieldRest },
74+
fieldState: { error },
75+
} = useController({ name, control, rules: { required, validate } })
6676
return (
67-
<div className="max-w-lg">
68-
<div className="mb-2">
77+
<div className={cn(variant !== 'inline' && 'max-w-lg')}>
78+
{/* Hiding the label for inline inputs but keeping it available for screen readers */}
79+
<div className={cn('mb-2', variant === 'inline' && 'sr-only')}>
6980
<FieldLabel htmlFor={id} id={`${id}-label`} optional={!required}>
7081
{label} {units && <span className="ml-1 text-default">({units})</span>}
7182
</FieldLabel>
@@ -75,54 +86,18 @@ export function TextField<
7586
</TextInputHint>
7687
)}
7788
</div>
78-
{/* passing the generated id is very important for a11y */}
79-
<TextFieldInner name={name} {...props} id={id} />
80-
</div>
81-
)
82-
}
83-
84-
/**
85-
* Primarily exists for `TextField`, but we occasionally also need a plain field
86-
* without a label on it.
87-
*
88-
* Note that `id` is an allowed prop, unlike in `TextField`, where it is always
89-
* generated from `name`. This is because we need to pass the generated ID in
90-
* from there to here. For the case where `TextFieldInner` is used
91-
* independently, we also generate an ID for use only if none is passed in.
92-
*/
93-
export const TextFieldInner = <
94-
TFieldValues extends FieldValues,
95-
TName extends FieldPath<TFieldValues>,
96-
>({
97-
name,
98-
type = 'text',
99-
label = capitalize(name),
100-
validate,
101-
control,
102-
required,
103-
id: idProp,
104-
transform,
105-
...props
106-
}: TextFieldProps<TFieldValues, TName> & UITextAreaProps) => {
107-
const generatedId = useId()
108-
const id = idProp || generatedId
109-
const {
110-
field: { onChange, ...fieldRest },
111-
fieldState: { error },
112-
} = useController({ name, control, rules: { required, validate } })
113-
return (
114-
<>
11589
<UITextField
11690
id={id}
11791
title={label}
11892
type={type}
11993
error={!!error}
120-
aria-labelledby={`${id}-label ${id}-help-text`}
94+
aria-labelledby={cn(`${id}-label`, description ? `${id}-help-text` : '')}
12195
onChange={(e) => onChange(transform ? transform(e.target.value) : e.target.value)}
12296
{...fieldRest}
12397
{...props}
12498
/>
99+
{/* todo: inline error message tooltip */}
125100
<ErrorMessage error={error} label={label} />
126-
</>
101+
</div>
127102
)
128103
}

app/forms/firewall-rules-common.tsx

Lines changed: 18 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { ListboxField } from '~/components/form/fields/ListboxField'
3030
import { NameField, validateName } from '~/components/form/fields/NameField'
3131
import { NumberField } from '~/components/form/fields/NumberField'
3232
import { RadioField } from '~/components/form/fields/RadioField'
33-
import { TextField, TextFieldInner } from '~/components/form/fields/TextField'
33+
import { TextField } from '~/components/form/fields/TextField'
3434
import { useVpcSelector } from '~/hooks/use-params'
3535
import {
3636
ProtocolCell,
@@ -40,11 +40,9 @@ import {
4040
import { Badge } from '~/ui/lib/Badge'
4141
import { toComboboxItems } from '~/ui/lib/Combobox'
4242
import { FormDivider } from '~/ui/lib/Divider'
43-
import { FieldLabel } from '~/ui/lib/FieldLabel'
4443
import { Message } from '~/ui/lib/Message'
4544
import { ClearAndAddButtons, MiniTable } from '~/ui/lib/MiniTable'
4645
import { SideModal } from '~/ui/lib/SideModal'
47-
import { TextInputHint } from '~/ui/lib/TextInput'
4846
import { KEYS } from '~/ui/util/keys'
4947
import { ALL_ISH } from '~/util/consts'
5048
import { validateIp, validateIpNet } from '~/util/ip'
@@ -647,32 +645,23 @@ export const CommonFields = ({ control, nameTaken, error }: CommonFieldsProps) =
647645
/>
648646

649647
<div className="flex flex-col gap-3">
650-
{/* We have to blow this up instead of using TextField to get better
651-
text styling on the label */}
652-
<div className="mt-2">
653-
<FieldLabel id="portRange-label" htmlFor="portRange">
654-
Port filters
655-
</FieldLabel>
656-
<TextInputHint id="portRange-help-text" className="mb-2">
657-
A single destination port (1234) or a range (1234&ndash;2345)
658-
</TextInputHint>
659-
<TextFieldInner
660-
id="portRange"
661-
name="portRange"
662-
required
663-
control={portRangeForm.control}
664-
onKeyDown={(e) => {
665-
if (e.key === KEYS.enter) {
666-
e.preventDefault() // prevent full form submission
667-
submitPortRange(e)
668-
}
669-
}}
670-
validate={(value) => {
671-
if (!parsePortRange(value)) return 'Not a valid port range'
672-
if (ports.value.includes(value.trim())) return 'Port range already added'
673-
}}
674-
/>
675-
</div>
648+
<TextField
649+
label="Port filters"
650+
description="A single destination port (1234) or a range (1234&ndash;2345)"
651+
name="portRange"
652+
required
653+
control={portRangeForm.control}
654+
onKeyDown={(e) => {
655+
if (e.key === KEYS.enter) {
656+
e.preventDefault() // prevent full form submission
657+
submitPortRange(e)
658+
}
659+
}}
660+
validate={(value) => {
661+
if (!parsePortRange(value)) return 'Not a valid port range'
662+
if (ports.value.includes(value.trim())) return 'Port range already added'
663+
}}
664+
/>
676665
<ClearAndAddButtons
677666
addButtonCopy="Add port filter"
678667
disabled={!portValue}

app/forms/network-interface-edit.tsx

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,13 @@ import {
1818

1919
import { DescriptionField } from '~/components/form/fields/DescriptionField'
2020
import { NameField } from '~/components/form/fields/NameField'
21-
import { TextFieldInner } from '~/components/form/fields/TextField'
21+
import { TextField } from '~/components/form/fields/TextField'
2222
import { SideModalForm } from '~/components/form/SideModalForm'
2323
import { HL } from '~/components/HL'
2424
import { useInstanceSelector } from '~/hooks/use-params'
2525
import { addToast } from '~/stores/toast'
2626
import { FormDivider } from '~/ui/lib/Divider'
27-
import { FieldLabel } from '~/ui/lib/FieldLabel'
2827
import { ClearAndAddButtons, MiniTable } from '~/ui/lib/MiniTable'
29-
import { TextInputHint } from '~/ui/lib/TextInput'
3028
import { KEYS } from '~/ui/util/keys'
3129
import { validateIpNet } from '~/util/ip'
3230
import { links } from '~/util/links'
@@ -96,36 +94,32 @@ export function EditNetworkInterfaceForm({
9694
<FormDivider />
9795

9896
<div className="flex flex-col gap-3">
99-
{/* We have to blow this up instead of using TextField for better layout control of field and ClearAndAddButtons */}
100-
<div>
101-
<FieldLabel id="transitIp-label" htmlFor="transitIp" optional>
102-
Transit IPs
103-
</FieldLabel>
104-
<TextInputHint id="transitIp-help-text" className="mb-2">
105-
An IP network, like 192.168.0.0/16.{' '}
106-
<a href={links.transitIpsDocs} target="_blank" rel="noreferrer">
107-
Learn more about transit IPs.
108-
</a>
109-
</TextInputHint>
110-
<TextFieldInner
111-
id="transitIp"
112-
name="transitIp"
113-
control={transitIpsForm.control}
114-
onKeyDown={(e) => {
115-
if (e.key === KEYS.enter) {
116-
e.preventDefault() // prevent full form submission
117-
submitTransitIp()
118-
}
119-
}}
120-
validate={(value) => {
121-
const error = validateIpNet(value)
122-
if (error) return error
97+
<TextField
98+
name="transitIp"
99+
label="Transit IPs"
100+
description={
101+
<>
102+
An IP network, like 192.168.0.0/16.{' '}
103+
<a href={links.transitIpsDocs} target="_blank" rel="noreferrer">
104+
Learn more about transit IPs.
105+
</a>
106+
</>
107+
}
108+
control={transitIpsForm.control}
109+
onKeyDown={(e) => {
110+
if (e.key === KEYS.enter) {
111+
e.preventDefault() // prevent full form submission
112+
submitTransitIp()
113+
}
114+
}}
115+
validate={(value) => {
116+
const error = validateIpNet(value)
117+
if (error) return error
123118

124-
if (transitIps.includes(value)) return 'Transit IP already in list'
125-
}}
126-
placeholder="Enter an IP network"
127-
/>
128-
</div>
119+
if (transitIps.includes(value)) return 'Transit IP already in list'
120+
}}
121+
placeholder="Enter an IP network"
122+
/>
129123
<ClearAndAddButtons
130124
addButtonCopy="Add Transit IP"
131125
disabled={!transitIpValue}

app/forms/project-create.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export default function ProjectCreateSideModalForm() {
5656
loading={createProject.isPending}
5757
submitError={createProject.error}
5858
>
59-
<NameField name="name" control={form.control} />
59+
<NameField variant="inline" name="name" control={form.control} />
6060
<DescriptionField name="description" control={form.control} />
6161
</SideModalForm>
6262
)

app/pages/LoginPage.tsx

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useNavigate, useSearchParams } from 'react-router'
1111

1212
import { useApiMutation, type UsernamePasswordCredentials } from '@oxide/api'
1313

14-
import { TextFieldInner } from '~/components/form/fields/TextField'
14+
import { TextField } from '~/components/form/fields/TextField'
1515
import { useSiloSelector } from '~/hooks/use-params'
1616
import { addToast } from '~/stores/toast'
1717
import { Button } from '~/ui/lib/Button'
@@ -58,24 +58,22 @@ export default function LoginPage() {
5858
loginPost.mutate({ body, path: { siloName: silo } })
5959
})}
6060
>
61-
<div>
62-
<TextFieldInner
63-
name="username"
64-
placeholder="Username"
65-
autoComplete="username"
66-
required
67-
control={form.control}
68-
/>
69-
</div>
70-
<div>
71-
<TextFieldInner
72-
name="password"
73-
type="password"
74-
placeholder="Password"
75-
required
76-
control={form.control}
77-
/>
78-
</div>
61+
<TextField
62+
variant="inline"
63+
name="username"
64+
placeholder="Username"
65+
autoComplete="username"
66+
required
67+
control={form.control}
68+
/>
69+
<TextField
70+
variant="inline"
71+
name="password"
72+
type="password"
73+
placeholder="Password"
74+
required
75+
control={form.control}
76+
/>
7977
<Button type="submit" className="w-full" disabled={loginPost.isPending}>
8078
Sign in
8179
</Button>

0 commit comments

Comments
 (0)