@baukit/a11y-core

中文文档
0.18.3发布于 2026/11/07MITTypeScriptES Module零依赖构建来源已验证近 30 天下载 8727

Accessibility behavior shared by web and React Native products: overlay focus, inert background content, announcements, reduced motion, and keyboard movement.

原始使用文档
文档目录8 节
  1. Two entry points
  2. Overlays
  3. Menus
  4. Route focus
  5. The React Native to DOM boundary
  6. Announcements
  7. Reduced motion, groups, and forms
  8. Boundaries

@baukit/a11y-core

@baukit/a11y-core holds the accessibility behavior that web and React Native products share: overlay focus, inert background content, announcements, reduced motion, and keyboard movement through groups and forms. Products keep their components, copy, and visual design local.

The package imports React and React Native and nothing else. Both are peer dependencies, so the product's Expo SDK decides the versions, and React Native is optional.

Two entry points

A React Native product imports the package root and gets everything. A plain React web app imports @baukit/a11y-core/web and gets announce, useFocusTrap, useInert, useAriaHiddenInert, useReducedMotion, useReducedMotionPreference, useRovingMenu, nextEnabledMenuIndex, useSingleFlight, createRouteFocusController, and the dom-boundary helpers. Nothing reachable from that entry imports react-native, at runtime or in its types, so the app needs no React Native in its dependency tree. react-native is an optional peer dependency for exactly that reason.

The DOM hooks ask whether a document exists rather than asking Platform which OS this is. The two questions have the same answer here: every branch those hooks guard reads or writes the DOM, React Native Web gives them a real document, and React Native has none. Hooks with a genuine platform split, such as useOverlayA11y, keep reading Platform and stay behind the root entry. announce and useReducedMotion exist on both entries: the root version reads Platform, and the /web version is its DOM half.

Overlays

useOverlayA11y is one contract over two platforms that behave nothing alike.

const { backgroundProps, containerProps } = useOverlayA11y({
  active: visible,
  containerRef: panelRef,
  inertContainerRef: overlayRootRef,
  initialFocusRef,
  onEscape: onClose,
  triggerRef,
});

On web it moves focus into the overlay, contains Tab and Shift+Tab, calls onEscape, and makes everything outside inertContainerRef inert. On close it checks focus on the next task so useInert can release the background first. It keeps focus on a connected element outside the closing container unless that element is in an inert, hidden, or aria-hidden="true" subtree. Otherwise it restores the opener if the opener is still connected. Reopening before the task runs cancels the pending restore.

Both useFocusTrap and useOverlayA11y accept two optional callbacks:

  • restoreFocus?: () => boolean decides whether to restore the opener. It defaults to true.
  • returnFocus?: () => FocusTarget | null supplies a fallback when the opener is disconnected or restoreFocus returns false. A FocusTarget has a focus() method, so an element or native view can be returned directly. Web only focuses a connected fallback.

The callbacks run when restoration is needed, after the outside-focus check on web. Changes to either callback do not restart the trap. To leave focus alone, pass restoreFocus: () => false without a fallback. To return to a route heading when an action removes the opener:

useFocusTrap({
  active: visible,
  containerRef: panelRef,
  restoreFocus: () => !navigatingRef.current,
  returnFocus: () => headingRef.current,
});

On native it waits before requesting accessibility focus, so no focus lands before layout. On close it returns focus to triggerRef. Native cannot discover what previously held accessibility focus, so the caller owns that ref. The hook resolves the ref again on close and uses returnFocus if the trigger has unmounted or restoreFocus returns false. Native cannot inspect current accessibility focus, so it cannot apply the web outside-focus check. Pass triggerHandle instead when the node tag is already resolved; the caller must keep that tag valid until close.

The wait defaults to InteractionManager.runAfterInteractions, which React Native 0.86 deprecates without offering a replacement for "after the overlay presented". Products with a real presentation event should pass deferFocus and drive the focus request from it:

const deferFocus = (task: () => void) => {
  layoutTasks.push(task);
  return { cancel: () => remove(task) };
};

Spread backgroundProps onto the content behind the overlay, never the overlay itself. It sets accessibilityElementsHidden and importantForAccessibility while the overlay is open, and is empty on web and while closed. Spread containerProps onto the overlay container.

useFocusTrap and useInert are the web halves on their own, for products that compose their own overlay. useAriaHiddenInert covers a separate problem: routers mark inactive web scenes aria-hidden, which leaves their descendants in the keyboard focus order. Mount it once at the app root. Put ARIA_HIDDEN_INERT_OPT_OUT on an element that must stay focusable anyway.

Menus

useRovingMenu holds the keyboard state of an action menu. It gives the menu one tab stop on an enabled item, moves focus with the arrow keys while skipping disabled items, wraps at both ends, and sends Home and End to the first and last enabled items. It renders nothing and never invokes an action. Escape, Tab containment, the inert background, and focus restoration stay with useOverlayA11y, so call useRovingMenu first and pass its initialFocusRef on:

const menu = useRovingMenu({ active: visible, options: items });
const overlay = useOverlayA11y({
  active: visible,
  containerRef: panelRef,
  initialFocusRef: menu.initialFocusRef,
  onEscape: onClose,
  triggerRef,
});

items.map((item, index) => (
  <Pressable key={item.id} {...menu.itemProps(index)} disabled={item.disabled} />
));

options only needs disabled and selected, so the product's item array fits as is. The tab stop starts on the first selected enabled item, otherwise the first enabled item, and returns there each time the menu opens. itemProps(index) returns the tabIndex, ref, and onKeyDown for one item; activeIndex names the item holding the tab stop.

  • With no enabled item, activeIndex is null, every item has tabIndex: -1, arrow keys do nothing, and initialFocusRef.current is null. useOverlayA11y then focuses the first focusable element in the container, so put the close control before the items.
  • When the active item is disabled or removed while the menu is open, the tab stop moves to the item now at that position if it is enabled, otherwise back to the initial item. If the removed item held focus and focus fell to the document body, the hook focuses the new active item.
  • A key that comes from a host nested inside an item, such as a text field, is left alone.
  • initialFocusRef is one stable ref for the life of the component. The hook points it at the active item in an effect, and the overlay reads it in a later effect when the menu opens, so both hooks must run in the same component with useRovingMenu first. Do not swap it for another ref while the menu is open: the web trap would re-enter and forget the trigger it restores to.

nextEnabledMenuIndex(key, currentIndex, options) is the pure step behind the hook, for a product that handles keys on the menu container instead. It returns null for keys other than the arrows, Home, and End, and for a menu without an enabled item. It moves relative to currentIndex even when that item is disabled or gone: an index past the end moves Down to the first enabled item and Up to the last, and so does -1.

Action order, closing before or after an action, async failure recovery, selection meaning, and copy stay with the product.

Route focus

WebKit can blur the initiating control to body when a router makes the outgoing scene inert. Create one createRouteFocusController at app startup so it can remember the last reachable focused element. Enter a route with a target getter, then run the returned cleanup when the route becomes inactive:

const routeFocus = createRouteFocusController();

const leaveRoute = routeFocus.enterRoute(() =>
  document.querySelector<HTMLElement>('[data-route-heading]'),
);

leaveRoute();
routeFocus.dispose();

The controller waits for the destination target to mount and retries on animation frames for up to 1.5 seconds. It waits for inert or hidden return targets to become reachable. It stops if the user moves focus to another reachable element, and it never focuses a target below inert, aria-hidden="true", or hidden.

React Native has no document, so the controller does not apply there. To move VoiceOver or TalkBack to a destination heading, call focusAccessibilityElement(headingRef) from the root entry once the heading has laid out, for example one animation frame after the route gains focus. On native it resolves the view tag and calls AccessibilityInfo.setAccessibilityFocus. On web it focuses the element with preventScroll. It returns false when the ref is empty, the view is unmounted, or the web element cannot take focus.

import { focusAccessibilityElement } from '@baukit/a11y-core';

const frame = requestAnimationFrame(() => {
  focusAccessibilityElement(headingRef);
});

The React Native to DOM boundary

React Native Web renders a View as a DOM element, but the View type never says so. Every crossing goes through dom-boundary, which checks for the method it needs and returns null otherwise. asFocusTarget, asFocusContainer, and asTreeElement take a HostRef and narrow it; a native host that has no DOM capability simply yields null instead of throwing. HostRef is RefObject<object | null>, which accepts a View ref and an element ref without naming either type.

Announcements

announce(message, options) speaks an outcome without a visible live-region component. Native calls announceForAccessibility. Web writes into a visually hidden region, clearing the text and forcing a reflow first so the same message twice is spoken twice. Blank messages are dropped.

The region's DOM id defaults to baukit-announcer. Pass liveRegionId to place it under a product-owned id, and assertive: true to interrupt rather than wait for a pause.

announce from @baukit/a11y-core/web takes the same arguments and is the web half alone, so a plain React app gets the live region without React Native. It creates the region on the first call, or adopts an element the product already rendered under that id, and needs no component or provider. Filtering which messages deserve speech stays in the product: call announce only for the ones that do.

Reduced motion, groups, and forms

useReducedMotionPreference returns { reducedMotion, resolved } and follows preference changes on both platforms. Web resolves during the first render. The native query is asynchronous, so resolved starts as false and becomes true whether the query succeeds or fails. Do not start non-essential motion until it is true. useReducedMotion remains the boolean form for existing callers.

The web entry exports both hooks with the same names and return shapes, built on matchMedia and useSyncExternalStore alone. They read the media query during the first client render and follow its change events. During server rendering and the hydration render they report { reducedMotion: false, resolved: false }, then resolve on the client. A host without matchMedia reports no preference, resolved.

useRovingRadioGroup gives a radio group a single tab stop and arrow-key movement between its options, wrapping at both ends and honoring Home and End. radioProps(index) returns the tabIndex, ref, and onKeyDown for one option.

useEnterToNext makes Enter walk a web form field by field and submit from the last one. Multiline fields keep their newline behavior and are skipped along the way. On native it returns only the ref, leaving the platform keyboard alone.

useSingleFlight is a synchronous mutex for async UI mutations. React state cannot lock within a single tick, so a double tap would submit twice. A rejected call resolves to undefined, and the lock is released even when the operation throws.

Boundaries

This package renders nothing and ships no components, styles, or copy. It does not decide which outcomes deserve an announcement, which motion is essential, or what an overlay looks like. Layout breakpoint arithmetic lives in @baukit/ui-tokens, not here.

版本与发布记录

@baukit/a11y-core 共发布 67 个版本,首次发布于 2026/08/26;过去 12 个月发布了 67 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.18.3latest2026/11/07今天
0.18.22026/11/061 天前
0.18.12026/11/062 天前
0.18.02026/11/052 天前
0.17.12026/11/034 天前
0.17.02026/11/025 天前

关于 @baukit/a11y-core 的常见问题

@baukit/a11y-core 是什么?

Accessibility behavior shared by web and React Native products: overlay focus, inert background content, announcements, reduced motion, and keyboard movement.

如何安装 @baukit/a11y-core?

在项目目录运行 npm install @baukit/a11y-core,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:react(^19.2.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@baukit/a11y-core 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@baukit/a11y-core 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@baukit/a11y-core 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@baukit/a11y-core 可以商用吗?

@baukit/a11y-core 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@baukit/a11y-core 的下载量有多少?

根据 npm 官方下载统计,@baukit/a11y-core 在 2026/10/08 – 2026/11/06 这 30 天内被下载 8,727 次,日均约 291 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@baukit/a11y-core 还在维护吗?

最新版本 0.18.3 发布于 2026/11/07(今天);至今共发布 67 个版本,其中过去 12 个月发布了 67 个;首次发布于 2026/08/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。