import * as React from 'react'; import { flushSync } from 'react-dom'; import { composeEventHandlers } from '@radix-ui/primitive'; import { useControllableState } from '@radix-ui/react-use-controllable-state'; import { Primitive } from '@radix-ui/react-primitive'; import { useComposedRefs } from '@radix-ui/react-compose-refs'; import { useId } from '@radix-ui/react-id'; import { useIsHydrated } from '@radix-ui/react-use-is-hydrated'; import { useEffectEvent } from '@radix-ui/react-use-effect-event'; import type { Scope } from '@radix-ui/react-context'; import { createContextScope } from '@radix-ui/react-context'; const PASSWORD_TOGGLE_FIELD_NAME = 'PasswordToggleField'; /* ------------------------------------------------------------------------------------------------- * PasswordToggleFieldProvider * -----------------------------------------------------------------------------------------------*/ type InternalFocusState = { clickTriggered: boolean; selectionStart: number | null; selectionEnd: number | null; }; interface PasswordToggleFieldContextValue { inputId: string; inputRef: React.RefObject; visible: boolean; setVisible: React.Dispatch>; syncInputId: (providedId: string | number | undefined) => void; focusState: React.RefObject; } const [createPasswordToggleFieldContext] = createContextScope(PASSWORD_TOGGLE_FIELD_NAME); const [PasswordToggleFieldProvider, usePasswordToggleFieldContext] = createPasswordToggleFieldContext(PASSWORD_TOGGLE_FIELD_NAME); /* ------------------------------------------------------------------------------------------------- * PasswordToggleField * -----------------------------------------------------------------------------------------------*/ type ScopedProps

= P & { __scopePasswordToggleField?: Scope }; interface PasswordToggleFieldProps { id?: string; visible?: boolean; defaultVisible?: boolean; onVisiblityChange?: (visible: boolean) => void; children?: React.ReactNode; } const INITIAL_FOCUS_STATE: InternalFocusState = { clickTriggered: false, selectionStart: null, selectionEnd: null, }; const PasswordToggleField: React.FC = ({ __scopePasswordToggleField, ...props }: ScopedProps) => { const baseId = useId(props.id); const defaultInputId = `${baseId}-input`; const [inputIdState, setInputIdState] = React.useState(defaultInputId); const inputId = inputIdState ?? defaultInputId; const syncInputId = React.useCallback( (providedId: string | number | undefined) => setInputIdState(providedId != null ? String(providedId) : null), [], ); const { visible: visibleProp, defaultVisible, onVisiblityChange, children } = props; const [visible = false, setVisible] = useControllableState({ caller: PASSWORD_TOGGLE_FIELD_NAME, prop: visibleProp, defaultProp: defaultVisible ?? false, onChange: onVisiblityChange, }); const inputRef = React.useRef(null); const focusState = React.useRef(INITIAL_FOCUS_STATE); return ( {children} ); }; PasswordToggleField.displayName = PASSWORD_TOGGLE_FIELD_NAME; /* ------------------------------------------------------------------------------------------------- * PasswordToggleFieldInput * -----------------------------------------------------------------------------------------------*/ const PASSWORD_TOGGLE_FIELD_INPUT_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Input'; type PrimitiveInputProps = React.ComponentPropsWithoutRef<'input'>; interface PasswordToggleFieldOwnProps { autoComplete?: 'current-password' | 'new-password'; } interface PasswordToggleFieldInputProps extends PasswordToggleFieldOwnProps, Omit { autoComplete?: 'current-password' | 'new-password'; } const PasswordToggleFieldInput = React.forwardRef( ( { __scopePasswordToggleField, autoComplete = 'current-password', autoCapitalize = 'off', spellCheck = false, id: idProp, ...props }: ScopedProps, forwardedRef, ) => { const { visible, inputRef, inputId, syncInputId, setVisible, focusState } = usePasswordToggleFieldContext(PASSWORD_TOGGLE_FIELD_INPUT_NAME, __scopePasswordToggleField); React.useEffect(() => { syncInputId(idProp); }, [idProp, syncInputId]); // We want to reset the visibility to `false` to revert the input to // `type="password"` when: // - The form is reset (for consistency with other form controls) // - The form is submitted (to prevent the browser from remembering the // input's value. // // See "Keeping things secure": // https://technology.blog.gov.uk/2021/04/19/simple-things-are-complicated-making-a-show-password-option/) const _setVisible = useEffectEvent(setVisible); React.useEffect(() => { const inputElement = inputRef.current; const form = inputElement?.form; if (!form) { return; } const controller = new AbortController(); form.addEventListener( 'reset', (event) => { if (!event.defaultPrevented) { _setVisible(false); } }, { signal: controller.signal }, ); form.addEventListener( 'submit', () => { // always reset the visibility on submit regardless of whether the // default action is prevented _setVisible(false); }, { signal: controller.signal }, ); return () => { controller.abort(); }; }, [inputRef]); return ( { // get the cursor position const { selectionStart, selectionEnd } = event.currentTarget; focusState.current.selectionStart = selectionStart; focusState.current.selectionEnd = selectionEnd; })} /> ); }, ); PasswordToggleFieldInput.displayName = PASSWORD_TOGGLE_FIELD_INPUT_NAME; /* ------------------------------------------------------------------------------------------------- * PasswordToggleFieldToggle * -----------------------------------------------------------------------------------------------*/ const PASSWORD_TOGGLE_FIELD_TOGGLE_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Toggle'; type PrimitiveButtonProps = React.ComponentPropsWithoutRef<'button'>; interface PasswordToggleFieldToggleProps extends Omit {} const PasswordToggleFieldToggle = React.forwardRef< HTMLButtonElement, PasswordToggleFieldToggleProps >( ( { __scopePasswordToggleField, onClick, onPointerDown, onPointerCancel, onPointerUp, onFocus, children, 'aria-label': ariaLabelProp, 'aria-controls': ariaControls, 'aria-hidden': ariaHidden, tabIndex, ...props }: ScopedProps, forwardedRef, ) => { const { setVisible, visible, inputRef, inputId, focusState } = usePasswordToggleFieldContext( PASSWORD_TOGGLE_FIELD_TOGGLE_NAME, __scopePasswordToggleField, ); const [internalAriaLabel, setInternalAriaLabel] = React.useState(undefined); const elementRef = React.useRef(null); const ref = useComposedRefs(forwardedRef, elementRef); const isHydrated = useIsHydrated(); React.useEffect(() => { const element = elementRef.current; if (!element || ariaLabelProp) { setInternalAriaLabel(undefined); return; } const DEFAULT_ARIA_LABEL = visible ? 'Hide password' : 'Show password'; function checkForInnerTextLabel(textContent: string | undefined | null) { const text = textContent ? textContent : undefined; // If the element has inner text, no need to force an aria-label. setInternalAriaLabel(text ? undefined : DEFAULT_ARIA_LABEL); } checkForInnerTextLabel(element.textContent); const observer = new MutationObserver((entries) => { let textContent: string | undefined; for (const entry of entries) { if (entry.type === 'characterData') { if (element.textContent) { textContent = element.textContent; } } } checkForInnerTextLabel(textContent); }); observer.observe(element, { characterData: true, subtree: true }); return () => { observer.disconnect(); }; }, [visible, ariaLabelProp]); const ariaLabel = ariaLabelProp || internalAriaLabel; // Before hydration the button will not work, but we want to render it // regardless to prevent potential layout shift. Hide it from assistive tech // by default. Post-hydration it will be visible, focusable and associated // with the input via aria-controls. if (!isHydrated) { ariaHidden ??= true; tabIndex ??= -1; } else { ariaControls ??= inputId; } React.useEffect(() => { let cleanup = () => {}; const ownerWindow = elementRef.current?.ownerDocument?.defaultView || window; const reset = () => (focusState.current.clickTriggered = false); const handlePointerUp = () => (cleanup = requestIdleCallback(ownerWindow, reset)); ownerWindow.addEventListener('pointerup', handlePointerUp); return () => { cleanup(); ownerWindow.removeEventListener('pointerup', handlePointerUp); }; }, [focusState]); return ( { focusState.current.clickTriggered = true; })} onPointerCancel={(event) => { // do not use `composeEventHandlers` here because we always want to // reset the ref on cancellation, regardless of whether the user has // called preventDefault on the event onPointerCancel?.(event); focusState.current = INITIAL_FOCUS_STATE; }} // do not use `composeEventHandlers` here because we always want to // reset the ref after click, regardless of whether the user has // called preventDefault on the event onClick={(event) => { onClick?.(event); if (event.defaultPrevented) { focusState.current = INITIAL_FOCUS_STATE; return; } flushSync(() => { setVisible((s) => !s); }); if (focusState.current.clickTriggered) { const input = inputRef.current; if (input) { const { selectionStart, selectionEnd } = focusState.current; input.focus(); if (selectionStart !== null || selectionEnd !== null) { // wait a tick so that focus has settled, then restore select position requestAnimationFrame(() => { // make sure the input still has focus (developer may have // programatically moved focus elsewhere) if (input.ownerDocument.activeElement === input) { input.selectionStart = selectionStart; input.selectionEnd = selectionEnd; } }); } } } focusState.current = INITIAL_FOCUS_STATE; }} onPointerUp={(event) => { onPointerUp?.(event); // if click handler hasn't been called at this point, it may have been // intercepted, in which case we still want to reset our internal // state setTimeout(() => { focusState.current = INITIAL_FOCUS_STATE; }, 50); }} type="button" > {children} ); }, ); PasswordToggleFieldToggle.displayName = PASSWORD_TOGGLE_FIELD_TOGGLE_NAME; /* ------------------------------------------------------------------------------------------------- * PasswordToggleFieldSlot * -----------------------------------------------------------------------------------------------*/ const PASSWORD_TOGGLE_FIELD_SLOT_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Slot'; interface PasswordToggleFieldSlotDeclarativeProps { visible: React.ReactNode; hidden: React.ReactNode; } interface PasswordToggleFieldSlotRenderProps { render: (args: { visible: boolean }) => React.ReactElement; } type PasswordToggleFieldSlotProps = | PasswordToggleFieldSlotDeclarativeProps | PasswordToggleFieldSlotRenderProps; const PasswordToggleFieldSlot: React.FC = ({ __scopePasswordToggleField, ...props }: ScopedProps) => { const { visible } = usePasswordToggleFieldContext( PASSWORD_TOGGLE_FIELD_SLOT_NAME, __scopePasswordToggleField, ); return 'render' in props ? // props.render({ visible }) : visible ? props.visible : props.hidden; }; PasswordToggleFieldSlot.displayName = PASSWORD_TOGGLE_FIELD_SLOT_NAME; /* ------------------------------------------------------------------------------------------------- * PasswordToggleFieldIcon * -----------------------------------------------------------------------------------------------*/ const PASSWORD_TOGGLE_FIELD_ICON_NAME = PASSWORD_TOGGLE_FIELD_NAME + 'Icon'; type PrimitiveSvgProps = React.ComponentPropsWithoutRef<'svg'>; interface PasswordToggleFieldIconProps extends Omit { visible: React.ReactElement; hidden: React.ReactElement; } const PasswordToggleFieldIcon = React.forwardRef( ( { __scopePasswordToggleField, // @ts-expect-error children, ...props }: ScopedProps, forwardedRef, ) => { const { visible } = usePasswordToggleFieldContext( PASSWORD_TOGGLE_FIELD_ICON_NAME, __scopePasswordToggleField, ); const { visible: visibleIcon, hidden: hiddenIcon, ...domProps } = props; return ( {visible ? visibleIcon : hiddenIcon} ); }, ); PasswordToggleFieldIcon.displayName = PASSWORD_TOGGLE_FIELD_ICON_NAME; export { PasswordToggleField, PasswordToggleFieldInput, PasswordToggleFieldToggle, PasswordToggleFieldSlot, PasswordToggleFieldIcon, // PasswordToggleField as Root, PasswordToggleFieldInput as Input, PasswordToggleFieldToggle as Toggle, PasswordToggleFieldSlot as Slot, PasswordToggleFieldIcon as Icon, }; export type { PasswordToggleFieldProps, PasswordToggleFieldInputProps, PasswordToggleFieldToggleProps, PasswordToggleFieldIconProps, PasswordToggleFieldSlotProps, }; function requestIdleCallback( window: Window, callback: IdleRequestCallback, options?: IdleRequestOptions, ): () => void { if ((window as any).requestIdleCallback) { const id = window.requestIdleCallback(callback, options); return () => { window.cancelIdleCallback(id); }; } const start = Date.now(); const id = window.setTimeout(() => { const timeRemaining = () => Math.max(0, 50 - (Date.now() - start)); callback({ didTimeout: false, timeRemaining }); }, 1); return () => { window.clearTimeout(id); }; }