feat(component): add autoFocusConfirmButton for confirm-modal (#7801)
close #5813 <div class='graphite__hidden'> <div>🎥 Video uploaded on Graphite:</div> <a href="https://app.graphite.dev/media/video/LakojjjzZNf6ogjOVwKE/aff35b76-9f73-4d15-b2cb-c25b03e2e2c3.mp4"> <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/LakojjjzZNf6ogjOVwKE/aff35b76-9f73-4d15-b2cb-c25b03e2e2c3.mp4"> </a> </div> <video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/aff35b76-9f73-4d15-b2cb-c25b03e2e2c3.mp4">CleanShot 2024-08-09 at 11.25.46.mp4</video>
This commit is contained in:
33
packages/frontend/component/src/hooks/focus-and-select.ts
Normal file
33
packages/frontend/component/src/hooks/focus-and-select.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useLayoutEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export const useAutoFocus = <T extends HTMLElement = HTMLElement>(
|
||||||
|
autoFocus?: boolean
|
||||||
|
) => {
|
||||||
|
const ref = useRef<T | null>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (ref.current && autoFocus) {
|
||||||
|
// to avoid clicking on something focusable(e.g MenuItem),
|
||||||
|
// then the input will not be focused
|
||||||
|
setTimeout(() => {
|
||||||
|
ref.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}, [autoFocus]);
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAutoSelect = <T extends HTMLInputElement = HTMLInputElement>(
|
||||||
|
autoSelect?: boolean
|
||||||
|
) => {
|
||||||
|
const ref = useAutoFocus<T>(autoSelect);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (ref.current && autoSelect) {
|
||||||
|
ref.current?.select();
|
||||||
|
}
|
||||||
|
}, [autoSelect, ref]);
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
};
|
||||||
1
packages/frontend/component/src/hooks/index.ts
Normal file
1
packages/frontend/component/src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useAutoFocus, useAutoSelect } from './focus-and-select';
|
||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { cloneElement, forwardRef, useCallback } from 'react';
|
import { cloneElement, forwardRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useAutoFocus } from '../../hooks';
|
||||||
import { Loading } from '../loading';
|
import { Loading } from '../loading';
|
||||||
import { Tooltip, type TooltipProps } from '../tooltip';
|
import { Tooltip, type TooltipProps } from '../tooltip';
|
||||||
import * as styles from './button.css';
|
import * as styles from './button.css';
|
||||||
@@ -120,12 +121,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
tooltip,
|
tooltip,
|
||||||
tooltipShortcut,
|
tooltipShortcut,
|
||||||
tooltipOptions,
|
tooltipOptions,
|
||||||
|
autoFocus,
|
||||||
onClick,
|
onClick,
|
||||||
|
|
||||||
...otherProps
|
...otherProps
|
||||||
},
|
},
|
||||||
ref
|
upstreamRef
|
||||||
) => {
|
) => {
|
||||||
|
const ref = useAutoFocus<HTMLButtonElement>(autoFocus);
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: MouseEvent<HTMLButtonElement>) => {
|
(e: MouseEvent<HTMLButtonElement>) => {
|
||||||
if (loading || disabled) return;
|
if (loading || disabled) return;
|
||||||
@@ -134,11 +138,22 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
[disabled, loading, onClick]
|
[disabled, loading, onClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const buttonRef = (el: HTMLButtonElement | null) => {
|
||||||
|
ref.current = el;
|
||||||
|
if (upstreamRef) {
|
||||||
|
if (typeof upstreamRef === 'function') {
|
||||||
|
upstreamRef(el);
|
||||||
|
} else {
|
||||||
|
upstreamRef.current = el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content={tooltip} shortcut={tooltipShortcut} {...tooltipOptions}>
|
<Tooltip content={tooltip} shortcut={tooltipShortcut} {...tooltipOptions}>
|
||||||
<button
|
<button
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
ref={ref}
|
ref={buttonRef}
|
||||||
className={clsx(styles.button, className)}
|
className={clsx(styles.button, className)}
|
||||||
data-loading={loading || undefined}
|
data-loading={loading || undefined}
|
||||||
data-block={block || undefined}
|
data-block={block || undefined}
|
||||||
|
|||||||
@@ -8,14 +8,9 @@ import type {
|
|||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {
|
import { forwardRef, useCallback, useEffect } from 'react';
|
||||||
forwardRef,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
|
import { useAutoFocus, useAutoSelect } from '../../hooks';
|
||||||
import { input, inputWrapper } from './style.css';
|
import { input, inputWrapper } from './style.css';
|
||||||
|
|
||||||
export type InputProps = {
|
export type InputProps = {
|
||||||
@@ -55,30 +50,31 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|||||||
}: InputProps,
|
}: InputProps,
|
||||||
upstreamRef: ForwardedRef<HTMLInputElement>
|
upstreamRef: ForwardedRef<HTMLInputElement>
|
||||||
) {
|
) {
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const focusRef = useAutoFocus<HTMLInputElement>(autoFocus);
|
||||||
useLayoutEffect(() => {
|
const selectRef = useAutoSelect<HTMLInputElement>(autoSelect);
|
||||||
if (inputRef.current && (autoFocus || autoSelect)) {
|
|
||||||
// to avoid clicking on something focusable(e.g MenuItem),
|
const inputRef = (el: HTMLInputElement | null) => {
|
||||||
// then the input will not be focused
|
focusRef.current = el;
|
||||||
setTimeout(() => {
|
selectRef.current = el;
|
||||||
inputRef.current?.focus();
|
if (upstreamRef) {
|
||||||
}, 0);
|
if (typeof upstreamRef === 'function') {
|
||||||
if (autoSelect) {
|
upstreamRef(el);
|
||||||
inputRef.current?.select();
|
} else {
|
||||||
|
upstreamRef.current = el;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [autoFocus, autoSelect, upstreamRef]);
|
};
|
||||||
|
|
||||||
// use native blur event to get event after unmount
|
// use native blur event to get event after unmount
|
||||||
// don't use useLayoutEffect here, because the cleanup function will be called before unmount
|
// don't use useLayoutEffect here, because the cleanup function will be called before unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onBlur) return;
|
if (!onBlur) return;
|
||||||
inputRef.current?.addEventListener('blur', onBlur as any);
|
selectRef.current?.addEventListener('blur', onBlur as any);
|
||||||
return () => {
|
return () => {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
inputRef.current?.removeEventListener('blur', onBlur as any);
|
selectRef.current?.removeEventListener('blur', onBlur as any);
|
||||||
};
|
};
|
||||||
}, [onBlur]);
|
}, [onBlur, selectRef]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -105,16 +101,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|||||||
large: size === 'large',
|
large: size === 'large',
|
||||||
'extra-large': size === 'extraLarge',
|
'extra-large': size === 'extraLarge',
|
||||||
})}
|
})}
|
||||||
ref={ref => {
|
ref={inputRef}
|
||||||
inputRef.current = ref;
|
|
||||||
if (upstreamRef) {
|
|
||||||
if (typeof upstreamRef === 'function') {
|
|
||||||
upstreamRef(ref);
|
|
||||||
} else {
|
|
||||||
upstreamRef.current = ref;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
onChange={useCallback(
|
onChange={useCallback(
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ export interface ConfirmModalProps extends ModalProps {
|
|||||||
cancelText?: React.ReactNode;
|
cancelText?: React.ReactNode;
|
||||||
cancelButtonOptions?: Omit<ButtonProps, 'children'>;
|
cancelButtonOptions?: Omit<ButtonProps, 'children'>;
|
||||||
reverseFooter?: boolean;
|
reverseFooter?: boolean;
|
||||||
|
/**
|
||||||
|
* Auto focus on confirm button when modal opened
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
autoFocusConfirm?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfirmModal = ({
|
export const ConfirmModal = ({
|
||||||
@@ -30,6 +35,7 @@ export const ConfirmModal = ({
|
|||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
width = 480,
|
width = 480,
|
||||||
|
autoFocusConfirm = true,
|
||||||
...props
|
...props
|
||||||
}: ConfirmModalProps) => {
|
}: ConfirmModalProps) => {
|
||||||
const onConfirmClick = useCallback(() => {
|
const onConfirmClick = useCallback(() => {
|
||||||
@@ -73,6 +79,7 @@ export const ConfirmModal = ({
|
|||||||
<Button
|
<Button
|
||||||
onClick={onConfirmClick}
|
onClick={onConfirmClick}
|
||||||
data-testid="confirm-modal-confirm"
|
data-testid="confirm-modal-confirm"
|
||||||
|
autoFocus={autoFocusConfirm}
|
||||||
{...confirmButtonOptions}
|
{...confirmButtonOptions}
|
||||||
>
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
|
|||||||
Reference in New Issue
Block a user