Gutenberg Disabled 组件深度解析:一个 inert 属性如何批量禁用整个交互区域
【免费下载链接】gutenbergThe Block Editor project for WordPress and beyond. Plugin is available from the official repository.项目地址: https://gitcode.com/GitHub_Trending/gu/gutenberg
在 WordPress Gutenberg 的组件库@wordpress/components中,Disabled是一个专门用于"区域级禁用"的工具组件:它把子孙元素整体移出 Tab 焦点序列,同时阻断所有指针交互,而不必逐个给子组件传disabled属性。读完本文,你将掌握该组件的用法与 Props 细节、基于inert属性与pointer-events的底层实现原理、通过Disabled.Context感知禁用状态的子组件模式,以及仓库中真实测试用例所验证的行为边界,从而在编辑器插件、模态框、预览态界面中正确实现整块区域的可逆禁用。
组件定位:与单个disabled属性有何不同
Disabled的官方定义只有一句话:
Disabled is a component which disables descendant tabbable elements and prevents pointer interaction. (禁用后代中可 Tab 聚焦的元素,并阻止指针交互。)
对应源码位于 index.tsx,组件渲染为一个带inert属性的<div>包裹层。与原生disabled属性只能作用于单个表单元素不同,Disabled解决的是整棵子树的禁用问题:表单输入、按钮、contentEditable区域、下拉框等,只要包在<Disabled>里,会同时失去焦点能力和鼠标事件,且整个状态可以通过一个 prop 随时可逆地切换。
基本用法:包裹表单即可整体禁用
文档(README.md)给出的标准示例是一个可切换禁用状态的表单。完整继承如下:
import { useState } from 'react'; import { Button, Disabled, TextControl } from '@wordpress/components'; const MyDisabled = () => { const [ isDisabled, setIsDisabled ] = useState( true ); let input = ( <TextControl label="Input" onChange={ () => {} } /> ); if ( isDisabled ) { input = <Disabled>{ input }</Disabled>; } const toggleDisabled = () => { setIsDisabled( ( state ) => ! state ); }; return ( <div> { input } <Button variant="primary" onClick={ toggleDisabled }> Toggle Disabled </Button> </div> ); };这个示例演示了两种等价的启用方式:
- 条件渲染:仅在需要时把内容包进
<Disabled>(示例中的写法),组件树中不存在包裹层; - prop 切换:始终保留
<Disabled>,通过isDisabled={ isDisabled }动态开关(源码与测试均采用此方式,见下文)。
注意示例中的Toggle Disabled按钮放在<Disabled>之外——这一点很关键:被禁用区域内的按钮无法点击,切换开关必须位于禁用区域外部,否则用户将永远无法恢复交互。仓库中 Storybook 的演示故事(stories/index.story.tsx)用TextControl、TextareaControl、SelectControl组合成一个表单来验证同样的效果,并提供了一个单独的ContentEditable故事,确认contentEditable区域同样会被禁用。
Props 说明
组件的 TypeScript 类型定义在 types.ts:
export interface DisabledProps { /** * Whether to disable all the descendant fields. * * @default true */ isDisabled?: boolean; /** * The children elements. */ children: React.ReactNode; }逐项说明:
| Prop | 类型 | 必填 | 默认值 | 说明 |
|---|---|---|---|---|
isDisabled | Boolean | 否 | true | 是否禁用所有后代字段。设为false时保留包裹结构但解除inert与禁用样式 |
children | React.ReactNode | 是 | — | 要被整体禁用的子元素 |
| 其余 props | HTMLAttributes | 否 | — | 透传给内部<div>,如className、data-testid等 |
关于最后一点:组件签名是WordPressComponentProps< DisabledProps, 'div' >(见 index.tsx),即除isDisabled和children外,所有标准 div 属性都会透传到包裹层 DOM 节点上。其中className有特殊处理逻辑,下一节会详细说明。
实现原理:inert属性 + Provider 双层控制
完整实现仅有约 80 行,核心渲染逻辑如下(摘自 index.tsx):
function Disabled( { className, children, isDisabled = true, ...props }: WordPressComponentProps< DisabledProps, 'div' > ) { return ( <Provider value={ isDisabled }> <div // @ts-expect-error `inert` is not declared in React 18's HTML attribute types. inert={ isDisabled ? 'true' : undefined } className={ clsx( className, isDisabled && [ styles.disabled, 'components-disabled' ] ) || undefined } { ...props } > { children } </div> </Provider> ); }它同时做了两件事:
1. 给 DOM 挂上inert属性
inert是 HTML 标准属性,浏览器对带inert的容器会执行两件事:容器内所有后代不可聚焦(Tab 导航直接跳过),且不接收任何指针事件。这正是"disable descendant tabbable elements"的实现基础,完全由浏览器原生行为完成,无需 JS 拦截事件。
两个源码细节值得注意:
- 属性以字符串形式
inert={ isDisabled ? 'true' : undefined }写入,且源码中带有@ts-expect-error注释——因为 React 18 的 HTML 属性类型定义尚未包含inert,这是刻意的类型绕过而非临时 hack; isDisabled为false时属性值为undefined,React 会直接移除该属性,实现可逆切换。
2. 通过 Context 广播禁用状态
外层的<Provider value={ isDisabled }>把当前禁用状态写进 React Context,供子组件自行读取。Context 定义在 context.ts:
import { createContext } from '@wordpress/element'; const Context = createContext< boolean >( false ); Context.displayName = 'DisabledContext'; export default Context;默认值是false——这意味着没有被<Disabled>包裹时,所有消费方拿到的都是"未禁用",语义与inert未挂载时的浏览器行为一致。组件还把 Context 挂载为静态属性导出(Disabled.Context = Context; Disabled.Consumer = Consumer;,见 index.tsx),两种消费方式都可用。
className 的合并策略:只切换禁用类,不动业务类
源码注释明确写道:"Only the disabled styling is conditional. The consumer's own className has to stick around so the wrapper stays targetable whether or not it is currently disabled."(只有禁用样式是条件性的,使用者自己的 className 必须保留,以便包裹层无论禁用与否都可被选择器定位。)
具体实现是用clsx合并:className无条件保留;styles.disabled和全局类components-disabled仅在isDisabled为true时附加。jsdom 测试专门验证了这一点(test/index.jsdom.test.tsx):
rerender 后 wrapper 仍然有 my-wrapper 类, 但不再带有 components-disabled 和 styles.disabled 类这一设计对样式定位很实用:外层样式表可以用固定 class 稳定命中包裹层,而禁用态的视觉反馈(透明度、指针样式)由条件类独立控制。
CSS 层:pointer-events如何兜底拦截鼠标
inert负责焦点与事件,而视觉上的"不可点击"由 style.module.scss 保证,全文只有几行:
.disabled { position: relative; pointer-events: none; &::after { content: ""; position: absolute; inset: 0; } // Also make nested blocks unselectable. * { pointer-events: none; } }拆解一下各行的作用:
pointer-events: none:包裹层自身不再命中鼠标事件;* { pointer-events: none }:注释写明目的是"让嵌套块也无法被选中",即子树内所有元素都不响应鼠标,包括文本选中;::after伪元素铺满整个容器(inset: 0):配合position: relative形成一个覆盖层。浏览器测试(test/index.browser.test.tsx)验证的结果是:被禁用区域内按钮的计算样式pointer-events为none,即指针事件确实到达不了后代。
这种"CSS 拦截 + inert 属性"的双保险结构,保证了即便个别浏览器对inert的指针处理有差异,视觉上与交互上仍然表现为不可操作。
Disabled.Context:子组件如何知道自己被禁用了
README 中给出的消费模式是:
function CustomButton( props ) { const isDisabled = useContext( Disabled.Context ); return <button { ...props } style={ { opacity: isDisabled ? 0.5 : 1 } } />; }被<Disabled>包裹时读到true,未包裹时读到false。适合用它实现"淡化显示、切换提示文案、隐藏某些控件"等视觉态调整——因为inert只处理交互,不处理外观。
Gutenberg 仓库中有真实的使用者。在导航块编辑器里:
- use-generate-default-navigation-title.js 用
useContext( Disabled.Context )判断编辑区是否被禁用,从而控制自动生成标题的行为; - unsaved-inner-blocks.jsx 同样读取该 Context,在禁用状态下调整未保存内部块的显示逻辑。
从源码结构看,这是编辑器的典型场景:当某块被"禁用编辑"(例如正在编辑其他块、或只读预览)时,整棵子树包进<Disabled>,而子块内部组件无需逐个传参,只需读一次 Context 即可同步视觉与行为状态。
测试(test/index.jsdom.test.tsx)覆盖了三条 Context 路径:
- 包裹在默认
<Disabled>中 → 消费方读到true,渲染 "Disabled"; - 包裹在
<Disabled isDisabled={ false }>中 → 读到false; - 完全没有
<Disabled>包裹 → 读到默认值false,不会报错。
测试用例揭示的行为边界
jsdom 测试文件(test/index.jsdom.test.tsx)还验证了几个实际开发中容易踩坑的点:
- 干净地取消禁用(reconciliation):当用"有/无
<Disabled>包裹"两种结构切换时(对应 README 示例的条件渲染写法),取消后包裹节点从文档中完全移除,inert属性随之消失; - prop 切换不丢用户输入:测试先往
<input>和contentEditable中分别输入文本,再反复切换isDisabled,断言两次切换后输入内容都原样保留。也就是说禁用是非破坏性的——它只锁交互,不卸载、不清空子组件的受控状态; - 包裹层属性完整性:
inert属性、components-disabled类与 CSS Module 的styles.disabled类三者同进同出。
浏览器端测试(test/index.browser.test.tsx)则在真实浏览器环境中验证了两条核心承诺:
- 被禁用区域按钮的
getComputedStyle(...).pointerEvents为'none'; - 按 Tab 键时焦点跳过整个禁用子树,直接落在区域外的 "Next action" 按钮上——这正是"disables descendant tabbable elements"的可执行定义。
浏览器兼容性与inertpolyfill
README 结尾有一条兼容性提示,此处完整保留:
Note: this component may not behave as expected in browsers that don't support the
inertHTML attribute. We recommend adding the official WICG polyfill when using this component in your project.(注意:在不支持inertHTML 属性的浏览器中,该组件可能表现不符合预期。在项目中使用此组件时,建议引入官方的 WICG inert polyfill。)
含义是:
- 在支持
inert的浏览器中,焦点隔离与事件屏蔽由浏览器原生保证,组件本身零 JS 运行时开销; - 在不支持的旧浏览器中,
inert属性会被当作无效属性忽略,此时焦点隔离失效,仅剩pointer-events: none的 CSS 兜底拦截鼠标;若目标环境包含此类浏览器,需要自行引入 WICG 的官方 inert polyfill。
小结与速查
| 要点 | 结论 | 依据 |
|---|---|---|
| 禁用机制 | 包裹层 DOM 挂inert属性 + CSSpointer-events: none+::after覆盖层 | index.tsx、style.module.scss |
| 可逆切换 | isDisabled传false即移除inert与禁用类,子组件状态不受影响 | test/index.jsdom.test.tsx |
| 状态感知 | useContext( Disabled.Context ),默认false;Disabled.Consumer等价可用 | context.ts |
| 导出位置 | export { default as Disabled } from './disabled'; | packages/components/src/index.ts |
| Props | isDisabled(Boolean,默认true)、children,其余 div 属性透传 | types.ts |
| 兼容前提 | 依赖浏览器对inert的支持,旧浏览器需 WICG polyfill | README.md |
使用Disabled的决策建议:只需禁用单个表单控件时,优先用原生disabled/ 组件自带的disabledprop;需要把一整块区域(表单组、子块、模态框内容、预览态界面)连同焦点和鼠标一起"锁住"、并且要随时可逆地解锁时,才用<Disabled>包裹,并让子组件通过Disabled.Context同步自己的视觉状态。
【免费下载链接】gutenbergThe Block Editor project for WordPress and beyond. Plugin is available from the official repository.项目地址: https://gitcode.com/GitHub_Trending/gu/gutenberg
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考