ariakit icon indicating copy to clipboard operation
ariakit copied to clipboard

Feature Request: add isPressed state prop to interactive components

Open Saeris opened this issue 3 years ago • 7 comments

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:

  • :active CSS pseudo-class: this one works in some cases, but for this I wanted to conditionally show one of three SVG icons which came from react-icons, so this would have been a bit sloppy to conditionally show/hide 3 elements with the display property.
  • third-party library such as @react-aria/interactions: problem with using the usePress hook 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.

Saeris avatar Mar 07 '22 07:03 Saeris

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:

  1. Use this CSS selector instead of a plain :hover:
:is(:active:hover,[data-active],[data-keyboard-active]):not([disabled],[aria-disabled]) { }
  1. 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 :)

DaniGuardiola avatar Aug 29 '22 23:08 DaniGuardiola

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.

stale[bot] avatar Mar 18 '23 23:03 stale[bot]

Reopening the suggestion, awaiting more discussion/support.

DaniGuardiola avatar May 23 '23 19:05 DaniGuardiola

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.

DaniGuardiola avatar May 23 '23 19:05 DaniGuardiola

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.

That makes sense. Could this use the data-active attribute or would be too different from the :active pseudo-class?

diegohaz avatar Jun 04 '23 14:06 diegohaz

@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.

DaniGuardiola avatar Jun 05 '23 16:06 DaniGuardiola

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.

diegohaz avatar Jun 05 '23 17:06 diegohaz