Feature Request: add isPressed state prop to interactive components
Motivation
Ran into some roadblocks trying to add an intermediate state for a toggle button built on top of the FormCheckbox and Button components in v2. It doesn't appear like there is a straightforward way to get an isPressed state for a button (meaning the time between mouseDown and mouseUp or similar "press" events).
Since I couldn't get this from the checkbox or button state hooks, the alternatives I tried were:
:activeCSS pseudo-class: this one works in some cases, but for this I wanted to conditionally show one of three SVG icons which came fromreact-icons, so this would have been a bit sloppy to conditionally show/hide 3 elements with thedisplayproperty.- third-party library such as
@react-aria/interactions: problem with using theusePresshook from this library is that it's event handlers didn't coexist well with ariakit. I had issues with event bubbling, and it feels like overkill to include a hook with >400 LOC just to get a boolean state. - implement my own event handler with
useState: similar to the above, I ran into odd event bubbling issues. - compare a ref to
document.activeElement: this condition remains true after clicking because now the input itself has focus.
Usage example
Here's a simplified version of the motivating example:
import { Checkbox, useCheckbox } from "ariakit/checkbox";
import { MdClear, MdCheck, MdClose } from "react-icons/md";
export const Toggle = (props) => {
const state= useCheckbox(props);
return (
<Checkbox as="button" state={state}>
{state.isPressed ? <MdClear /> : state.checked ? <MdCheck /> : <MdClose />}
</Checkbox>
);
};
With state.isPressed, we could short-circuit to display an icon only while the mouse button is held down, the spacebar is held, etc.
Possible implementations
The usePress hook in @react-aria/interactions gives me the impression that there is a lot of subtle nuance in implementing this state, but I would think that this should be simpler than that to get an initial implementation in.
I think the majority of this can be wrapped up in a hook that can be consumed by most of the interactive components. I'm not very opinionated about how it should be implemented, nor do I have a good enough understanding of the edge cases that need to be accounted for.
I also used react-aria in the past and was missing isPressed, so I came up with a simpler way to achieve it, by leveraging CSS (for pointer events) and a simple hook to add spacebar press support. Here's how:
- Use this CSS selector instead of a plain
:hover:
:is(:active:hover,[data-active],[data-keyboard-active]):not([disabled],[aria-disabled]) { }
- Use this hook to account for spacebar press:
const { keyboardActiveProps } = useKeyboardActive(props);
return <Button {...keyboardActiveProps } />
Note: you might need to merge the listeners with other props you're passing.
Code for the hook:
/**
* Hook that tracks whether the spacebar is currently being pressed for a button,
* which should trigger `:active` styles. Only works for non-disabled elements that
* are not links.
*
* @example
* ```jsx
* const { isKeyboardActive, keyboardActiveProps } = useKeyboardActive(props);
* <button
* {...keyboardActiveProps}
* data-keyboard-active={isKeyboardActive ? "" : undefined}
* />
* ```
*/
export function useKeyboardActive({
as,
disabled,
"aria-disabled": ariaDisabled,
}: UseKeyboardActiveOptions) {
const [isKeyboardActive, setKeyboardActive, isKeyboardActiveRef] =
useStateRef(false);
const globalKeyUpListener = useRef<((event: KeyboardEvent) => void) | null>(
null
);
function cleanUpGlobalListener() {
if (globalKeyUpListener.current) {
document.removeEventListener("keyup", globalKeyUpListener.current);
globalKeyUpListener.current = null;
}
}
// make sure we clean the global listener on unmount
useEffect(() => cleanUpGlobalListener, []);
return useMemo(() => {
// bail if disabled or not a button
if (disabled || ariaDisabled || as === "a")
return { isKeyboardActive: false, keyboardActiveProps: {} };
function stop() {
// set to false if already started
if (isKeyboardActiveRef.current) setKeyboardActive(false);
// clean up global handler either way
cleanUpGlobalListener();
}
function start() {
// bail if already started
if (isKeyboardActiveRef.current) return;
// set to true
setKeyboardActive(true);
// register global keyup event handler, in case focus moves to a
// different element before the "keyup" event is fired
if (!globalKeyUpListener.current) {
// if it doesn't already exist, create handler and register it
globalKeyUpListener.current = (globalEvent) => {
// stop if key is spacebar
if (["Spacebar", " "].includes(globalEvent.key)) stop();
};
document.addEventListener("keyup", globalKeyUpListener.current);
}
}
const keyboardActiveProps: HTMLAttributes<HTMLElement> & {
"data-keyboard-active": "" | undefined;
} = {
onKeyDown: (event) => {
// start if key is spacebar and event is not repeating
if (["Spacebar", " "].includes(event.key) && !event.repeat) start();
},
onBlur: () => stop(),
"data-keyboard-active": isKeyboardActive ? "" : undefined,
};
return { isKeyboardActive, keyboardActiveProps };
}, [
ariaDisabled,
as,
disabled,
isKeyboardActive,
isKeyboardActiveRef,
setKeyboardActive,
]);
}
Feel free to use this hook as long as you don't publish it as part of open source code - I might do that myself :)
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
Reopening the suggestion, awaiting more discussion/support.
Just wanna add as an idea that this could be supported in a simpler way by setting an attribute like data-pressed with this special behavior, both for the pointer and keyboard behaviors described above, in the Command component.
Just wanna add as an idea that this could be supported in a simpler way by setting an attribute like
data-pressedwith this special behavior, both for the pointer and keyboard behaviors described above, in theCommandcomponent.
That makes sense. Could this use the data-active attribute or would be too different from the :active pseudo-class?
@diegohaz this state is different enough to grant a different name IMO. For example, there is a situation where :active would be... active (haha) and [data-active] wouldn't. This might be confusing since for other data- attributes are considered "fallbacks" that are used together (e.g. in CSS .element:hover, .element[data-hover]), but here you instead would *replace* :activeentirely with[data-active]` for the desired effect.
Also, :active refers only to pointer events, but this would also cover keyboard interaction like the one discussed above.
There may be some differences between pseudo-classes and their equivalent data attributes in Ariakit. For example, the data-focus-visible attribute is applied to composite items that receive keyboard focus through aria-activedescendant. We may also force this attribute on Select when it's focused by clicking on SelectLabel (see https://github.com/ariakit/ariakit/issues/2497#issuecomment-1573846021).
Although I cannot recall a specific scenario where :focus-visible is applied without data-focus-visible, it is possible. Because of that, in the Styling guide, we're suggesting using only data-focus-visible when styling Ariakit components. If it makes sense, we could follow the same approach for data-active.
Regarding the :active pseudo-class, it's also triggered when using the Space key. If we opt not to use data-active, it would be prudent to select a name that resembles :active to indicate a specialized version of the pseudo-class, similar to the relationship between :focus-visible and :focus. Perhaps something like data-active-visible.
One concern with data-pressed is the potential confusion with the existing aria-pressed state, which holds a different meaning specifically for buttons. Introducing data-pressed could lead to some ambiguity.