Create AriaClickable component

This commit is contained in:
Jamie Kyle
2025-09-02 10:31:58 -07:00
committed by GitHub
parent 10e1953ae3
commit b4da619b3c
5 changed files with 374 additions and 2 deletions

View File

@@ -0,0 +1,110 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React, { useId } from 'react';
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { AriaClickable } from './AriaClickable';
import { AxoButton } from './AxoButton';
import { tw } from './tw';
export default {
title: 'Axo/AriaClickable',
} satisfies Meta;
function Card(props: { children: ReactNode }) {
return (
<AriaClickable.Root
className={tw(
'group flex items-center gap-4 rounded-md border border-border-secondary p-4',
'data-[hovered]:bg-background-secondary',
'data-[pressed]:bg-fill-secondary-pressed',
'outline-0 outline-border-focused',
'data-[focused]:outline-[2.5px]'
)}
>
{props.children}
</AriaClickable.Root>
);
}
function CardTitle(props: { children: ReactNode }) {
return (
<h3 className={tw('type-title-medium text-label-primary')}>
{props.children}
</h3>
);
}
function CardContent(props: { children: ReactNode }) {
return <div className={tw('flex-1')}>{props.children}</div>;
}
function CardSeeMoreLink(props: { onClick: () => void; children: ReactNode }) {
const id = useId();
return (
<>
<span
id={id}
className={tw(
'text-color-label-primary',
'group-data-[hovered]:underline'
)}
>
{props.children}
</span>
<AriaClickable.HiddenTrigger
aria-labelledby={id}
onClick={props.onClick}
/>
</>
);
}
function CardActions(props: { children: ReactNode }) {
return (
<AriaClickable.DeadArea
className={tw('flex w-fit shrink-0 items-center gap-4 rounded-full')}
>
{props.children}
</AriaClickable.DeadArea>
);
}
function CardButton(props: {
variant: AxoButton.Variant;
onClick: () => void;
children: ReactNode;
}) {
return (
<AriaClickable.SubWidget>
<AxoButton variant={props.variant} size="medium" onClick={props.onClick}>
{props.children}
</AxoButton>
</AriaClickable.SubWidget>
);
}
export function Basic(): JSX.Element | null {
return (
<Card>
<CardContent>
<CardTitle>Card Title</CardTitle>
<p>
Lorem ipsum dolor, sit amet consectetur adipisicing elit...{' '}
<CardSeeMoreLink onClick={action('onSeeMore')}>
See more
</CardSeeMoreLink>
</p>
</CardContent>
<CardActions>
<CardButton variant="borderless-primary" onClick={action('onEdit')}>
Edit
</CardButton>
<CardButton variant="destructive" onClick={action('onDelete')}>
Delete
</CardButton>
</CardActions>
</Card>
);
}

246
ts/axo/AriaClickable.tsx Normal file
View File

@@ -0,0 +1,246 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
createContext,
memo,
useCallback,
useContext,
useRef,
useState,
} from 'react';
import type { ReactNode, MouseEvent, FC } from 'react';
import { useLayoutEffect } from '@react-aria/utils';
import { tw } from './tw';
import { assert } from './_internal/assert';
const Namespace = 'AriaClickable';
/**
* @example Anatomy
* ```tsx
* export default () => (
* <AriaClickable.Root>
* <h3>Card Title</h3>
* <p>
* Lorem ipsum dolor sit amet consectetur adipisicing elit...
* <span id="see-more-1">See more</span>
* <AriaClickable.HiddenTrigger aria-labelledby="see-more-1"/>
* </p>
* <AriaClickable.SubWidget>
* <AxoButton>Delete</AxoButton>
* </AriaClickable.SubWidget>
* <AriaClickable.SubWidget>
* <AxoLink>Edit</AxoLink>
* </AriaClickable.SubWidget>
* </AriaClickable.Root>
* );
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace AriaClickable {
type TriggerState = Readonly<{
hovered: boolean;
pressed: boolean;
focused: boolean;
}>;
const INITIAL_TRIGGER_STATE: TriggerState = {
hovered: false,
pressed: false,
focused: false,
};
type TriggerStateUpdate = (state: TriggerState) => void;
const TriggerStateUpdateContext = createContext<TriggerStateUpdate | null>(
null
);
/**
* Component: <AriaClickable.Root>
* -------------------------------
*/
export type RootProps = Readonly<{
className?: string;
children: ReactNode;
}>;
export const Root: FC<RootProps> = memo(props => {
const [hovered, setHovered] = useState(INITIAL_TRIGGER_STATE.hovered);
const [pressed, setPressed] = useState(INITIAL_TRIGGER_STATE.pressed);
const [focused, setFocused] = useState(INITIAL_TRIGGER_STATE.focused);
const handleTriggerStateUpdate: TriggerStateUpdate = useCallback(state => {
setHovered(state.hovered);
setPressed(state.pressed);
setFocused(state.focused);
}, []);
return (
<TriggerStateUpdateContext.Provider value={handleTriggerStateUpdate}>
<div
// eslint-disable-next-line better-tailwindcss/no-restricted-classes
className={tw('relative!', props.className)}
// For styling based on the HiddenTrigger state.
data-hovered={hovered ? true : null}
data-focused={focused ? true : null}
data-pressed={pressed ? true : null}
>
{props.children}
</div>
</TriggerStateUpdateContext.Provider>
);
});
Root.displayName = `${Namespace}.Root`;
/**
* Component: <AriaClickable.SubAction>
* ------------------------------------
*/
export type SubWidgetProps = Readonly<{
children: ReactNode;
}>;
/**
* Every nested interactive widget (buttons, links, selects, etc) should be
* wrapped with <SubWidget> in order to give it the correct styles (z-index).
*/
export const SubWidget: FC<SubWidgetProps> = memo(props => {
return (
<div
// eslint-disable-next-line better-tailwindcss/no-restricted-classes
className={tw('contents *:relative *:z-20')}
>
{props.children}
</div>
);
});
SubWidget.displayName = `${Namespace}.SubWidget`;
export type DeadAreaProps = Readonly<{
className?: string;
children: ReactNode;
}>;
/**
* Use this to create an "dead" area around your nested widgets where the
* pointer won't click the `<HiddenTrigger>`.
*
* This is useful when you want to prevent accidental clicks around one or
* more nested widgets.
*/
export const DeadArea: FC<DeadAreaProps> = memo(props => {
return (
// eslint-disable-next-line better-tailwindcss/no-restricted-classes
<div className={tw('relative! z-20!', props.className)}>
{props.children}
</div>
);
});
DeadArea.displayName = `${Namespace}.DeadArea`;
/**
* Component: <AriaClickable.HiddenTrigger>
* ------------------------------------
*/
export type HiddenTriggerProps = Readonly<{
/**
* This should reference the ID of an element that describes the action that
* will be taken `onClick`, not the entire clickable root.
*
* @example
* ```tsx
* <span id="see-more-1">See more</span>
* <HiddenTrigger aria-labelledby="see-more-1"/>
* ```
*/
'aria-labelledby': string;
onClick: (event: MouseEvent) => void;
}>;
/**
* Provides an invisible button that fills the entire area of
* `<AriaClickable.Root>`
*
* Notes:
* - This cannot be wrapped with any other `position: relative` element.
* - This should be inserted in the expected focus order, which is likely
* before any <AriaClickable.SubWidget>.
*/
export const HiddenTrigger: FC<HiddenTriggerProps> = memo(props => {
const ref = useRef<HTMLButtonElement>(null);
const onTriggerStateUpdate = useContext(TriggerStateUpdateContext);
if (onTriggerStateUpdate == null) {
throw new Error(
`<${Namespace}.HiddenTrigger> must be wrapped with <${Namespace}.Root>`
);
}
const onTriggerStateUpdateRef = useRef(onTriggerStateUpdate);
useLayoutEffect(() => {
onTriggerStateUpdateRef.current = onTriggerStateUpdate;
}, [onTriggerStateUpdate]);
useLayoutEffect(() => {
const button = assert(ref.current, 'Missing ref');
let timer: ReturnType<typeof setTimeout>;
function update() {
onTriggerStateUpdateRef.current({
hovered: button.matches(':hover:not(:disabled)'),
pressed: button.matches(':active:not(:disabled)'),
focused: button.matches('.keyboard-mode :focus'),
});
}
function delayedUpdate() {
clearTimeout(timer);
timer = setTimeout(update, 1);
}
update();
button.addEventListener('pointerenter', update);
button.addEventListener('pointerleave', update);
button.addEventListener('pointerdown', update);
button.addEventListener('pointerup', update);
button.addEventListener('focus', update);
button.addEventListener('blur', update);
// need delay
button.addEventListener('keydown', delayedUpdate);
button.addEventListener('keyup', delayedUpdate);
return () => {
clearTimeout(timer);
onTriggerStateUpdateRef.current(INITIAL_TRIGGER_STATE);
button.removeEventListener('pointerenter', update);
button.removeEventListener('pointerleave', update);
button.removeEventListener('pointerdown', update);
button.removeEventListener('pointerup', update);
button.removeEventListener('focus', update);
button.removeEventListener('blur', update);
// need delay
button.removeEventListener('keydown', delayedUpdate);
button.removeEventListener('keyup', delayedUpdate);
};
}, []);
return (
<button
ref={ref}
type="button"
className={tw('absolute inset-0 z-10 outline-0')}
aria-labelledby={props['aria-labelledby']}
onClick={props.onClick}
/>
);
});
HiddenTrigger.displayName = `${Namespace}.HiddenTrigger`;
}

View File

@@ -26,7 +26,7 @@ const Namespace = 'AxoSelect';
* <AxoSelect.Item/>
* </AxoSelect.Group>
* </AxoSelect.Content>
* </Select.Root>
* </AxoSelect.Root>
* );
* ```
*/

View File

@@ -4,7 +4,9 @@
export type TailwindStyles = string & { __Styles: never };
export function tw(
...classNames: ReadonlyArray<TailwindStyles | string | boolean | null>
...classNames: ReadonlyArray<
TailwindStyles | string | boolean | null | undefined
>
): TailwindStyles {
const { length } = classNames;

View File

@@ -811,6 +811,20 @@
"reasonCategory": "usageTrusted",
"updated": "2024-11-14T18:53:33.345Z"
},
{
"rule": "React-useRef",
"path": "ts/axo/AriaClickable.tsx",
"line": " const ref = useRef<HTMLButtonElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-08-28T23:36:44.974Z"
},
{
"rule": "React-useRef",
"path": "ts/axo/AriaClickable.tsx",
"line": " const onTriggerStateUpdateRef = useRef(onTriggerStateUpdate);",
"reasonCategory": "usageTrusted",
"updated": "2025-08-28T23:36:44.974Z"
},
{
"rule": "React-useRef",
"path": "ts/calling/useGetCallingFrameBuffer.ts",