mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 20:26:24 +00:00
Create AriaClickable component
This commit is contained in:
110
ts/axo/AriaClickable.stories.tsx
Normal file
110
ts/axo/AriaClickable.stories.tsx
Normal 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
246
ts/axo/AriaClickable.tsx
Normal 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`;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const Namespace = 'AxoSelect';
|
||||
* <AxoSelect.Item/>
|
||||
* </AxoSelect.Group>
|
||||
* </AxoSelect.Content>
|
||||
* </Select.Root>
|
||||
* </AxoSelect.Root>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user