@multiplatform.one/forms 7.41.0 → 7.43.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.
Files changed (188) hide show
  1. package/dist/cjs/Chip/index.cjs +5 -2
  2. package/dist/cjs/Chip/index.native.js +2 -1
  3. package/dist/cjs/Chip/index.native.js.map +1 -1
  4. package/dist/cjs/Form/index.cjs +25 -22
  5. package/dist/cjs/Form/index.native.js +27 -24
  6. package/dist/cjs/Form/index.native.js.map +1 -1
  7. package/dist/cjs/InputParts/index.cjs +18 -7
  8. package/dist/cjs/InputParts/index.native.js +18 -7
  9. package/dist/cjs/InputParts/index.native.js.map +1 -1
  10. package/dist/cjs/fields/Input/index.cjs +40 -23
  11. package/dist/cjs/fields/Input/index.native.js +42 -25
  12. package/dist/cjs/fields/Input/index.native.js.map +1 -1
  13. package/dist/cjs/fields/SearchInput/index.cjs +5 -2
  14. package/dist/cjs/fields/SearchInput/index.native.js +2 -1
  15. package/dist/cjs/fields/SearchInput/index.native.js.map +1 -1
  16. package/dist/cjs/index.cjs +7 -0
  17. package/dist/cjs/index.native.js +7 -0
  18. package/dist/cjs/index.native.js.map +1 -1
  19. package/dist/cjs/keyboard/KeyboardDoneBar.cjs +30 -0
  20. package/dist/cjs/keyboard/KeyboardDoneBar.native.js +65 -0
  21. package/dist/cjs/keyboard/KeyboardDoneBar.native.js.map +1 -0
  22. package/dist/cjs/keyboard/chain.cjs +106 -0
  23. package/dist/cjs/keyboard/chain.native.js +203 -0
  24. package/dist/cjs/keyboard/chain.native.js.map +1 -0
  25. package/dist/cjs/keyboard/index.cjs +35 -0
  26. package/dist/cjs/keyboard/index.native.js +38 -0
  27. package/dist/cjs/keyboard/index.native.js.map +1 -0
  28. package/dist/cjs/keyboard/types.cjs +18 -0
  29. package/dist/cjs/keyboard/types.native.js +21 -0
  30. package/dist/cjs/keyboard/types.native.js.map +1 -0
  31. package/dist/cjs/keyboard/useKeyboardChain.cjs +33 -0
  32. package/dist/cjs/keyboard/useKeyboardChain.native.js +114 -0
  33. package/dist/cjs/keyboard/useKeyboardChain.native.js.map +1 -0
  34. package/dist/cjs/shared/nativeAutoFocus.cjs +37 -0
  35. package/dist/cjs/shared/nativeAutoFocus.native.js +41 -0
  36. package/dist/cjs/shared/nativeAutoFocus.native.js.map +1 -0
  37. package/dist/cjs/themedPrimitives.cjs +2 -0
  38. package/dist/cjs/themedPrimitives.native.js +2 -0
  39. package/dist/cjs/themedPrimitives.native.js.map +1 -1
  40. package/dist/esm/Chip/index.mjs +5 -2
  41. package/dist/esm/Chip/index.mjs.map +1 -1
  42. package/dist/esm/Chip/index.native.js +2 -1
  43. package/dist/esm/Chip/index.native.js.map +1 -1
  44. package/dist/esm/Form/index.mjs +25 -22
  45. package/dist/esm/Form/index.mjs.map +1 -1
  46. package/dist/esm/Form/index.native.js +27 -24
  47. package/dist/esm/Form/index.native.js.map +1 -1
  48. package/dist/esm/InputParts/index.mjs +18 -7
  49. package/dist/esm/InputParts/index.mjs.map +1 -1
  50. package/dist/esm/InputParts/index.native.js +18 -7
  51. package/dist/esm/InputParts/index.native.js.map +1 -1
  52. package/dist/esm/fields/Input/index.mjs +41 -24
  53. package/dist/esm/fields/Input/index.mjs.map +1 -1
  54. package/dist/esm/fields/Input/index.native.js +43 -26
  55. package/dist/esm/fields/Input/index.native.js.map +1 -1
  56. package/dist/esm/fields/SearchInput/index.mjs +5 -2
  57. package/dist/esm/fields/SearchInput/index.mjs.map +1 -1
  58. package/dist/esm/fields/SearchInput/index.native.js +2 -1
  59. package/dist/esm/fields/SearchInput/index.native.js.map +1 -1
  60. package/dist/esm/index.mjs +2 -1
  61. package/dist/esm/index.mjs.map +1 -1
  62. package/dist/esm/index.native.js +2 -1
  63. package/dist/esm/index.native.js.map +1 -1
  64. package/dist/esm/keyboard/KeyboardDoneBar.mjs +5 -0
  65. package/dist/esm/keyboard/KeyboardDoneBar.mjs.map +1 -0
  66. package/dist/esm/keyboard/KeyboardDoneBar.native.js +37 -0
  67. package/dist/esm/keyboard/KeyboardDoneBar.native.js.map +1 -0
  68. package/dist/esm/keyboard/chain.mjs +78 -0
  69. package/dist/esm/keyboard/chain.mjs.map +1 -0
  70. package/dist/esm/keyboard/chain.native.js +172 -0
  71. package/dist/esm/keyboard/chain.native.js.map +1 -0
  72. package/dist/esm/keyboard/index.mjs +5 -0
  73. package/dist/esm/keyboard/index.mjs.map +1 -0
  74. package/dist/esm/keyboard/index.native.js +5 -0
  75. package/dist/esm/keyboard/index.native.js.map +1 -0
  76. package/dist/esm/keyboard/types.mjs +2 -0
  77. package/dist/esm/keyboard/types.mjs.map +1 -0
  78. package/dist/esm/keyboard/types.native.js +2 -0
  79. package/dist/esm/keyboard/types.native.js.map +1 -0
  80. package/dist/esm/keyboard/useKeyboardChain.mjs +8 -0
  81. package/dist/esm/keyboard/useKeyboardChain.mjs.map +1 -0
  82. package/dist/esm/keyboard/useKeyboardChain.native.js +86 -0
  83. package/dist/esm/keyboard/useKeyboardChain.native.js.map +1 -0
  84. package/dist/esm/shared/nativeAutoFocus.mjs +12 -0
  85. package/dist/esm/shared/nativeAutoFocus.mjs.map +1 -0
  86. package/dist/esm/shared/nativeAutoFocus.native.js +13 -0
  87. package/dist/esm/shared/nativeAutoFocus.native.js.map +1 -0
  88. package/dist/esm/themedPrimitives.mjs +2 -0
  89. package/dist/esm/themedPrimitives.mjs.map +1 -1
  90. package/dist/esm/themedPrimitives.native.js +2 -0
  91. package/dist/esm/themedPrimitives.native.js.map +1 -1
  92. package/dist/jsx/Chip/index.mjs +5 -2
  93. package/dist/jsx/Chip/index.mjs.map +1 -1
  94. package/dist/jsx/Chip/index.native.js +2 -1
  95. package/dist/jsx/Chip/index.native.js.map +1 -1
  96. package/dist/jsx/Form/index.mjs +25 -22
  97. package/dist/jsx/Form/index.mjs.map +1 -1
  98. package/dist/jsx/Form/index.native.js +27 -24
  99. package/dist/jsx/Form/index.native.js.map +1 -1
  100. package/dist/jsx/InputParts/index.mjs +18 -7
  101. package/dist/jsx/InputParts/index.mjs.map +1 -1
  102. package/dist/jsx/InputParts/index.native.js +18 -7
  103. package/dist/jsx/InputParts/index.native.js.map +1 -1
  104. package/dist/jsx/fields/Input/index.mjs +41 -24
  105. package/dist/jsx/fields/Input/index.mjs.map +1 -1
  106. package/dist/jsx/fields/Input/index.native.js +42 -25
  107. package/dist/jsx/fields/Input/index.native.js.map +1 -1
  108. package/dist/jsx/fields/SearchInput/index.mjs +5 -2
  109. package/dist/jsx/fields/SearchInput/index.mjs.map +1 -1
  110. package/dist/jsx/fields/SearchInput/index.native.js +2 -1
  111. package/dist/jsx/fields/SearchInput/index.native.js.map +1 -1
  112. package/dist/jsx/index.js +2 -1
  113. package/dist/jsx/index.js.map +1 -1
  114. package/dist/jsx/index.mjs +2 -1
  115. package/dist/jsx/index.mjs.map +1 -1
  116. package/dist/jsx/index.native.js +7 -0
  117. package/dist/jsx/index.native.js.map +1 -1
  118. package/dist/jsx/keyboard/KeyboardDoneBar.mjs +5 -0
  119. package/dist/jsx/keyboard/KeyboardDoneBar.mjs.map +1 -0
  120. package/dist/jsx/keyboard/KeyboardDoneBar.native.js +65 -0
  121. package/dist/jsx/keyboard/KeyboardDoneBar.native.js.map +1 -0
  122. package/dist/jsx/keyboard/chain.mjs +78 -0
  123. package/dist/jsx/keyboard/chain.mjs.map +1 -0
  124. package/dist/jsx/keyboard/chain.native.js +203 -0
  125. package/dist/jsx/keyboard/chain.native.js.map +1 -0
  126. package/dist/jsx/keyboard/index.mjs +5 -0
  127. package/dist/jsx/keyboard/index.mjs.map +1 -0
  128. package/dist/jsx/keyboard/index.native.js +38 -0
  129. package/dist/jsx/keyboard/index.native.js.map +1 -0
  130. package/dist/jsx/keyboard/types.mjs +2 -0
  131. package/dist/jsx/keyboard/types.mjs.map +1 -0
  132. package/dist/jsx/keyboard/types.native.js +21 -0
  133. package/dist/jsx/keyboard/types.native.js.map +1 -0
  134. package/dist/jsx/keyboard/useKeyboardChain.mjs +8 -0
  135. package/dist/jsx/keyboard/useKeyboardChain.mjs.map +1 -0
  136. package/dist/jsx/keyboard/useKeyboardChain.native.js +114 -0
  137. package/dist/jsx/keyboard/useKeyboardChain.native.js.map +1 -0
  138. package/dist/jsx/shared/nativeAutoFocus.mjs +12 -0
  139. package/dist/jsx/shared/nativeAutoFocus.mjs.map +1 -0
  140. package/dist/jsx/shared/nativeAutoFocus.native.js +41 -0
  141. package/dist/jsx/shared/nativeAutoFocus.native.js.map +1 -0
  142. package/dist/jsx/themedPrimitives.mjs +2 -0
  143. package/dist/jsx/themedPrimitives.mjs.map +1 -1
  144. package/dist/jsx/themedPrimitives.native.js +2 -0
  145. package/dist/jsx/themedPrimitives.native.js.map +1 -1
  146. package/package.json +8 -8
  147. package/src/Button/Button.disabledReasonInk.spec.tsx +7 -1
  148. package/src/Chip/index.tsx +2 -1
  149. package/src/Form/index.tsx +31 -26
  150. package/src/InputParts/index.tsx +22 -12
  151. package/src/fields/Input/index.tsx +53 -31
  152. package/src/fields/SearchInput/SearchInput.focusHold.native.spec.tsx +81 -0
  153. package/src/fields/SearchInput/index.tsx +2 -1
  154. package/src/index.ts +17 -0
  155. package/src/keyboard/KeyboardDoneBar.native.tsx +38 -0
  156. package/src/keyboard/KeyboardDoneBar.tsx +6 -0
  157. package/src/keyboard/chain.spec.ts +98 -0
  158. package/src/keyboard/chain.tsx +115 -0
  159. package/src/keyboard/index.ts +5 -0
  160. package/src/keyboard/types.ts +21 -0
  161. package/src/keyboard/useKeyboardChain.native.tsx +79 -0
  162. package/src/keyboard/useKeyboardChain.ts +6 -0
  163. package/src/shared/nativeAutoFocus.ts +20 -0
  164. package/src/themedPrimitives.tsx +9 -1
  165. package/types/Chip/index.d.ts.map +1 -1
  166. package/types/Form/index.d.ts.map +1 -1
  167. package/types/InputParts/index.d.ts.map +1 -1
  168. package/types/fields/Input/index.d.ts.map +1 -1
  169. package/types/fields/SearchInput/index.d.ts.map +1 -1
  170. package/types/index.d.ts +2 -0
  171. package/types/index.d.ts.map +1 -1
  172. package/types/keyboard/KeyboardDoneBar.d.ts +4 -0
  173. package/types/keyboard/KeyboardDoneBar.d.ts.map +1 -0
  174. package/types/keyboard/KeyboardDoneBar.native.d.ts +8 -0
  175. package/types/keyboard/KeyboardDoneBar.native.d.ts.map +1 -0
  176. package/types/keyboard/chain.d.ts +48 -0
  177. package/types/keyboard/chain.d.ts.map +1 -0
  178. package/types/keyboard/index.d.ts +6 -0
  179. package/types/keyboard/index.d.ts.map +1 -0
  180. package/types/keyboard/types.d.ts +19 -0
  181. package/types/keyboard/types.d.ts.map +1 -0
  182. package/types/keyboard/useKeyboardChain.d.ts +4 -0
  183. package/types/keyboard/useKeyboardChain.d.ts.map +1 -0
  184. package/types/keyboard/useKeyboardChain.native.d.ts +3 -0
  185. package/types/keyboard/useKeyboardChain.native.d.ts.map +1 -0
  186. package/types/shared/nativeAutoFocus.d.ts +13 -0
  187. package/types/shared/nativeAutoFocus.d.ts.map +1 -0
  188. package/types/themedPrimitives.d.ts.map +1 -1
@@ -21,6 +21,7 @@ import {
21
21
  import { ContextualSaveBar, type ContextualSaveBarProps } from "../ContextualSaveBar";
22
22
  import { ErrorSummary, collectFormFieldErrors, type ErrorSummaryItem } from "../ErrorSummary";
23
23
  import { RequiredMarkingContext, mapHouseRequiredMarking } from "../requiredMarking";
24
+ import { KeyboardChainScope } from "../keyboard/chain";
24
25
  import { getFormSubmission } from "./submissionController";
25
26
  import { type FormValidateOn, resolveEffectiveValidateOn } from "./validateOn";
26
27
 
@@ -320,32 +321,36 @@ export function Form<TFormData extends Record<string, any>, TFormValidator = any
320
321
  <FormSaveModeContext.Provider value={saveMode}>
321
322
  <RequiredMarkingContext.Provider value={{ mode: requiredMarkMode }}>
322
323
  <FormValidateProvider form={form} validateOn={validateOn}>
323
- <TamaguiForm ref={setFormRef} onSubmit={handleSubmit} {...tamaguiFormProps}>
324
- <YStack
325
- data-mpo-form-layout=""
326
- data-gap={knobProps.gap.gap}
327
- data-size-token={knobProps.sizeToken}
328
- width="100%"
329
- minWidth={0}
330
- // A readable column: fields and actions read as one group on a
331
- // wide screen. `maxWidth` on the Form ejects (e.g. "100%").
332
- maxWidth={(tamaguiFormProps.maxWidth as never) ?? FORM_READABLE_WIDTH}
333
- {...knobProps.gap}
334
- {...(tamaguiFormProps.flex != null ? { flex: tamaguiFormProps.flex as never } : {})}
335
- >
336
- {showErrorSummary ? (
337
- <AutoErrorSummary
338
- form={form}
339
- title={errorSummaryTitle}
340
- formElement={formElementRef.current}
341
- />
342
- ) : null}
343
- {children}
344
- {saveBar ? (
345
- <ContextualSaveBar {...(typeof saveBar === "object" ? saveBar : {})} />
346
- ) : null}
347
- </YStack>
348
- </TamaguiForm>
324
+ <KeyboardChainScope>
325
+ <TamaguiForm ref={setFormRef} onSubmit={handleSubmit} {...tamaguiFormProps}>
326
+ <YStack
327
+ data-mpo-form-layout=""
328
+ data-gap={knobProps.gap.gap}
329
+ data-size-token={knobProps.sizeToken}
330
+ width="100%"
331
+ minWidth={0}
332
+ // A readable column: fields and actions read as one group on a
333
+ // wide screen. `maxWidth` on the Form ejects (e.g. "100%").
334
+ maxWidth={(tamaguiFormProps.maxWidth as never) ?? FORM_READABLE_WIDTH}
335
+ {...knobProps.gap}
336
+ {...(tamaguiFormProps.flex != null
337
+ ? { flex: tamaguiFormProps.flex as never }
338
+ : {})}
339
+ >
340
+ {showErrorSummary ? (
341
+ <AutoErrorSummary
342
+ form={form}
343
+ title={errorSummaryTitle}
344
+ formElement={formElementRef.current}
345
+ />
346
+ ) : null}
347
+ {children}
348
+ {saveBar ? (
349
+ <ContextualSaveBar {...(typeof saveBar === "object" ? saveBar : {})} />
350
+ ) : null}
351
+ </YStack>
352
+ </TamaguiForm>
353
+ </KeyboardChainScope>
349
354
  </FormValidateProvider>
350
355
  </RequiredMarkingContext.Provider>
351
356
  </FormSaveModeContext.Provider>
@@ -50,6 +50,7 @@ import {
50
50
  } from "@multiplatform.one/theme";
51
51
  import { formButtonColors, formCommonColors, formInputColors } from "../shared/colorRamps";
52
52
  import { useControlGrouped } from "../shared/groupContext";
53
+ import { nativeAutoFocus } from "../shared/nativeAutoFocus";
53
54
  import { useIsInTableCell } from "../shared/tableCellContext";
54
55
  import { useFieldA11y, useFieldDescribedBy } from "../fieldLayout";
55
56
 
@@ -593,6 +594,13 @@ const InputGroupImpl = InputGroupFrame.styleable<{
593
594
  }
594
595
  : undefined;
595
596
 
597
+ const focusRing = ensureFocusVisibleRing({
598
+ ...inputOutlineFocus,
599
+ ...control.focusKnobProps,
600
+ ...control.focusVisibleKnobProps,
601
+ outlineOffset: boxRingOffset(ringMode),
602
+ });
603
+
596
604
  return (
597
605
  <FocusContext.Provider focused={focused} setFocused={setFocused}>
598
606
  {wrapNativePressable(
@@ -640,19 +648,20 @@ const InputGroupImpl = InputGroupFrame.styleable<{
640
648
  // resolved backup (and the native path). Button-like Boxes
641
649
  // (Select) do not set `focused` (no Area onFocus).
642
650
  borderColor: formInputColors.border.focus,
643
- ...ensureFocusVisibleRing({
644
- ...inputOutlineFocus,
645
- ...control.focusKnobProps,
646
- ...control.focusVisibleKnobProps,
647
- outlineOffset: boxRingOffset(ringMode),
648
- }),
651
+ ...focusRing,
649
652
  }
650
- : {
651
- // Explicit rest — avoid leaving theme `$outlineColor` (near-white in
652
- // dark) as the computed outline-color while width settles.
653
- // Keep outline-transparent rest (border-flicker fix); do not warn.
654
- ...inputOutlineRest,
655
- })}
653
+ : isWeb
654
+ ? {
655
+ // Explicit rest — avoid leaving theme `$outlineColor` (near-white in
656
+ // dark) as the computed outline-color while width settles.
657
+ // Keep outline-transparent rest (border-flicker fix); do not warn.
658
+ ...inputOutlineRest,
659
+ }
660
+ : // FZ-7718: on iOS the outline's width and offset changing as
661
+ // the field takes focus makes it resign first responder, so a
662
+ // programmatic focus (Return to the next field) never sticks.
663
+ // Native keeps the ring's geometry at rest and fades its color.
664
+ { ...focusRing, outlineColor: "transparent" })}
656
665
  {...suppressedRingProps}
657
666
  // LC-40 DISABLED-VISIBLE: the input frame is text-bearing chrome —
658
667
  // keepLabel washes fill/border (value text keeps >= AA) and
@@ -886,6 +895,7 @@ const InputAreaImpl = InputFrame.styleable<{
886
895
  : undefined
887
896
  }
888
897
  {...props}
898
+ {...nativeAutoFocus(props as { autoFocus?: boolean; autoFocusNative?: boolean })}
889
899
  {...(flexProp != null ? { flex: flexProp } : undefined)}
890
900
  {...(widthProp != null
891
901
  ? { width: widthProp, minWidth: minWidthProp, maxWidth: maxWidthProp }
@@ -26,6 +26,8 @@ import {
26
26
  import { Input as InputParts, useForwardFocus } from "../../InputParts";
27
27
  import type { AnyFormApi, LinkResolver } from "../../types";
28
28
  import { LinkCell } from "../../utils/LinkCell";
29
+ import { getFormSubmission } from "../../Form/submissionController";
30
+ import { useKeyboardChain } from "../../keyboard";
29
31
 
30
32
  type InputAreaProps = ComponentProps<typeof InputParts.Area>;
31
33
 
@@ -167,6 +169,17 @@ export function Input({
167
169
 
168
170
  const resolvedValidators = useResolvedValidators(required, validators, label, name);
169
171
 
172
+ const submitForm = useCallback(() => {
173
+ if (resolvedForm && name) void getFormSubmission(resolvedForm).submit();
174
+ }, [resolvedForm, name]);
175
+ const keyboard = useKeyboardChain({
176
+ inputRef,
177
+ enabled: !disabled && !readOnly && !skeleton && !children,
178
+ multiline,
179
+ onSubmitLast: submitForm,
180
+ onSubmitEditing: inputProps?.onSubmitEditing as ((...args: any[]) => void) | undefined,
181
+ });
182
+
170
183
  // Purpose → autocomplete token + keyboard hints (DG §32). Explicit
171
184
  // autoComplete/keyboardType/inputProps below eject per key.
172
185
  const purposeInputProps = resolveFieldInputProps(purpose, autoComplete);
@@ -337,6 +350,7 @@ export function Input({
337
350
  ? { autoComplete: purposeInputProps.autoComplete }
338
351
  : undefined)}
339
352
  {...inputProps}
353
+ {...keyboard.inputProps}
340
354
  {...identifierProps}
341
355
  {...(multiline !== undefined ? { multiline } : undefined)}
342
356
  {...(maxLength !== undefined ? { maxLength } : undefined)}
@@ -405,43 +419,51 @@ export function Input({
405
419
  );
406
420
  };
407
421
 
422
+ // The iOS Done bar sits outside FieldLayout so it keeps the page's theme,
423
+ // not the error theme an invalid field wraps itself in.
408
424
  if (!resolvedForm || !name) {
409
425
  return (
410
- <FieldLayout {...layoutBind} error={error} onBlur={onBlur}>
411
- <InputParts size={resolvedSize}>
412
- {renderInputBox(value ?? defaultValue, undefined, undefined, !!error)}
413
- </InputParts>
414
- </FieldLayout>
426
+ <>
427
+ <FieldLayout {...layoutBind} error={error} onBlur={onBlur}>
428
+ <InputParts size={resolvedSize}>
429
+ {renderInputBox(value ?? defaultValue, undefined, undefined, !!error)}
430
+ </InputParts>
431
+ </FieldLayout>
432
+ {keyboard.accessory}
433
+ </>
415
434
  );
416
435
  }
417
436
 
418
437
  return (
419
- <Field
420
- form={resolvedForm}
421
- name={name}
422
- defaultValue={defaultValue}
423
- validators={resolvedValidators}
424
- >
425
- {(field) => {
426
- const resolvedError = getFieldError(field, error);
427
- return (
428
- <FieldLayout
429
- {...layoutBind}
430
- error={resolvedError}
431
- onBlur={mergeFieldHandler(field, "handleBlur", onBlur)}
432
- >
433
- <InputParts size={resolvedSize}>
434
- {renderInputBox(
435
- field.state.value as string,
436
- (text) => field.handleChange(text as any),
437
- undefined,
438
- !!resolvedError,
439
- )}
440
- </InputParts>
441
- </FieldLayout>
442
- );
443
- }}
444
- </Field>
438
+ <>
439
+ <Field
440
+ form={resolvedForm}
441
+ name={name}
442
+ defaultValue={defaultValue}
443
+ validators={resolvedValidators}
444
+ >
445
+ {(field) => {
446
+ const resolvedError = getFieldError(field, error);
447
+ return (
448
+ <FieldLayout
449
+ {...layoutBind}
450
+ error={resolvedError}
451
+ onBlur={mergeFieldHandler(field, "handleBlur", onBlur)}
452
+ >
453
+ <InputParts size={resolvedSize}>
454
+ {renderInputBox(
455
+ field.state.value as string,
456
+ (text) => field.handleChange(text as any),
457
+ undefined,
458
+ !!resolvedError,
459
+ )}
460
+ </InputParts>
461
+ </FieldLayout>
462
+ );
463
+ }}
464
+ </Field>
465
+ {keyboard.accessory}
466
+ </>
445
467
  );
446
468
  }
447
469
 
@@ -0,0 +1,81 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
4
+ import { act, fireEvent } from "@testing-library/react";
5
+ import { createRef, useState } from "react";
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import { nativeAutoFocus } from "../../shared/nativeAutoFocus";
8
+ import { SearchInput, type SearchInputHandle } from "./index";
9
+
10
+ vi.mock("tamagui", async (importOriginal) => ({
11
+ ...(await importOriginal<typeof import("tamagui")>()),
12
+ isWeb: false,
13
+ }));
14
+
15
+ const src = join(process.cwd(), "src");
16
+
17
+ function searchbox(container: HTMLElement) {
18
+ return container.querySelector('[role="searchbox"]') as HTMLInputElement;
19
+ }
20
+
21
+ describe("FZ-7785: SearchInput autoFocus reaches the native TextInput", () => {
22
+ it("carries autoFocus to tamagui's native autoFocusNative", () => {
23
+ expect(nativeAutoFocus({ autoFocus: true })).toEqual({ autoFocusNative: true });
24
+ expect(nativeAutoFocus({ autoFocus: false })).toEqual({ autoFocusNative: false });
25
+ expect(nativeAutoFocus({})).toBeUndefined();
26
+ expect(nativeAutoFocus({ autoFocus: true, autoFocusNative: false })).toBeUndefined();
27
+ });
28
+
29
+ it("every MPO text field base forwards it: Input.Area and ThemedTextArea", () => {
30
+ const area = readFileSync(join(src, "InputParts", "index.tsx"), "utf8");
31
+ const textArea = readFileSync(join(src, "themedPrimitives.tsx"), "utf8");
32
+ expect(area).toMatch(/\{\.\.\.props\}\s*\{\.\.\.nativeAutoFocus\(props/);
33
+ expect(textArea).toMatch(/\{\.\.\.props\}\s*\{\.\.\.nativeAutoFocus\(props/);
34
+ });
35
+
36
+ it("autoFocus reaches the field and the field keeps focus", () => {
37
+ const { container } = renderWithProviders(<SearchInput aria-label="Find" autoFocus />);
38
+ const input = searchbox(container);
39
+ expect(input.autofocus || document.activeElement === input).toBe(true);
40
+ act(() => input.focus());
41
+ expect(document.activeElement).toBe(input);
42
+ });
43
+ });
44
+
45
+ describe("FZ-7785: SearchInput holds a programmatic focus on native", () => {
46
+ it("ref.focus() keeps the same field focused through a parent re-render at onFocus", () => {
47
+ const ref = createRef<SearchInputHandle>();
48
+ function Parent() {
49
+ const [focusCount, setFocusCount] = useState(0);
50
+ return (
51
+ <SearchInput
52
+ ref={ref}
53
+ aria-label={`Find ${focusCount}`}
54
+ inputProps={{ onFocus: () => setFocusCount((n) => n + 1) }}
55
+ />
56
+ );
57
+ }
58
+ const { container } = renderWithProviders(<Parent />);
59
+ const input = searchbox(container);
60
+ act(() => ref.current!.focus());
61
+ expect(searchbox(container)).toBe(input);
62
+ expect(document.activeElement).toBe(input);
63
+ });
64
+ });
65
+
66
+ describe("FZ-7785: SearchInput's clear X on native", () => {
67
+ it("empties the field, fires onChange('') and onClear, and keeps focus", () => {
68
+ const onChange = vi.fn();
69
+ const onClear = vi.fn();
70
+ const { container, getByRole } = renderWithProviders(
71
+ <SearchInput aria-label="Find" defaultValue="query" onChange={onChange} onClear={onClear} />,
72
+ );
73
+ const input = searchbox(container);
74
+ act(() => input.focus());
75
+ fireEvent.click(getByRole("button", { name: "Clear search" }));
76
+ expect(onChange).toHaveBeenLastCalledWith("");
77
+ expect(onClear).toHaveBeenCalledTimes(1);
78
+ expect(searchbox(container).value).toBe("");
79
+ expect(document.activeElement).toBe(searchbox(container));
80
+ });
81
+ });
@@ -365,6 +365,7 @@ function SearchInputImpl(
365
365
  />
366
366
  {showClear ? (
367
367
  <View
368
+ group={"searchClear" as unknown as undefined}
368
369
  role="button"
369
370
  tabIndex={0}
370
371
  aria-label={t("Clear search")}
@@ -396,7 +397,7 @@ function SearchInputImpl(
396
397
  alignItems="center"
397
398
  justifyContent="center"
398
399
  hoverStyle={{ backgroundColor: "$color4" }}
399
- pressStyle={{ backgroundColor: "$color5" }}
400
+ {...{ "$group-searchClear-press": { backgroundColor: "$color5" } }}
400
401
  >
401
402
  <InputParts.Icon aria-hidden color={formCommonColors.muted}>
402
403
  <XIcon size={resolvedIconSize} />
package/src/index.ts CHANGED
@@ -28,6 +28,23 @@ export type {
28
28
  UseNavigationBlocker,
29
29
  } from "./ContextualSaveBar";
30
30
 
31
+ // ── Keyboard: return-key chain + iOS Done bar (FZ-7718) ───────
32
+ export {
33
+ KeyboardChain,
34
+ KeyboardChainContext,
35
+ KeyboardChainScope,
36
+ KeyboardDoneBar,
37
+ readingOrder,
38
+ useKeyboardChain,
39
+ } from "./keyboard";
40
+ export type {
41
+ ChainBox,
42
+ ChainEntry,
43
+ KeyboardChainBinding,
44
+ KeyboardChainOptions,
45
+ KeyboardDoneBarProps,
46
+ } from "./keyboard";
47
+
31
48
  // ── Error Summary (DG-VAL-02..04) ─────────────────────────────
32
49
  export {
33
50
  ErrorSummary,
@@ -0,0 +1,38 @@
1
+ import { hairline } from "@multiplatform.one/theme";
2
+ import { InputAccessoryView, Keyboard } from "react-native";
3
+ import { XStack, useTheme } from "tamagui";
4
+ import { Button } from "../Button";
5
+ import type { KeyboardDoneBarProps } from "./types";
6
+
7
+ const DONE_LABEL = "Done";
8
+
9
+ /**
10
+ * The bar iOS shows above the keyboard for an MPO text field: one Done that
11
+ * closes the keyboard, which iOS otherwise gives no way to do for most
12
+ * keyboard types.
13
+ */
14
+ export function KeyboardDoneBar({ nativeID }: KeyboardDoneBarProps) {
15
+ const theme = useTheme();
16
+ return (
17
+ <InputAccessoryView nativeID={nativeID} backgroundColor={theme.color2?.val}>
18
+ <XStack
19
+ justifyContent="flex-end"
20
+ alignItems="center"
21
+ paddingHorizontal="$2"
22
+ paddingVertical="$1"
23
+ backgroundColor="$color2"
24
+ borderColor="$borderColor"
25
+ {...hairline.top}
26
+ >
27
+ <Button
28
+ chromeless
29
+ accessibilityLabel={DONE_LABEL}
30
+ testID="keyboard-done"
31
+ onPress={() => Keyboard.dismiss()}
32
+ >
33
+ {DONE_LABEL}
34
+ </Button>
35
+ </XStack>
36
+ </InputAccessoryView>
37
+ );
38
+ }
@@ -0,0 +1,6 @@
1
+ import type { KeyboardDoneBarProps } from "./types";
2
+
3
+ /** iOS only: the web and Android keyboards bring their own close control. */
4
+ export function KeyboardDoneBar(_props: KeyboardDoneBarProps) {
5
+ return null;
6
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { KeyboardChain, readingOrder, type ChainBox } from "./chain";
3
+
4
+ const box = (id: string, x: number, y: number, width = 100, height = 40): ChainBox => ({
5
+ id,
6
+ x,
7
+ y,
8
+ width,
9
+ height,
10
+ });
11
+
12
+ describe("readingOrder (FZ-7718)", () => {
13
+ it("runs top to bottom", () => {
14
+ expect(readingOrder([box("email", 0, 120), box("name", 0, 40)])).toEqual(["name", "email"]);
15
+ });
16
+
17
+ it("runs left to right across a row, even when the row's fields sit a little off", () => {
18
+ expect(readingOrder([box("last", 200, 42), box("first", 0, 40), box("city", 0, 120)])).toEqual([
19
+ "first",
20
+ "last",
21
+ "city",
22
+ ]);
23
+ });
24
+
25
+ it("drops fields that have no size, such as a hidden tab's", () => {
26
+ expect(readingOrder([box("hidden", 0, 0, 0, 0), box("name", 0, 40)])).toEqual(["name"]);
27
+ });
28
+ });
29
+
30
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
31
+
32
+ function field(chain: KeyboardChain, at: ChainBox) {
33
+ const focus = vi.fn();
34
+ chain.register({ id: at.id, focus, measure: (done) => done(at) });
35
+ return focus;
36
+ }
37
+
38
+ describe("KeyboardChain (FZ-7718)", () => {
39
+ it("moves Return to the next field on screen", async () => {
40
+ const chain = new KeyboardChain();
41
+ field(chain, box("name", 0, 40));
42
+ const email = field(chain, box("email", 0, 120));
43
+ const last = vi.fn();
44
+ await settle();
45
+ chain.advance("name", last);
46
+ expect(email).toHaveBeenCalledOnce();
47
+ expect(last).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it("calls onLast from the last field", async () => {
51
+ const chain = new KeyboardChain();
52
+ const name = field(chain, box("name", 0, 40));
53
+ field(chain, box("email", 0, 120));
54
+ const last = vi.fn();
55
+ await settle();
56
+ chain.advance("email", last);
57
+ expect(last).toHaveBeenCalledOnce();
58
+ expect(name).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it("knows which field is last once measured, and tells subscribers", async () => {
62
+ const chain = new KeyboardChain();
63
+ const listener = vi.fn();
64
+ chain.subscribe(listener);
65
+ field(chain, box("email", 0, 120));
66
+ field(chain, box("name", 0, 40));
67
+ await settle();
68
+ expect(listener).toHaveBeenCalled();
69
+ expect(chain.isLast("name")).toBe(false);
70
+ expect(chain.isLast("email")).toBe(true);
71
+ });
72
+
73
+ it("forgets a field that unmounts", async () => {
74
+ const chain = new KeyboardChain();
75
+ field(chain, box("name", 0, 40));
76
+ const unregister = chain.register({
77
+ id: "email",
78
+ focus: vi.fn(),
79
+ measure: (done) => done(box("email", 0, 120)),
80
+ });
81
+ unregister();
82
+ const last = vi.fn();
83
+ await settle();
84
+ chain.advance("name", last);
85
+ expect(last).toHaveBeenCalledOnce();
86
+ });
87
+ });
88
+
89
+ describe("KeyboardChain before layout (FZ-7718)", () => {
90
+ it("measures first when Return comes before the chain has placed the field", async () => {
91
+ const chain = new KeyboardChain();
92
+ field(chain, box("name", 0, 40));
93
+ const email = field(chain, box("email", 0, 120));
94
+ chain.advance("name", vi.fn());
95
+ await settle();
96
+ expect(email).toHaveBeenCalledOnce();
97
+ });
98
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The return-key chain (FZ-7718): on a phone, Return in a text field moves to
3
+ * the next field on screen and submits from the last one.
4
+ *
5
+ * Fields register with the nearest scope (a Form, a KeyboardAvoidingWrapper,
6
+ * or the app-wide default). Order is where the fields sit on screen, measured
7
+ * rather than mounted, so a field revealed later still slots into its row.
8
+ */
9
+
10
+ import { type ReactNode, createContext, useState } from "react";
11
+
12
+ export interface ChainBox {
13
+ id: string;
14
+ x: number;
15
+ y: number;
16
+ width: number;
17
+ height: number;
18
+ }
19
+
20
+ export interface ChainEntry {
21
+ id: string;
22
+ focus: () => void;
23
+ measure: (done: (box: ChainBox | undefined) => void) => void;
24
+ }
25
+
26
+ /** Top-to-bottom, then left-to-right for fields that share a row. */
27
+ export function readingOrder(boxes: ChainBox[]): string[] {
28
+ return boxes
29
+ .filter((box) => box.width > 0 || box.height > 0)
30
+ .sort((a, b) => {
31
+ const sameRow = Math.abs(a.y - b.y) < Math.min(a.height, b.height) / 2;
32
+ return sameRow ? a.x - b.x : a.y - b.y;
33
+ })
34
+ .map((box) => box.id);
35
+ }
36
+
37
+ export class KeyboardChain {
38
+ private entries = new Map<string, ChainEntry>();
39
+ private order: string[] = [];
40
+ private listeners = new Set<() => void>();
41
+ private pending = false;
42
+
43
+ subscribe = (listener: () => void) => {
44
+ this.listeners.add(listener);
45
+ return () => {
46
+ this.listeners.delete(listener);
47
+ };
48
+ };
49
+
50
+ register(entry: ChainEntry) {
51
+ this.entries.set(entry.id, entry);
52
+ this.invalidate();
53
+ return () => {
54
+ this.entries.delete(entry.id);
55
+ this.invalidate();
56
+ };
57
+ }
58
+
59
+ /** A field that has not been measured yet reads as last, so it never claims "next" falsely. */
60
+ isLast(id: string) {
61
+ const at = this.order.indexOf(id);
62
+ return at === -1 || at === this.order.length - 1;
63
+ }
64
+
65
+ invalidate() {
66
+ if (this.pending) return;
67
+ this.pending = true;
68
+ void Promise.resolve().then(async () => {
69
+ this.pending = false;
70
+ await this.refresh();
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Focus the field after `id`, or run onLast from the last one. Uses the
76
+ * order measured at layout, and measures first only for a field it has not
77
+ * placed yet.
78
+ */
79
+ advance(id: string, onLast: () => void) {
80
+ if (!this.order.includes(id)) {
81
+ void this.refresh().then((order) => this.step(order, id, onLast));
82
+ return;
83
+ }
84
+ this.step(this.order, id, onLast);
85
+ }
86
+
87
+ private step(order: string[], id: string, onLast: () => void) {
88
+ const next = order[order.indexOf(id) + 1];
89
+ const entry = order.includes(id) && next ? this.entries.get(next) : undefined;
90
+ if (entry) entry.focus();
91
+ else onLast();
92
+ }
93
+
94
+ private async refresh() {
95
+ const boxes = await Promise.all(
96
+ [...this.entries.values()].map(
97
+ (entry) => new Promise<ChainBox | undefined>((resolve) => entry.measure(resolve)),
98
+ ),
99
+ );
100
+ const order = readingOrder(boxes.filter((box): box is ChainBox => Boolean(box)));
101
+ const changed =
102
+ order.length !== this.order.length || order.some((id, i) => id !== this.order[i]);
103
+ this.order = order;
104
+ if (changed) for (const listener of this.listeners) listener();
105
+ return order;
106
+ }
107
+ }
108
+
109
+ export const KeyboardChainContext = createContext<KeyboardChain>(new KeyboardChain());
110
+
111
+ /** Starts a fresh return-key chain for the fields inside it. */
112
+ export function KeyboardChainScope({ children }: { children?: ReactNode }) {
113
+ const [chain] = useState(() => new KeyboardChain());
114
+ return <KeyboardChainContext.Provider value={chain}>{children}</KeyboardChainContext.Provider>;
115
+ }
@@ -0,0 +1,5 @@
1
+ export { KeyboardChain, KeyboardChainContext, KeyboardChainScope, readingOrder } from "./chain";
2
+ export type { ChainBox, ChainEntry } from "./chain";
3
+ export { KeyboardDoneBar } from "./KeyboardDoneBar";
4
+ export { useKeyboardChain } from "./useKeyboardChain";
5
+ export type { KeyboardChainBinding, KeyboardChainOptions, KeyboardDoneBarProps } from "./types";
@@ -0,0 +1,21 @@
1
+ import type { ReactNode, RefObject } from "react";
2
+
3
+ export interface KeyboardChainOptions {
4
+ inputRef: RefObject<unknown>;
5
+ /** Off for disabled and read-only fields. */
6
+ enabled?: boolean;
7
+ /** Multiline fields keep Return for newlines and only get the Done bar. */
8
+ multiline?: boolean;
9
+ /** Runs when Return is pressed in the last field (a form's submit). */
10
+ onSubmitLast?: () => void;
11
+ onSubmitEditing?: (...args: any[]) => void;
12
+ }
13
+
14
+ export interface KeyboardChainBinding {
15
+ inputProps: Record<string, unknown>;
16
+ accessory: ReactNode;
17
+ }
18
+
19
+ export interface KeyboardDoneBarProps {
20
+ nativeID: string;
21
+ }