Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IOPID-2107] - A11Y TextInputBase accessibility improvement #327

Merged
merged 7 commits into from
Aug 30, 2024
3 changes: 3 additions & 0 deletions src/components/textInput/TextInputBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ type InputTextProps = WithTestID<{
value: string;
onChangeText: (value: string) => void;
accessibilityLabel?: string;
accessibilityHint?: string;
textInputProps?: RNTextInputProps;
inputType?: InputType;
status?: InputStatus;
Expand Down Expand Up @@ -197,6 +198,7 @@ export const TextInputBase = ({
value = "",
onChangeText,
accessibilityLabel,
accessibilityHint,
textInputProps,
inputType = "default",
status,
Expand Down Expand Up @@ -392,6 +394,7 @@ export const TextInputBase = ({
disableFullscreenUI={true}
accessibilityState={{ disabled }}
accessibilityLabel={accessibilityLabel ?? placeholder}
accessibilityHint={accessibilityHint}
selectionColor={IOColors[theme["interactiveElem-default"]]} // Caret on iOS
cursorColor={IOColors[theme["interactiveElem-default"]]} // Caret Android
maxLength={counterLimit}
Expand Down
48 changes: 37 additions & 11 deletions src/components/textInput/TextInputValidation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,28 @@ import { triggerHaptic } from "../../functions";
import { IOIconSizeScale, IOIcons, Icon } from "../icons";
import { TextInputBase } from "./TextInputBase";

export type ValidationWithOptions = { isValid: boolean; errorMessage: string };

type TextInputValidationProps = Omit<
React.ComponentProps<typeof TextInputBase>,
"rightElement" | "status" | "bottomMessageColor" | "isPassword"
> & {
onValidate: (value: string) => boolean;
/**
* This function can return either a `boolean` or a `ValidationWithOptions` object.
* If a `boolean` is returned and the field is not valid, the value of the errorMessage prop will be displayed/announced.
* If a `ValidationWithOptions` object is returned and the field is not valid, the value displayed/announced will be the one contained within this object.
*/
onValidate: (value: string) => boolean | ValidationWithOptions;
/**
* In case of a dynamic `errorMessage`, use the `onValidate` function with a `ValidationWithOptions` object as the return value to ensure that screen readers announce the correct value.
*/
errorMessage: string;
};

function isValidationWithOptions(validation: boolean | ValidationWithOptions): validation is ValidationWithOptions {
return typeof validation === 'object' && 'isValid' in validation && 'errorMessage' in validation;
}

const feedbackIconSize: IOIconSizeScale = 24;

export const TextInputValidation = ({
Expand All @@ -31,34 +45,46 @@ export const TextInputValidation = ({
...props
}: TextInputValidationProps) => {
const [isValid, setIsValid] = useState<boolean | undefined>(undefined);
const [errMessage, setErrMessage] = useState(errorMessage);

const onBlurHandler = useCallback(() => {
const validation = onValidate(value);
setIsValid(validation);
if (!validation) {
const getErrorFeedback = useCallback((isValid: boolean, message: string) => {
setIsValid(isValid);
setErrMessage(message);

if (!isValid) {
triggerHaptic("notificationError");
AccessibilityInfo.announceForAccessibilityWithOptions(errorMessage, {
AccessibilityInfo.announceForAccessibilityWithOptions(message, {
queue: true
});
} else {
triggerHaptic("notificationSuccess");
}
}, []);

const onBlurHandler = useCallback(() => {
const validation = onValidate(value);

if (isValidationWithOptions(validation)) {
getErrorFeedback(validation.isValid, validation.errorMessage);
} else {
getErrorFeedback(validation, errorMessage);
}
onBlur?.();
}, [onValidate, value, onBlur, errorMessage]);
}, [value, errorMessage, onBlur, onValidate, getErrorFeedback]);

const onFocusHandler = useCallback(() => {
setIsValid(undefined);
onFocus?.();
}, [onFocus]);

const labelError = useMemo(
() => (isValid === false && errorMessage ? errorMessage : bottomMessage),
[isValid, errorMessage, bottomMessage]
() => (isValid === false && errMessage ? errMessage : bottomMessage),
[isValid, errMessage, bottomMessage]
);

const labelErrorColor: IOColors | undefined = useMemo(
() => (isValid === false && errorMessage ? "error-600" : undefined),
[isValid, errorMessage]
() => (isValid === false && errMessage ? "error-600" : undefined),
[isValid, errMessage]
);

const feedbackIconAttrMap: Record<
Expand Down
Loading