@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.
- package/dist/cjs/Chip/index.cjs +5 -2
- package/dist/cjs/Chip/index.native.js +2 -1
- package/dist/cjs/Chip/index.native.js.map +1 -1
- package/dist/cjs/Form/index.cjs +25 -22
- package/dist/cjs/Form/index.native.js +27 -24
- package/dist/cjs/Form/index.native.js.map +1 -1
- package/dist/cjs/InputParts/index.cjs +18 -7
- package/dist/cjs/InputParts/index.native.js +18 -7
- package/dist/cjs/InputParts/index.native.js.map +1 -1
- package/dist/cjs/fields/Input/index.cjs +40 -23
- package/dist/cjs/fields/Input/index.native.js +42 -25
- package/dist/cjs/fields/Input/index.native.js.map +1 -1
- package/dist/cjs/fields/SearchInput/index.cjs +5 -2
- package/dist/cjs/fields/SearchInput/index.native.js +2 -1
- package/dist/cjs/fields/SearchInput/index.native.js.map +1 -1
- package/dist/cjs/index.cjs +7 -0
- package/dist/cjs/index.native.js +7 -0
- package/dist/cjs/index.native.js.map +1 -1
- package/dist/cjs/keyboard/KeyboardDoneBar.cjs +30 -0
- package/dist/cjs/keyboard/KeyboardDoneBar.native.js +65 -0
- package/dist/cjs/keyboard/KeyboardDoneBar.native.js.map +1 -0
- package/dist/cjs/keyboard/chain.cjs +106 -0
- package/dist/cjs/keyboard/chain.native.js +203 -0
- package/dist/cjs/keyboard/chain.native.js.map +1 -0
- package/dist/cjs/keyboard/index.cjs +35 -0
- package/dist/cjs/keyboard/index.native.js +38 -0
- package/dist/cjs/keyboard/index.native.js.map +1 -0
- package/dist/cjs/keyboard/types.cjs +18 -0
- package/dist/cjs/keyboard/types.native.js +21 -0
- package/dist/cjs/keyboard/types.native.js.map +1 -0
- package/dist/cjs/keyboard/useKeyboardChain.cjs +33 -0
- package/dist/cjs/keyboard/useKeyboardChain.native.js +114 -0
- package/dist/cjs/keyboard/useKeyboardChain.native.js.map +1 -0
- package/dist/cjs/shared/nativeAutoFocus.cjs +37 -0
- package/dist/cjs/shared/nativeAutoFocus.native.js +41 -0
- package/dist/cjs/shared/nativeAutoFocus.native.js.map +1 -0
- package/dist/cjs/themedPrimitives.cjs +2 -0
- package/dist/cjs/themedPrimitives.native.js +2 -0
- package/dist/cjs/themedPrimitives.native.js.map +1 -1
- package/dist/esm/Chip/index.mjs +5 -2
- package/dist/esm/Chip/index.mjs.map +1 -1
- package/dist/esm/Chip/index.native.js +2 -1
- package/dist/esm/Chip/index.native.js.map +1 -1
- package/dist/esm/Form/index.mjs +25 -22
- package/dist/esm/Form/index.mjs.map +1 -1
- package/dist/esm/Form/index.native.js +27 -24
- package/dist/esm/Form/index.native.js.map +1 -1
- package/dist/esm/InputParts/index.mjs +18 -7
- package/dist/esm/InputParts/index.mjs.map +1 -1
- package/dist/esm/InputParts/index.native.js +18 -7
- package/dist/esm/InputParts/index.native.js.map +1 -1
- package/dist/esm/fields/Input/index.mjs +41 -24
- package/dist/esm/fields/Input/index.mjs.map +1 -1
- package/dist/esm/fields/Input/index.native.js +43 -26
- package/dist/esm/fields/Input/index.native.js.map +1 -1
- package/dist/esm/fields/SearchInput/index.mjs +5 -2
- package/dist/esm/fields/SearchInput/index.mjs.map +1 -1
- package/dist/esm/fields/SearchInput/index.native.js +2 -1
- package/dist/esm/fields/SearchInput/index.native.js.map +1 -1
- package/dist/esm/index.mjs +2 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/index.native.js +2 -1
- package/dist/esm/index.native.js.map +1 -1
- package/dist/esm/keyboard/KeyboardDoneBar.mjs +5 -0
- package/dist/esm/keyboard/KeyboardDoneBar.mjs.map +1 -0
- package/dist/esm/keyboard/KeyboardDoneBar.native.js +37 -0
- package/dist/esm/keyboard/KeyboardDoneBar.native.js.map +1 -0
- package/dist/esm/keyboard/chain.mjs +78 -0
- package/dist/esm/keyboard/chain.mjs.map +1 -0
- package/dist/esm/keyboard/chain.native.js +172 -0
- package/dist/esm/keyboard/chain.native.js.map +1 -0
- package/dist/esm/keyboard/index.mjs +5 -0
- package/dist/esm/keyboard/index.mjs.map +1 -0
- package/dist/esm/keyboard/index.native.js +5 -0
- package/dist/esm/keyboard/index.native.js.map +1 -0
- package/dist/esm/keyboard/types.mjs +2 -0
- package/dist/esm/keyboard/types.mjs.map +1 -0
- package/dist/esm/keyboard/types.native.js +2 -0
- package/dist/esm/keyboard/types.native.js.map +1 -0
- package/dist/esm/keyboard/useKeyboardChain.mjs +8 -0
- package/dist/esm/keyboard/useKeyboardChain.mjs.map +1 -0
- package/dist/esm/keyboard/useKeyboardChain.native.js +86 -0
- package/dist/esm/keyboard/useKeyboardChain.native.js.map +1 -0
- package/dist/esm/shared/nativeAutoFocus.mjs +12 -0
- package/dist/esm/shared/nativeAutoFocus.mjs.map +1 -0
- package/dist/esm/shared/nativeAutoFocus.native.js +13 -0
- package/dist/esm/shared/nativeAutoFocus.native.js.map +1 -0
- package/dist/esm/themedPrimitives.mjs +2 -0
- package/dist/esm/themedPrimitives.mjs.map +1 -1
- package/dist/esm/themedPrimitives.native.js +2 -0
- package/dist/esm/themedPrimitives.native.js.map +1 -1
- package/dist/jsx/Chip/index.mjs +5 -2
- package/dist/jsx/Chip/index.mjs.map +1 -1
- package/dist/jsx/Chip/index.native.js +2 -1
- package/dist/jsx/Chip/index.native.js.map +1 -1
- package/dist/jsx/Form/index.mjs +25 -22
- package/dist/jsx/Form/index.mjs.map +1 -1
- package/dist/jsx/Form/index.native.js +27 -24
- package/dist/jsx/Form/index.native.js.map +1 -1
- package/dist/jsx/InputParts/index.mjs +18 -7
- package/dist/jsx/InputParts/index.mjs.map +1 -1
- package/dist/jsx/InputParts/index.native.js +18 -7
- package/dist/jsx/InputParts/index.native.js.map +1 -1
- package/dist/jsx/fields/Input/index.mjs +41 -24
- package/dist/jsx/fields/Input/index.mjs.map +1 -1
- package/dist/jsx/fields/Input/index.native.js +42 -25
- package/dist/jsx/fields/Input/index.native.js.map +1 -1
- package/dist/jsx/fields/SearchInput/index.mjs +5 -2
- package/dist/jsx/fields/SearchInput/index.mjs.map +1 -1
- package/dist/jsx/fields/SearchInput/index.native.js +2 -1
- package/dist/jsx/fields/SearchInput/index.native.js.map +1 -1
- package/dist/jsx/index.js +2 -1
- package/dist/jsx/index.js.map +1 -1
- package/dist/jsx/index.mjs +2 -1
- package/dist/jsx/index.mjs.map +1 -1
- package/dist/jsx/index.native.js +7 -0
- package/dist/jsx/index.native.js.map +1 -1
- package/dist/jsx/keyboard/KeyboardDoneBar.mjs +5 -0
- package/dist/jsx/keyboard/KeyboardDoneBar.mjs.map +1 -0
- package/dist/jsx/keyboard/KeyboardDoneBar.native.js +65 -0
- package/dist/jsx/keyboard/KeyboardDoneBar.native.js.map +1 -0
- package/dist/jsx/keyboard/chain.mjs +78 -0
- package/dist/jsx/keyboard/chain.mjs.map +1 -0
- package/dist/jsx/keyboard/chain.native.js +203 -0
- package/dist/jsx/keyboard/chain.native.js.map +1 -0
- package/dist/jsx/keyboard/index.mjs +5 -0
- package/dist/jsx/keyboard/index.mjs.map +1 -0
- package/dist/jsx/keyboard/index.native.js +38 -0
- package/dist/jsx/keyboard/index.native.js.map +1 -0
- package/dist/jsx/keyboard/types.mjs +2 -0
- package/dist/jsx/keyboard/types.mjs.map +1 -0
- package/dist/jsx/keyboard/types.native.js +21 -0
- package/dist/jsx/keyboard/types.native.js.map +1 -0
- package/dist/jsx/keyboard/useKeyboardChain.mjs +8 -0
- package/dist/jsx/keyboard/useKeyboardChain.mjs.map +1 -0
- package/dist/jsx/keyboard/useKeyboardChain.native.js +114 -0
- package/dist/jsx/keyboard/useKeyboardChain.native.js.map +1 -0
- package/dist/jsx/shared/nativeAutoFocus.mjs +12 -0
- package/dist/jsx/shared/nativeAutoFocus.mjs.map +1 -0
- package/dist/jsx/shared/nativeAutoFocus.native.js +41 -0
- package/dist/jsx/shared/nativeAutoFocus.native.js.map +1 -0
- package/dist/jsx/themedPrimitives.mjs +2 -0
- package/dist/jsx/themedPrimitives.mjs.map +1 -1
- package/dist/jsx/themedPrimitives.native.js +2 -0
- package/dist/jsx/themedPrimitives.native.js.map +1 -1
- package/package.json +8 -8
- package/src/Button/Button.disabledReasonInk.spec.tsx +7 -1
- package/src/Chip/index.tsx +2 -1
- package/src/Form/index.tsx +31 -26
- package/src/InputParts/index.tsx +22 -12
- package/src/fields/Input/index.tsx +53 -31
- package/src/fields/SearchInput/SearchInput.focusHold.native.spec.tsx +81 -0
- package/src/fields/SearchInput/index.tsx +2 -1
- package/src/index.ts +17 -0
- package/src/keyboard/KeyboardDoneBar.native.tsx +38 -0
- package/src/keyboard/KeyboardDoneBar.tsx +6 -0
- package/src/keyboard/chain.spec.ts +98 -0
- package/src/keyboard/chain.tsx +115 -0
- package/src/keyboard/index.ts +5 -0
- package/src/keyboard/types.ts +21 -0
- package/src/keyboard/useKeyboardChain.native.tsx +79 -0
- package/src/keyboard/useKeyboardChain.ts +6 -0
- package/src/shared/nativeAutoFocus.ts +20 -0
- package/src/themedPrimitives.tsx +9 -1
- package/types/Chip/index.d.ts.map +1 -1
- package/types/Form/index.d.ts.map +1 -1
- package/types/InputParts/index.d.ts.map +1 -1
- package/types/fields/Input/index.d.ts.map +1 -1
- package/types/fields/SearchInput/index.d.ts.map +1 -1
- package/types/index.d.ts +2 -0
- package/types/index.d.ts.map +1 -1
- package/types/keyboard/KeyboardDoneBar.d.ts +4 -0
- package/types/keyboard/KeyboardDoneBar.d.ts.map +1 -0
- package/types/keyboard/KeyboardDoneBar.native.d.ts +8 -0
- package/types/keyboard/KeyboardDoneBar.native.d.ts.map +1 -0
- package/types/keyboard/chain.d.ts +48 -0
- package/types/keyboard/chain.d.ts.map +1 -0
- package/types/keyboard/index.d.ts +6 -0
- package/types/keyboard/index.d.ts.map +1 -0
- package/types/keyboard/types.d.ts +19 -0
- package/types/keyboard/types.d.ts.map +1 -0
- package/types/keyboard/useKeyboardChain.d.ts +4 -0
- package/types/keyboard/useKeyboardChain.d.ts.map +1 -0
- package/types/keyboard/useKeyboardChain.native.d.ts +3 -0
- package/types/keyboard/useKeyboardChain.native.d.ts.map +1 -0
- package/types/shared/nativeAutoFocus.d.ts +13 -0
- package/types/shared/nativeAutoFocus.d.ts.map +1 -0
- package/types/themedPrimitives.d.ts.map +1 -1
package/src/Form/index.tsx
CHANGED
|
@@ -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
|
-
<
|
|
324
|
-
<
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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>
|
package/src/InputParts/index.tsx
CHANGED
|
@@ -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
|
-
...
|
|
644
|
-
...inputOutlineFocus,
|
|
645
|
-
...control.focusKnobProps,
|
|
646
|
-
...control.focusVisibleKnobProps,
|
|
647
|
-
outlineOffset: boxRingOffset(ringMode),
|
|
648
|
-
}),
|
|
651
|
+
...focusRing,
|
|
649
652
|
}
|
|
650
|
-
:
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
411
|
-
<
|
|
412
|
-
{
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
{
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|