@estiva-app/ui 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -90,7 +90,10 @@ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
90
90
  send the highlight to the first or last row; the palette stops that.
91
91
  - **The field is named by its placeholder**, so say what it asks for.
92
92
  - **A form's fields go in `Field`s.** Mark the missing ones with `error` inside
93
- `onSubmit`, and the first of them takes focus. `working` locks the fields,
93
+ `onSubmit`, and the first of them takes focus. The level is the package
94
+ **Form** (`enterSends={false}`), so work a mark out from what is typed — a
95
+ field that is filled must stop saying it is missing, or the Form will not
96
+ send again. `working` locks the fields,
94
97
  puts its words on the button and beside it, and holds focus on the form so
95
98
  Esc still closes it; when it clears, focus goes to the first field that needs
96
99
  something, or back where it was. `error` is what went wrong, beside the
@@ -111,7 +114,8 @@ import { CommandPalette, CommandPaletteSearch } from '@estiva-app/ui'
111
114
  | | Home / End | move the text cursor; the lit row stays |
112
115
  | inside a level | Backspace at the start of the field, or the chip's ✕ | back, with focus in the field |
113
116
  | a form | Tab / Shift+Tab | between the fields, never out of the window |
114
- | | Ctrl+Enter | submits; if a field needs something, focus goes to the first |
117
+ | | Enter in a text field | nothing; Enter in a text area is a new line |
118
+ | | Ctrl+Enter, or the button | submits; if a field needs something, focus goes to the first |
115
119
  | | Backspace in an empty text field | back. In a form that is one list, Backspace anywhere; in a list inside a form with text fields, nothing |
116
120
  | | while working | nothing but Esc |
117
121
  | | Esc in an open list | closes the list first |
@@ -155,7 +155,11 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
155
155
 
156
156
  // ── The form keeps what was typed while the palette is open.
157
157
  const [draft, setDraft] = useState({ title: '', notes: '', kind: 'one', group: '' })
158
- const [missing, setMissing] = useState<{ title?: string; group?: string }>({})
158
+ // What a field needs is worked out from the draft once a send has been tried, so a field that is
159
+ // filled stops saying so at once. A mark left standing would keep the Form from ever sending.
160
+ const [tried, setTried] = useState(false)
161
+ const need = { title: draft.title.trim() ? undefined : 'Give it a title.', group: draft.group ? undefined : 'Choose a group.' }
162
+ const missing: { title?: string; group?: string } = tried ? need : {}
159
163
  const [working, setWorking] = useState(false)
160
164
  const [refused, setRefused] = useState(false)
161
165
  const [kind, setKind] = useState('one')
@@ -264,8 +268,7 @@ function Demo({ start, where, modKey, label }: { start: Start; where?: string; m
264
268
  }, [frame, level, asked, recent, searching])
265
269
 
266
270
  const submitForm = async () => {
267
- const need = { title: draft.title.trim() ? undefined : 'Give it a title.', group: draft.group ? undefined : 'Choose a group.' }
268
- setMissing(need)
271
+ setTried(true)
269
272
  setRefused(false)
270
273
  if (need.title || need.group) return
271
274
  setWorking(true)
@@ -316,6 +316,21 @@ describe('CommandPaletteForm', () => {
316
316
  expect(onSubmit).toHaveBeenCalledTimes(1)
317
317
  })
318
318
 
319
+ it('sends nothing on a plain Enter in a field; the button sends, through the same Form submit', async () => {
320
+ const onSubmit = vi.fn()
321
+ const user = userEvent.setup()
322
+ render(<Form onSubmit={onSubmit} />)
323
+ const title = screen.getByRole('textbox', { name: 'Title' }) as HTMLInputElement
324
+ await waitFor(() => expect(document.activeElement).toBe(title))
325
+ await user.keyboard('A{Enter}')
326
+ expect(onSubmit).not.toHaveBeenCalled()
327
+ expect(title.value).toBe('A')
328
+ const button = screen.getByRole('button', { name: 'Create item' })
329
+ expect(button.getAttribute('type')).toBe('submit')
330
+ await user.click(button)
331
+ expect(onSubmit).toHaveBeenCalledTimes(1)
332
+ })
333
+
319
334
  it('puts focus on the first field that needs something', async () => {
320
335
  const user = userEvent.setup()
321
336
  render(<Form />)
@@ -19,6 +19,7 @@ import { Button } from './Button'
19
19
  import { InputChip } from './ChipInput'
20
20
  import { EmptyState } from './EmptyState'
21
21
  import { FieldLine } from './Field'
22
+ import { Form } from './Form'
22
23
  import { Kbd } from './Kbd'
23
24
  import { EnterHint, MenuItemBody, menuItemClassName } from './Menu'
24
25
  import { ScrollArea } from './ScrollArea'
@@ -484,15 +485,18 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
484
485
  const busy = !!working
485
486
  const busyRef = useRef(busy)
486
487
  busyRef.current = busy
487
- const returnTo = useRef<HTMLElement | null>(null)
488
488
 
489
489
  /** A field that says it needs something: Base UI marks the `Field` itself. */
490
490
  const firstInvalid = () => frameRef.current?.querySelector<HTMLElement>('[data-invalid]')?.querySelector<HTMLElement>(CONTROL) ?? null
491
491
 
492
+ /*
493
+ The form's one way in: its button (type="submit") and Ctrl+Enter both
494
+ arrive here through the package Form's submit, so Base UI's field check
495
+ runs for both. The guard stays: a waiting button is focusable while
496
+ disabled, so it is not the browser that stops it.
497
+ */
492
498
  const submit = () => {
493
499
  if (busyRef.current || submitWaits) return
494
- const active = document.activeElement
495
- returnTo.current = active instanceof HTMLElement && frameRef.current?.contains(active) ? active : null
496
500
  onSubmit()
497
501
  // If the caller marked fields instead of starting, the first of them
498
502
  // takes focus: the key list's "focus goes to the first".
@@ -502,26 +506,12 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
502
506
  }
503
507
 
504
508
  /*
505
- The lock must not lose focus.
506
-
507
- Locking disables the fields, and a disabled field drops focus to the page
508
- measured in the prototype, where after Ctrl+Enter no key but Esc ever
509
- worked again, even after the error came back. So while the form works,
510
- focus sits on the form's own box, where its keys still arrive; when it
511
- stops, focus goes to the first field that needs something, or back where
512
- it was, or to the first field.
509
+ The lock must not lose focus — measured in the prototype, where after
510
+ Ctrl+Enter no key but Esc ever worked again. The package Form does it now
511
+ (UIG-7): while it works, focus sits on the form, where its keys still
512
+ arrive; when it stops, focus goes to the first field that needs something,
513
+ or back where it was, or to the first field.
513
514
  */
514
- useLayoutEffect(() => {
515
- if (busy) {
516
- frameRef.current?.focus()
517
- return
518
- }
519
- if (document.activeElement !== frameRef.current) return
520
- const was = returnTo.current
521
- const target = firstInvalid() ?? (was?.isConnected && !(was as HTMLButtonElement).disabled ? was : null) ?? firstControl(popupRef.current)
522
- target?.focus()
523
- // Runs when the lock changes, and reads the DOM it leaves behind.
524
- }, [busy])
525
515
 
526
516
  /*
527
517
  Backspace goes back only where nothing typed is lost: from an empty text
@@ -541,12 +531,8 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
541
531
  const measure = () => setBackNamed(backWorksFrom(document.activeElement))
542
532
  useLayoutEffect(measure)
543
533
 
534
+ // Ctrl+Enter is the Form's; plain Enter in a field sends nothing here (`enterSends={false}`).
544
535
  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
545
- if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
546
- e.preventDefault()
547
- submit()
548
- return
549
- }
550
536
  if (e.key === 'Backspace' && !e.ctrlKey && !e.metaKey && !e.altKey && backWorksFrom(e.target as Element)) {
551
537
  e.preventDefault()
552
538
  back()
@@ -565,27 +551,29 @@ export function CommandPaletteForm({ chip, icon, submitLabel, onSubmit, submitWa
565
551
  {icon != null && <span className="flex shrink-0 items-center text-text-secondary">{icon}</span>}
566
552
  <LevelChip chip={chip} onBack={back} />
567
553
  </div>
568
- <ScrollArea viewportClassName="max-h-[420px]" contentClassName="flex flex-col px-5 py-4">
569
- {/* `contents`: the fieldset only locks; the fields lay out as if it
570
- were not there. */}
571
- <fieldset data-command-palette-fields="" disabled={busy} className="contents">
554
+ {/* The package Form, from the fields to the button (UIG-7). The chip row
555
+ stays outside it, so its still goes back while the form works; the
556
+ button row is inside it, so focus that was on the button is held.
557
+ The fields are found through data-command-palette-fields. */}
558
+ <Form data-command-palette-fields="" busy={busy} enterSends={false} onSubmit={submit} className="flex min-h-0 flex-col">
559
+ <ScrollArea viewportClassName="max-h-[420px]" contentClassName="flex flex-col px-5 py-4">
572
560
  <div className="flex flex-col gap-4">{children}</div>
573
- </fieldset>
574
- </ScrollArea>
575
- <div className="flex shrink-0 items-center gap-3 px-5 pb-4">
576
- <div className="min-w-0 flex-1">
577
- {working ? <FieldLine>{working.line}</FieldLine> : error ? <FieldLine tone="error">{error}</FieldLine> : null}
561
+ </ScrollArea>
562
+ <div className="flex shrink-0 items-center gap-3 px-5 pb-4">
563
+ <div className="min-w-0 flex-1">
564
+ {working ? <FieldLine>{working.line}</FieldLine> : error ? <FieldLine tone="error">{error}</FieldLine> : null}
565
+ </div>
566
+ <Button
567
+ type="submit"
568
+ variant="primary"
569
+ disabled={busy}
570
+ disabledReason={busy ? undefined : submitWaits}
571
+ leadingIcon={busy ? <IconLoader2 size={16} stroke={1.5} className="animate-spin" /> : undefined}
572
+ >
573
+ {working ? working.button : submitLabel}
574
+ </Button>
578
575
  </div>
579
- <Button
580
- variant="primary"
581
- onClick={submit}
582
- disabled={busy}
583
- disabledReason={busy ? undefined : submitWaits}
584
- leadingIcon={busy ? <IconLoader2 size={16} stroke={1.5} className="animate-spin" /> : undefined}
585
- >
586
- {working ? working.button : submitLabel}
587
- </Button>
588
- </div>
576
+ </Form>
589
577
  <Footer keys={keys} />
590
578
  </div>
591
579
  )
package/src/Form.mdx CHANGED
@@ -36,7 +36,10 @@ import { Button, Field, Form, TextInput } from '@estiva-app/ui'
36
36
  ```
37
37
 
38
38
  - **`onSubmit` takes nothing.** The page's own submit is already prevented;
39
- there is no event to stop.
39
+ there is no event to stop. Every way of sending — Enter, Ctrl+Enter, a
40
+ submit button — arrives here the same way, after the same field check.
41
+ - **`enterSends={false}`** keeps Enter in a one-line field from sending, where
42
+ only Ctrl+Enter may: a form inside **CommandPalette**, which uses it.
40
43
  - **`busy` switches off every field and button inside**, so they do not
41
44
  each need `disabled={busy}`. A button outside the form that sends it —
42
45
  a dialog's footer, with `form="<id>"` and the form's `id` — is outside,
@@ -57,10 +60,17 @@ import { Button, Field, Form, TextInput } from '@estiva-app/ui'
57
60
 
58
61
  ## Keys
59
62
 
63
+ The same in every form (Katerina, 16 September).
64
+
60
65
  | Key | Does |
61
66
  |---|---|
62
- | Enter, in a one-line field | Sends the form. |
67
+ | Enter, in a one-line field | Sends the form — with or without a submit button, however many fields. Not with `enterSends={false}`. |
68
+ | Shift+Enter or Alt+Enter, in a one-line field | Nothing — the browser would send the form on its own; the form stops it. |
63
69
  | Enter, in a Textarea | A new line; it does not send. |
70
+ | Enter, in a list or a people picker | Picks; it does not send. |
71
+ | Enter, in a field that handles Enter itself | What the field does; the form does not send. |
72
+ | Ctrl+Enter (Cmd+Enter), anywhere inside | Sends the form. |
73
+ | Any of these, while busy | Nothing: a form that is sending does not send again. |
64
74
  | Tab, while busy | Nothing inside takes focus. |
65
75
 
66
76
  ## Props
package/src/Form.test.tsx CHANGED
@@ -54,6 +54,109 @@ describe('Form', () => {
54
54
  expect(onSubmit).toHaveBeenCalledTimes(1)
55
55
  })
56
56
 
57
+ describe('the keys, the same in every form', () => {
58
+ function Keys({ onSubmit, enterSends }: { onSubmit: () => void; enterSends?: boolean }) {
59
+ return (
60
+ <Form onSubmit={onSubmit} enterSends={enterSends} aria-label="Probe">
61
+ <Field label="One">
62
+ <TextInput />
63
+ </Field>
64
+ <Field label="Two">
65
+ <TextInput />
66
+ </Field>
67
+ <Field label="Words">
68
+ <Textarea />
69
+ </Field>
70
+ <input role="combobox" aria-label="Pick" aria-expanded="false" aria-controls="none" />
71
+ </Form>
72
+ )
73
+ }
74
+
75
+ it('Enter in a one-line field sends, even with two fields and no submit button', async () => {
76
+ const user = userEvent.setup()
77
+ const onSubmit = vi.fn()
78
+ render(<Keys onSubmit={onSubmit} />)
79
+ await user.type(screen.getByRole('textbox', { name: 'Two' }), 'x{Enter}')
80
+ expect(onSubmit).toHaveBeenCalledTimes(1)
81
+ })
82
+
83
+ it('Enter in a text area is a new line; Ctrl+Enter and Cmd+Enter there send', async () => {
84
+ const user = userEvent.setup()
85
+ const onSubmit = vi.fn()
86
+ render(<Keys onSubmit={onSubmit} />)
87
+ const words = screen.getByRole('textbox', { name: 'Words' }) as HTMLTextAreaElement
88
+ await user.type(words, 'a{Enter}b')
89
+ expect(words.value).toBe('a\nb')
90
+ expect(onSubmit).not.toHaveBeenCalled()
91
+ await user.keyboard('{Control>}{Enter}{/Control}')
92
+ expect(onSubmit).toHaveBeenCalledTimes(1)
93
+ await user.keyboard('{Meta>}{Enter}{/Meta}')
94
+ expect(onSubmit).toHaveBeenCalledTimes(2)
95
+ })
96
+
97
+ it("Enter in a picker's input does not send", async () => {
98
+ const user = userEvent.setup()
99
+ const onSubmit = vi.fn()
100
+ render(<Keys onSubmit={onSubmit} />)
101
+ await user.type(screen.getByRole('combobox', { name: 'Pick' }), 'x{Enter}')
102
+ expect(onSubmit).not.toHaveBeenCalled()
103
+ })
104
+
105
+ it('a field that handles its own Enter keeps it', async () => {
106
+ const user = userEvent.setup()
107
+ const onSubmit = vi.fn()
108
+ render(
109
+ <Form onSubmit={onSubmit} aria-label="Probe">
110
+ <TextInput aria-label="Own" onKeyDown={(event) => event.key === 'Enter' && event.preventDefault()} />
111
+ </Form>,
112
+ )
113
+ await user.type(screen.getByRole('textbox', { name: 'Own' }), 'x{Enter}')
114
+ expect(onSubmit).not.toHaveBeenCalled()
115
+ })
116
+
117
+ it('with enterSends off, Enter in a one-line field does nothing and Ctrl+Enter sends', async () => {
118
+ const user = userEvent.setup()
119
+ const onSubmit = vi.fn()
120
+ render(<Keys onSubmit={onSubmit} enterSends={false} />)
121
+ const one = screen.getByRole('textbox', { name: 'One' }) as HTMLInputElement
122
+ await user.type(one, 'x{Enter}')
123
+ expect(onSubmit).not.toHaveBeenCalled()
124
+ expect(one.value).toBe('x')
125
+ await user.keyboard('{Control>}{Enter}{/Control}')
126
+ expect(onSubmit).toHaveBeenCalledTimes(1)
127
+ })
128
+
129
+ it('Shift+Enter and Alt+Enter in a one-line field send nothing, even with a submit button', async () => {
130
+ const onSubmit = vi.fn()
131
+ render(<Harness onSubmit={onSubmit} />)
132
+ const field = screen.getByRole('textbox', { name: 'First' })
133
+ // The browser's implicit submission is the default action of this keydown: prevented, it does not happen.
134
+ for (const modifier of [{ shiftKey: true }, { altKey: true }]) {
135
+ expect(fireEvent.keyDown(field, { key: 'Enter', ...modifier })).toBe(false)
136
+ }
137
+ expect(onSubmit).not.toHaveBeenCalled()
138
+ })
139
+
140
+ it('Enter with a submit button in the form sends once, not twice', async () => {
141
+ const user = userEvent.setup()
142
+ const onSubmit = vi.fn()
143
+ render(<Harness onSubmit={onSubmit} />)
144
+ await user.type(screen.getByRole('textbox', { name: 'First' }), 'x{Enter}')
145
+ expect(onSubmit).toHaveBeenCalledTimes(1)
146
+ })
147
+
148
+ it('does not send again while busy', async () => {
149
+ const onSubmit = vi.fn()
150
+ const { rerender } = render(<Harness onSubmit={onSubmit} />)
151
+ act(() => screen.getByRole('textbox', { name: 'First' }).focus())
152
+ rerender(<Harness onSubmit={onSubmit} busy />)
153
+ const form = screen.getByRole('form', { name: 'Probe' })
154
+ fireEvent.keyDown(form, { key: 'Enter', ctrlKey: true })
155
+ fireEvent.submit(form)
156
+ expect(onSubmit).not.toHaveBeenCalled()
157
+ })
158
+ })
159
+
57
160
  it('does not send while a field shows its error', async () => {
58
161
  const user = userEvent.setup()
59
162
  const onSubmit = vi.fn()
package/src/Form.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useLayoutEffect, useRef, type FormHTMLAttributes, type ReactNode } from 'react'
1
+ import { useLayoutEffect, useRef, type FormHTMLAttributes, type KeyboardEvent, type ReactNode } from 'react'
2
2
  import { Fieldset } from '@base-ui/react/fieldset'
3
3
  import { Form as BaseForm } from '@base-ui/react/form'
4
4
  import { cn } from './cn'
@@ -27,26 +27,57 @@ import { FormBusyContext, useFormBusy } from './formBusy'
27
27
  * When `busy` ends, focus goes to the first invalid field, else to what sent
28
28
  * the form, else to the first control. That is `CommandPalette`'s order
29
29
  * too, so the two read the same (UIG-29).
30
+ *
31
+ * The keys are ours, the same in every form (Katerina, 16 September): Enter in
32
+ * a one-line field sends; Enter in a text area is a new line; Enter in a list
33
+ * or a people picker picks; Ctrl+Enter (Cmd+Enter) sends from anywhere inside.
34
+ * The form sends on Enter itself rather than leaving it to the browser, whose
35
+ * implicit submission depends on whether the form has a submit button and how
36
+ * many fields it holds. `enterSends={false}` keeps Enter in a one-line field
37
+ * from sending, for a form where only Ctrl+Enter may send (`CommandPalette`).
38
+ * Every way of sending goes through the form's submit, so Base UI's field
39
+ * check runs for each.
30
40
  */
31
41
  export interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit' | 'children' | 'noValidate'> {
32
42
  /** Enter in a field, or a submit button. The page's own submit is already prevented. */
33
43
  onSubmit: () => void | Promise<void>
34
44
  /** While sending: every field and button inside is switched off, and focus waits on the form. */
35
45
  busy?: boolean
46
+ /**
47
+ * Whether Enter in a one-line field sends. On by default. Off where only
48
+ * Ctrl+Enter may send — a form inside `CommandPalette`. Ctrl+Enter sends either way.
49
+ */
50
+ enterSends?: boolean
36
51
  children: ReactNode
37
52
  }
38
53
 
39
54
  const CONTROL = 'input:not([type="hidden"]), textarea, select, button, [role="checkbox"], [role="combobox"], [tabindex]:not([tabindex="-1"])'
40
55
 
56
+ /** The inputs a person types one line into; a picker's input (`role="combobox"`) is not one. */
57
+ const ONE_LINE = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'password', 'number'])
58
+
41
59
  function usable(element: Element | null): element is HTMLElement {
42
60
  return element instanceof HTMLElement && element.isConnected && element.matches(CONTROL) && !element.matches(':disabled') && element.getAttribute('aria-disabled') !== 'true'
43
61
  }
44
62
 
45
- export function Form({ onSubmit, busy: ownBusy = false, className, children, ...props }: FormProps) {
63
+ /** A marked field's own control: Base UI marks the control and the `Field` around it. */
64
+ function firstInvalid(form: HTMLElement): HTMLElement | undefined {
65
+ for (const marked of form.querySelectorAll('[data-invalid]')) {
66
+ if (usable(marked)) return marked
67
+ const inside = Array.from(marked.querySelectorAll(CONTROL)).find(usable)
68
+ if (inside) return inside
69
+ }
70
+ return undefined
71
+ }
72
+
73
+ export function Form({ onSubmit, busy: ownBusy = false, enterSends = true, className, children, ...props }: FormProps) {
46
74
  // A form inside a busy form is busy too.
47
75
  const outerBusy = useFormBusy()
48
76
  const busy = ownBusy || outerBusy
49
77
  const form = useRef<HTMLFormElement>(null)
78
+ // Read at submit time: a form that is sending does not send again (Ctrl+Enter reaches it while busy).
79
+ const busyNow = useRef(busy)
80
+ busyNow.current = busy
50
81
  // What had focus when the form went busy, and whether the form took focus from it.
51
82
  const sender = useRef<Element | null>(null)
52
83
  const holding = useRef(false)
@@ -77,12 +108,39 @@ export function Form({ onSubmit, busy: ownBusy = false, className, children, ...
77
108
  holding.current = false
78
109
  // Someone who moved focus on while waiting keeps it where they put it.
79
110
  if (document.activeElement !== element && document.activeElement !== document.body) return
80
- const invalid = Array.from(element.querySelectorAll('[data-invalid]')).find(usable)
81
- const target = invalid ?? (usable(sender.current) ? sender.current : Array.from(element.querySelectorAll(CONTROL)).find(usable))
111
+ const target = firstInvalid(element) ?? (usable(sender.current) ? sender.current : Array.from(element.querySelectorAll(CONTROL)).find(usable))
82
112
  sender.current = null
83
113
  target?.focus()
84
114
  }, [busy])
85
115
 
116
+ /*
117
+ Bubble phase, after the field's own handler: a picker picks on Enter and
118
+ says so by preventing it, and a field that handles Enter itself does too.
119
+ */
120
+ const onKeyDown = (event: KeyboardEvent<HTMLFormElement>) => {
121
+ props.onKeyDown?.(event)
122
+ if (event.key !== 'Enter' || event.defaultPrevented || event.nativeEvent.isComposing) return
123
+ const target = event.target
124
+ const inInput = target instanceof HTMLInputElement
125
+ /*
126
+ In an input the browser sends a form on its own — on Enter with Shift or
127
+ Alt too (measured in Chrome: Shift+Enter sent Peek's comment box, 16
128
+ September) — so the form stops that every time and sends only by these
129
+ rules. A text area's Enter is a new line and a button's Enter presses it:
130
+ those are left to the browser.
131
+ */
132
+ if (inInput) event.preventDefault()
133
+ if (event.altKey || event.shiftKey) return
134
+ if (event.ctrlKey || event.metaKey) {
135
+ event.preventDefault()
136
+ form.current?.requestSubmit()
137
+ return
138
+ }
139
+ if (!inInput) return
140
+ const oneLine = ONE_LINE.has((target.getAttribute('type') ?? '').toLowerCase()) && target.getAttribute('role') !== 'combobox'
141
+ if (enterSends && oneLine) form.current?.requestSubmit()
142
+ }
143
+
86
144
  return (
87
145
  <BaseForm
88
146
  ref={form}
@@ -100,8 +158,10 @@ export function Form({ onSubmit, busy: ownBusy = false, className, children, ...
100
158
  if (next && !form.current?.contains(next)) lastInside.current = null
101
159
  props.onBlur?.(event)
102
160
  }}
161
+ onKeyDown={onKeyDown}
103
162
  onSubmit={(event) => {
104
163
  event.preventDefault()
164
+ if (busyNow.current) return
105
165
  void onSubmit()
106
166
  }}
107
167
  >