Files
Triple-C/app/src/components/ui/unavailable.tsx
T
shadowdaoandClaude Opus 5 aa0a574091 Announce unavailable controls instead of hiding them behind disabled
Native `disabled` removes an element from the tab order and from the
accessibility tree, so any explanation of why a control cannot be used is
delivered only to a sighted user with a mouse. `useUnavailable` is the way
out: `aria-disabled` keeps the control focusable and announced,
`aria-describedby` carries the reason, and — because `aria-disabled` is
advisory and blocks nothing — the hook hands back the click and Enter/Space
guards along with the attributes, so a call site cannot take the
announcement without the guard.

`Button` gets it as an opt-in `unavailable` / `unavailableReason` pair.
Opt-in matters: 37 files render this button and none of them change. The
`aria-disabled:` class mirrors exist because Tailwind's `disabled:` variant
only matches the native attribute, which this pattern deliberately omits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
2026-09-02 09:08:50 -07:00

88 lines
2.6 KiB
TypeScript

import {
useId,
type KeyboardEventHandler,
type MouseEventHandler,
type ReactNode,
} from "react";
/** Keys a native `<button>` turns into a click. */
const ACTIVATION_KEYS = new Set([" ", "Spacebar", "Enter"]);
export interface UnavailableControlProps {
"aria-disabled"?: true;
"aria-describedby"?: string;
onClick?: MouseEventHandler<HTMLButtonElement>;
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
}
export interface UnavailableControl {
/** Spread onto the control. Carries the guarded handlers. */
controlProps: UnavailableControlProps;
/**
* Render as a *sibling* of the control — inside it the reason would be
* appended to the accessible name instead of the description.
*/
reasonNode: ReactNode;
}
/**
* Makes a control unavailable without hiding it from assistive technology.
*
* `disabled` takes an element out of the tab order *and* out of the
* accessibility tree, so the `title` explaining why it cannot be used is
* announced to nobody and shown only to a sighted user with a mouse. That is
* backwards: the people who most need the reason are the ones who never get
* it. `aria-disabled` keeps the control focusable and announced, and
* `aria-describedby` hands over the reason.
*
* The catch is that `aria-disabled` is advisory — it does not block clicks or
* Enter/Space the way `disabled` does. This hook therefore returns the guards
* along with the attributes, so a call site cannot take the announcement
* without the guard. Handlers that a form can reach without going through the
* control (Enter inside a text field submits the form) still have to guard
* themselves.
*/
export function useUnavailable({
unavailable,
reason,
onClick,
onKeyDown,
}: {
unavailable: boolean;
reason: string;
onClick?: MouseEventHandler<HTMLButtonElement>;
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
}): UnavailableControl {
const reasonId = `${useId()}unavailable`;
if (!unavailable) {
return { controlProps: { onClick, onKeyDown }, reasonNode: null };
}
return {
controlProps: {
"aria-disabled": true,
"aria-describedby": reasonId,
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
onKeyDown: (e) => {
if (!ACTIVATION_KEYS.has(e.key)) {
onKeyDown?.(e);
return;
}
// Suppress the default action before it can become a click, submit a
// form, or scroll the page.
e.preventDefault();
e.stopPropagation();
},
},
reasonNode: (
<span id={reasonId} className="sr-only">
{reason}
</span>
),
};
}