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
132 changes: 132 additions & 0 deletions app/components/form/Subform.browser.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* 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 http://mozilla.org/MPL/2.0/.
*
* Copyright Oxide Computer Company
*/
import { useState } from 'react'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { render, type RenderResult } from 'vitest-browser-react'
import { commands, userEvent } from 'vitest/browser'

import { Combobox, toComboboxItems } from '~/ui/lib/Combobox'

import { Subform } from './Subform'

// Combobox's onClose behaves differently when the document is unfocused, and
// the test iframe's focus state is nondeterministic. See Combobox.browser.spec.tsx.
beforeEach(() => {
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
})

afterEach(() => {
vi.restoreAllMocks()
})

const items = toComboboxItems([{ name: 'disk-3' }, { name: 'disk-4' }])

// Filling alone doesn't reliably leave the first option highlighted (Firefox
// CI pressed Enter with nothing active), so open the list and Home to it
// explicitly, like Combobox.browser.spec.tsx does
async function highlightFirstOption(screen: RenderResult, label: string) {
const combobox = screen.getByRole('combobox', { name: label })
await combobox.fill('disk')
await commands.pressComboboxKey(label, 'ArrowDown')
const option = screen.getByRole('option', { name: 'disk-3' })
await expect.element(option).toBeVisible()
await commands.pressComboboxKey(label, 'Home')
await expect
.element(combobox)
.toHaveAttribute('aria-activedescendant', option.element().id)
}

function Harness() {
const [inner, setInner] = useState('')
const [outer, setOuter] = useState('')
const [formSubmits, setFormSubmits] = useState(0)
const [subformSubmits, setSubformSubmits] = useState(0)

return (
<form
aria-label="Parent form"
onSubmit={(event) => {
event.preventDefault()
setFormSubmits((count) => count + 1)
}}
>
<Combobox
items={items}
label="Outer disk"
selectedItemValue={outer}
onChange={setOuter}
/>
<Subform
onSubmit={() => setSubformSubmits((count) => count + 1)}
onClear={() => {}}
addButtonLabel="Add"
disabled={false}
>
<Combobox
items={items}
label="Inner disk"
selectedItemValue={inner}
onChange={setInner}
/>
<input aria-label="Port" />
</Subform>
<button type="submit">Submit</button>
<output>Inner: {inner || '(empty)'}</output>
<output>Outer: {outer || '(empty)'}</output>
<output>Form submits: {formSubmits}</output>
<output>Subform submits: {subformSubmits}</output>
</form>
)
}

test('Enter selects while open, then submits the subform once closed', async () => {
const screen = await render(<Harness />)

await highlightFirstOption(screen, 'Inner disk')
await commands.pressComboboxKey('Inner disk', 'Enter')
await expect.element(screen.getByText('Inner: disk-3')).toBeVisible()
await expect.element(screen.getByText('Subform submits: 0')).toBeVisible()

await commands.pressComboboxKey('Inner disk', 'Enter')
await expect.element(screen.getByText('Subform submits: 1')).toBeVisible()
await expect.element(screen.getByText('Form submits: 0')).toBeVisible()
})

test('Enter in a text input submits the subform', async () => {
const screen = await render(<Harness />)

await screen.getByRole('textbox', { name: 'Port' }).fill('80')
await userEvent.keyboard('{Enter}')
await expect.element(screen.getByText('Subform submits: 1')).toBeVisible()
await expect.element(screen.getByText('Form submits: 0')).toBeVisible()
})

test('Enter on the Add button submits the subform once', async () => {
const screen = await render(<Harness />)

const button = screen.getByRole('button', { name: 'Add' })
// focus directly because WebKit doesn't Tab to buttons
button.element().focus()
await expect.element(button).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect.element(screen.getByText('Subform submits: 1')).toBeVisible()
await expect.element(screen.getByText('Form submits: 0')).toBeVisible()
})

test('outside a subform, Enter in a closed combobox submits the form', async () => {
const screen = await render(<Harness />)

await highlightFirstOption(screen, 'Outer disk')
await commands.pressComboboxKey('Outer disk', 'Enter')
await expect.element(screen.getByText('Outer: disk-3')).toBeVisible()
await expect.element(screen.getByText('Form submits: 0')).toBeVisible()

await commands.pressComboboxKey('Outer disk', 'Enter')
await expect.element(screen.getByText('Form submits: 1')).toBeVisible()
await expect.element(screen.getByText('Subform submits: 0')).toBeVisible()
})
75 changes: 75 additions & 0 deletions app/components/form/Subform.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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 type { ReactNode } from 'react'

import { Button } from '~/ui/lib/Button'
import { KEYS } from '~/ui/util/keys'

type SubformProps = {
/** Called on Enter in one of the subform's inputs and on the Add button */
onSubmit: () => void
onClear: () => void
addButtonLabel: string
/** Disables the Clear and Add buttons */
disabled: boolean
className?: string
children: ReactNode
}

/**
* Container for the fields of a subform: a nested `useForm` rendered inside an
* outer form, like the firewall rule targets or webhook secrets, followed by
* Clear and Add buttons for adding the result to a list. A subform is
* not a DOM form, so Enter in one of its inputs would submit the outer form.
* This catches the Enter as it bubbles and submits the subform instead, so the
* fields themselves don't need any Enter handling.
*
* A combobox with its options open handles Enter itself (selecting the
* highlighted option) and stops propagation, so that Enter never gets here.
*/
export function Subform({
onSubmit,
onClear,
addButtonLabel,
disabled,
className,
children,
}: SubformProps) {
return (
// The div only listens for Enter bubbling up from the inputs inside it
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={cn('flex flex-col gap-3', className)}
onKeyDown={(e) => {
if (
e.key === KEYS.enter &&
// a field already handled this Enter
!e.defaultPrevented &&
// Enter that commits an IME composition isn't a submit
!e.nativeEvent.isComposing &&
// buttons (Add, Clear, listbox triggers) handle Enter themselves
e.target instanceof HTMLInputElement
) {
e.preventDefault() // prevent full form submission
onSubmit()
}
}}
>
<div className="ox-subform">{children}</div>
<div className="flex justify-end gap-2.5">
<Button variant="ghost" size="sm" onClick={onClear} disabled={disabled}>
Clear
</Button>
<Button size="sm" onClick={onSubmit} disabled={disabled}>
{addButtonLabel}
</Button>
</div>
</div>
)
}
Loading
Loading