跳转到内容

connect 与属性产出 ​

connect 是无头内核的出口:输入是服务与归一化器,输出是一组 props-getter。它把状态机的当前状态翻译为 DOM 此刻应有的属性。

ts
export function connectAccordion<T extends PropTypes>(
  service: Service<AccordionSchema>,
  normalize: NormalizeProps<T>,
): AccordionApi<T>;

connect 中没有任何 DOM 写操作,也没有任何框架 API。它是一个纯函数:同样的服务状态,产出同样的属性字典。

api 的成员 ​

以手风琴为例:

ts
export interface AccordionApi<T extends PropTypes = PropTypes> {
  // 状态与操作
  value: string[];
  setValue: (next: string[]) => void;
  isOpen: (value: string) => boolean;

  // 每个部件一个 getter
  getRootProps: () => T["element"];
  getItemProps: (props: AccordionItemProps) => T["element"];
  getHeaderProps: (props: AccordionItemProps) => T["element"];
  getTriggerProps: (props: AccordionItemProps) => T["button"];
  getContentProps: (props: AccordionItemProps) => T["element"];
  getIndicatorProps: (props: AccordionItemProps) => T["element"];
}

两类成员:

  • 状态与操作:当前值、判定函数、命令式方法,供模板外使用;
  • props-getter:每个部件一个,名字固定为 get + 部件名的大驼峰 + Props。

部件本身有身份的(条目、标签页面板等),getter 接收一个描述该条目的参数;单例部件的 getter 不接收参数。

getter 的内容 ​

ts
getTriggerProps: item => normalize.button({
  ...parts.trigger.attrs, // data-scope + data-part
  [ITEM_VALUE_ATTR]: item.value, // 集合导航用的身份标记
  "id": triggerId(item.value), // 由 scope 派生,同页多实例不冲突
  "type": "button",
  "aria-controls": contentId(item.value), // 与 content 的 id 对上
  "aria-expanded": isOpen(item.value) ? "true" : "false",
  "aria-disabled": item.disabled ? "true" : "false",
  "data-state": stateAttr(item), // 皮肤钩子
  "data-disabled": dataAttr(item.disabled),
  "onClick": () => { /* send({ type: 'ITEM.TOGGLE', … }) */ },
  "onKeydown": onTriggerKeydown(item),
});

五类内容,缺一不可:

类别例子消费方
结构标识data-scope data-part皮肤、测试、诊断
无障碍语义role aria-expanded aria-controls读屏
状态钩子data-state data-disabled皮肤
id 关联id aria-labelledby读屏;id 由 scope.partId() 派生
事件处理器onClick onKeydown适配器绑到 DOM

id 一律由 scope 派生而不是随意生成,这样同一页面挂载多个实例时 aria-controls 指向的仍是自身的内容。跨适配器一致性测试会抹除 id 的具体值、把 IDREF 属性翻译为 @part(...) 再比对:各适配器的关联结构必须完全一致。

归一化器 ​

normalize 按元素类型分为若干 getter:element / button / input / label / output / select / textarea / img。connect 按部件实际渲染的标签选择对应的 getter,适配器即可针对性地转换。

ts
// 无头场景:恒等归一化,原样获得属性字典
import { normalizeProps } from "@xihan-ui/core";

const api = connectAccordion(service, normalizeProps);
api.getTriggerProps({ value: "a" });
// { 'data-scope': 'accordion', 'data-part': 'trigger', 'aria-expanded': 'false', … }

Vue 适配器传入 vueNormalize(把 onKeydown 一类的键改写为 Vue 识别的形态),Web Components 适配器传入 wcNormalize。接入新框架时实现一份 NormalizeProps 即可,connect 不需修改。

无障碍属性上的刻意选择 ​

connect 中对 ARIA 的处理有一批经过明确权衡,阅读组件源码时会反复见到:

  • 使用 aria-disabled 而非 disabled,当禁用项仍需可聚焦时(手风琴的禁用条目、加载中的按钮)。原生 disabled 会使元素失去焦点,读屏用户无法 Tab 到它,也读不到不可用的原因。
  • 不输出 tabindex 的组件是刻意的。手风琴不做 roving tabindex,每个触发器都是独立的 Tab 停靠点:这是 APG 对该模式的规定,不是遗漏。
  • hidden 使用 !open || undefined,使属性在展开时彻底消失而不是 hidden="false"。
  • 纯装饰部件设置 aria-hidden="true",例如指示箭头。

直接使用 api 渲染 ​

三种宿主都可以直接使用 connect,不必经过现成组件。

Vue 中使用组合式函数:

vue
<script setup lang="ts">
import { useAccordion } from "@xihan-ui/vue";

const { api } = useAccordion({ multiple: true, defaultValue: ["a"] });
</script>

<template>
  <section v-bind="api.getRootProps()">
    <!-- 自行决定渲染标签 -->
  </section>
</template>

完全脱离框架时自行创建服务:

ts
import { createCounterIdGenerator, createScope, createService, normalizeProps } from "@xihan-ui/core";
import { createVanillaRuntime } from "@xihan-ui/core/vanilla";
import { accordionMachine, connectAccordion } from "@xihan-ui/headless";

const runtime = createVanillaRuntime();
const scope = createScope(rootEl, createCounterIdGenerator());
const service = createService(accordionMachine, {
  props: () => ({ multiple: true }),
  runtime,
  scope,
});
runtime.start();

const api = connectAccordion(service, normalizeProps);
runtime.subscribe(() => {
  // 任一格子变化即重读 api,把属性写到 DOM 上
});

相关 ​

Released under The MIT License