@astryxdesign/lab 0.7.3-canary.b74cf21 → 0.7.3-canary.ca48bb7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,8 +14,9 @@ export interface ChatTypingIndicatorProps extends BaseProps<HTMLDivElement> {
14
14
  * Animated three-dot typing hint with a name-aware label.
15
15
  *
16
16
  * The dots bounce with staggered delays (disabled under
17
- * prefers-reduced-motion) and the label is announced politely to
18
- * screen readers via role="status".
17
+ * prefers-reduced-motion). Each new label is announced politely to screen
18
+ * readers through the shared announcement channel, unless an enclosing live
19
+ * container such as a chat log delivers it.
19
20
  *
20
21
  * @example
21
22
  * ```
@@ -1 +1 @@
1
- {"version":3,"file":"ChatTypingIndicator.d.ts","sourceRoot":"","sources":["../../src/Chat/ChatTypingIndicator.tsx"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AAMlD,MAAM,WAAW,wBAAyB,SAAQ,SAAS,CAAC,cAAc,CAAC;IACzE,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAEhC;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB;AA0FD;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,KAAK,EACL,MAAM,EACN,SAAS,EACT,KAAK,EAAE,SAAS,EAChB,aAAa,EAAE,MAAM,EACrB,GAAG,GACJ,EAAE,wBAAwB,+BA4B1B;yBAnCe,mBAAmB"}
1
+ {"version":3,"file":"ChatTypingIndicator.d.ts","sourceRoot":"","sources":["../../src/Chat/ChatTypingIndicator.tsx"],"names":[],"mappings":"AAiCA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AAOlD,MAAM,WAAW,wBAAyB,SAAQ,SAAS,CAAC,cAAc,CAAC;IACzE,wCAAwC;IACxC,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAEhC;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;CAClB;AAkGD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,KAAK,EACL,MAAM,EACN,SAAS,EACT,KAAK,EAAE,SAAS,EAChB,aAAa,EAAE,MAAM,EACrB,GAAG,GACJ,EAAE,wBAAwB,+BAkD1B;yBAzDe,mBAAmB"}
@@ -10,19 +10,22 @@
10
10
  *
11
11
  * Three staggered bouncing dots (stylex.keyframes, reduced-motion safe)
12
12
  * with a grammar-aware label: "Ana is typing…", "Ana and Ben are typing…",
13
- * or "Ana and 2 others are typing…". Announced politely via role="status".
14
- * Renders dots only when no names are provided.
13
+ * or "Ana and 2 others are typing…". The label is announced once per change
14
+ * through the shared polite channel (`useAnnounce`), or left to an enclosing
15
+ * live container such as a chat log; the element itself carries no live role
16
+ * (spec:AST-086 FR2, FR4). Renders dots only when no names are provided.
15
17
  *
16
18
  * SYNC: When modified, update these files to stay in sync:
17
19
  * - /packages/lab/src/Chat/index.ts (exports)
18
20
  * - /packages/lab/src/Chat/ChatTypingIndicator.doc.mjs
19
21
  * - /apps/storybook/stories/ChatAdditions.stories.tsx (examples)
20
22
  */
21
- import { useMemo } from 'react';
23
+ import { useEffect, useMemo, useRef } from 'react';
22
24
  import * as stylex from '@stylexjs/stylex';
23
25
  import '@astryxdesign/core/theme/tokens.stylex';
24
26
  import { colorVars, spacingVars, radiusVars, durationVars, typographyVars, typeScaleVars } from '@astryxdesign/core/theme/tokens.stylex';
25
- import { mergeProps } from '@astryxdesign/core/utils';
27
+ import { mergeProps, mergeRefs } from '@astryxdesign/core/utils';
28
+ import { useAnnounce } from '@astryxdesign/core/hooks';
26
29
  import { themeProps } from '@astryxdesign/core/utils';
27
30
  import { useLocale, useTranslator } from '@astryxdesign/core/i18n';
28
31
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
@@ -67,6 +70,13 @@ function typingLabel(names, t, listFormat) {
67
70
  });
68
71
  }
69
72
 
73
+ /**
74
+ * An enclosing live container (a chat transcript's role="log", or any element
75
+ * the page made live) already delivers content added inside it, so the
76
+ * indicator must not announce the same words again (spec:AST-086 FR4).
77
+ */
78
+ const LIVE_CONTAINER_SELECTOR = '[role="log"], [role="status"], [role="alert"], [aria-live="polite"], [aria-live="assertive"]';
79
+
70
80
  // =============================================================================
71
81
  // Component
72
82
  // =============================================================================
@@ -75,8 +85,9 @@ function typingLabel(names, t, listFormat) {
75
85
  * Animated three-dot typing hint with a name-aware label.
76
86
  *
77
87
  * The dots bounce with staggered delays (disabled under
78
- * prefers-reduced-motion) and the label is announced politely to
79
- * screen readers via role="status".
88
+ * prefers-reduced-motion). Each new label is announced politely to screen
89
+ * readers through the shared announcement channel, unless an enclosing live
90
+ * container such as a chat log delivers it.
80
91
  *
81
92
  * @example
82
93
  * ```
@@ -99,10 +110,29 @@ export function ChatTypingIndicator({
99
110
  type: 'conjunction'
100
111
  }), [locale]);
101
112
  const label = typingLabel(names, t, listFormat);
113
+ const rootRef = useRef(null);
114
+ const mergedRef = useMemo(() => mergeRefs(rootRef, ref), [ref]);
115
+ const announce = useAnnounce();
116
+ // The label last announced, so a rerender or a StrictMode effect replay does
117
+ // not repeat it; cleared when nobody is typing, so the same people starting
118
+ // again is a new occasion (spec:AST-086 FR6).
119
+ const announcedLabelRef = useRef(null);
120
+ useEffect(() => {
121
+ if (label == null) {
122
+ announcedLabelRef.current = null;
123
+ return;
124
+ }
125
+ if (announcedLabelRef.current === label) {
126
+ return;
127
+ }
128
+ announcedLabelRef.current = label;
129
+ const isInsideLiveContainer = rootRef.current?.parentElement?.closest(LIVE_CONTAINER_SELECTOR) != null;
130
+ if (!isInsideLiveContainer) {
131
+ announce(label, 'polite');
132
+ }
133
+ }, [announce, label]);
102
134
  return /*#__PURE__*/_jsxs("div", {
103
- ref: ref,
104
- role: "status",
105
- "aria-live": "polite",
135
+ ref: mergedRef,
106
136
  "data-testid": testId,
107
137
  ...mergeProps(themeProps('chat-typing-indicator'), stylex.props(styles.root, xstyle), className, styleProp),
108
138
  children: [/*#__PURE__*/_jsxs("span", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/lab",
3
- "version": "0.7.3-canary.b74cf21",
3
+ "version": "0.7.3-canary.ca48bb7",
4
4
  "description": "Experimental Astryx components — published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
5
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "peerDependencies": {
51
51
  "@stylexjs/stylex": ">=0.10.0",
52
- "@astryxdesign/core": "0.7.3-canary.b74cf21",
52
+ "@astryxdesign/core": "0.7.3-canary.ca48bb7",
53
53
  "react": ">=19.0.0",
54
54
  "react-dom": ">=19.0.0"
55
55
  },
@@ -59,8 +59,8 @@
59
59
  "d3-shape": "^3.2.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@astryxdesign/a11y-spec": "0.7.3-canary.b74cf21",
63
- "@astryxdesign/cli": "0.7.3-canary.b74cf21",
62
+ "@astryxdesign/a11y-spec": "0.7.3-canary.ca48bb7",
63
+ "@astryxdesign/cli": "0.7.3-canary.ca48bb7",
64
64
  "@babel/cli": "^7.29.7",
65
65
  "@babel/core": "^7.29.7",
66
66
  "@babel/preset-react": "^7.29.7",
@@ -8,7 +8,7 @@ export const docs = {
8
8
  group: 'Chat',
9
9
  category: 'Chat',
10
10
  displayName: 'Chat Typing Indicator',
11
- description: 'Animated three-dot typing hint with a localized, grammar-aware label: "Ana is typing...", "Ana and Ben are typing...", or "Ana and 2 others are typing...". Dots bounce with staggered stylex.keyframes delays, disabled under prefers-reduced-motion; the label is announced politely via role="status".',
11
+ description: 'Animated three-dot typing hint with a localized, grammar-aware label: "Ana is typing...", "Ana and Ben are typing...", or "Ana and 2 others are typing...". Dots bounce with staggered stylex.keyframes delays, disabled under prefers-reduced-motion. Each new label is announced once, politely, through the shared announcement channel (useAnnounce); inside a chat log the log announces it instead. The indicator itself carries no live role, so it can mount conditionally without being skipped or heard twice.',
12
12
  props: [
13
13
  {
14
14
  name: 'names',
@@ -21,7 +21,7 @@ export const docs = {
21
21
  export const docsZh = {
22
22
  name: 'ChatTypingIndicator',
23
23
  displayName: 'Chat Typing Indicator',
24
- description: '带语法感知标签的三点跳动输入提示:"Ana is typing..."、"Ana and Ben are typing..." 或 "Ana and 2 others are typing..."。圆点用 stylex.keyframes 交错跳动,prefers-reduced-motion 下禁用;标签通过 role="status" 礼貌播报。',
24
+ description: '带语法感知标签的三点跳动输入提示:"Ana is typing..."、"Ana and Ben are typing..." 或 "Ana and 2 others are typing..."。圆点用 stylex.keyframes 交错跳动,prefers-reduced-motion 下禁用。每个新标签通过共享播报通道(useAnnounce)礼貌播报一次;位于聊天记录(role="log")内时由记录负责播报。指示器本身不带 live 角色,因此可按条件挂载,既不会被跳过,也不会重复播报。',
25
25
  propDescriptions: {
26
26
  names: '正在输入的人名。一个名字渲染 "is typing",两个渲染双名,更多折叠为 "and N others"。语句取自 i18n 词条,人名由 Intl.ListFormat 按当前语言拼接,因此措辞与连接词随语言而变。省略或为空时仅渲染动画圆点。',
27
27
  },
@@ -30,7 +30,7 @@ export const docsZh = {
30
30
  export const docsDense = {
31
31
  name: 'ChatTypingIndicator',
32
32
  displayName: 'Chat Typing Indicator',
33
- description: 'bouncing three-dot typing hint; label grammar 1/2/N names; reduced-motion safe keyframes; role=status polite announce; dots-only when names empty',
33
+ description: 'bouncing three-dot typing hint; label grammar 1/2/N names; reduced-motion safe keyframes; announces each new label once via useAnnounce (polite), or leaves it to an enclosing role=log; no live role on the element; dots-only when names empty',
34
34
  propDescriptions: {
35
35
  names:
36
36
  "string[]; 1='X is typing', 2='X and Y', 3+='X and N others'; phrases from i18n catalog, names joined by Intl.ListFormat; empty=dots only",
@@ -1,10 +1,21 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- import {describe, it, expect} from 'vitest';
3
+ import {StrictMode} from 'react';
4
+ import {beforeEach, describe, it, expect, vi} from 'vitest';
4
5
  import {render, screen} from '@testing-library/react';
5
6
  import {ChatTypingIndicator} from './ChatTypingIndicator';
6
7
  import {InternationalizationProvider} from '@astryxdesign/core/i18n';
7
8
 
9
+ const announce = vi.fn();
10
+ vi.mock('@astryxdesign/core/hooks', async importOriginal => ({
11
+ ...(await importOriginal<typeof import('@astryxdesign/core/hooks')>()),
12
+ useAnnounce: () => announce,
13
+ }));
14
+
15
+ beforeEach(() => {
16
+ announce.mockClear();
17
+ });
18
+
8
19
  describe('ChatTypingIndicator', () => {
9
20
  it('renders "X is typing…" for one name', () => {
10
21
  render(<ChatTypingIndicator names={['Ana']} />);
@@ -84,4 +95,58 @@ describe('ChatTypingIndicator', () => {
84
95
  'astryx-chat-typing-indicator',
85
96
  );
86
97
  });
98
+
99
+ describe('status announcement (spec:AST-086)', () => {
100
+ it('mounts with its label but carries no live role (FR2)', () => {
101
+ const {container} = render(<ChatTypingIndicator names={['Ana']} />);
102
+ const root = container.firstElementChild as HTMLElement;
103
+ expect(root).toHaveTextContent('Ana is typing…');
104
+ expect(root).not.toHaveAttribute('role');
105
+ expect(root).not.toHaveAttribute('aria-live');
106
+ expect(screen.queryByRole('status')).toBeNull();
107
+ });
108
+
109
+ it('announces a mounted label once through the polite channel, even under StrictMode (FR2, FR6)', () => {
110
+ const {rerender} = render(
111
+ <StrictMode>
112
+ <ChatTypingIndicator names={['Ana']} />
113
+ </StrictMode>,
114
+ );
115
+ rerender(
116
+ <StrictMode>
117
+ <ChatTypingIndicator names={['Ana']} />
118
+ </StrictMode>,
119
+ );
120
+ expect(announce.mock.calls).toEqual([['Ana is typing…', 'polite']]);
121
+ });
122
+
123
+ it('announces each new label, nothing on clear, and the same people again as a new occasion (FR6)', () => {
124
+ const {rerender} = render(<ChatTypingIndicator names={[]} />);
125
+ expect(announce).not.toHaveBeenCalled();
126
+ rerender(<ChatTypingIndicator names={['Ana']} />);
127
+ rerender(<ChatTypingIndicator names={['Ana', 'Ben']} />);
128
+ rerender(<ChatTypingIndicator names={[]} />);
129
+ rerender(<ChatTypingIndicator names={['Ana', 'Ben']} />);
130
+ expect(announce.mock.calls).toEqual([
131
+ ['Ana is typing…', 'polite'],
132
+ ['Ana and Ben are typing…', 'polite'],
133
+ ['Ana and Ben are typing…', 'polite'],
134
+ ]);
135
+ });
136
+
137
+ it('leaves delivery to an enclosing chat log (FR4)', () => {
138
+ const {rerender} = render(
139
+ <div role="log">
140
+ <ChatTypingIndicator names={['Ana']} />
141
+ </div>,
142
+ );
143
+ rerender(
144
+ <div role="log">
145
+ <ChatTypingIndicator names={['Ana', 'Ben']} />
146
+ </div>,
147
+ );
148
+ expect(announce).not.toHaveBeenCalled();
149
+ expect(screen.queryByRole('status')).toBeNull();
150
+ });
151
+ });
87
152
  });
@@ -10,8 +10,10 @@
10
10
  *
11
11
  * Three staggered bouncing dots (stylex.keyframes, reduced-motion safe)
12
12
  * with a grammar-aware label: "Ana is typing…", "Ana and Ben are typing…",
13
- * or "Ana and 2 others are typing…". Announced politely via role="status".
14
- * Renders dots only when no names are provided.
13
+ * or "Ana and 2 others are typing…". The label is announced once per change
14
+ * through the shared polite channel (`useAnnounce`), or left to an enclosing
15
+ * live container such as a chat log; the element itself carries no live role
16
+ * (spec:AST-086 FR2, FR4). Renders dots only when no names are provided.
15
17
  *
16
18
  * SYNC: When modified, update these files to stay in sync:
17
19
  * - /packages/lab/src/Chat/index.ts (exports)
@@ -19,7 +21,7 @@
19
21
  * - /apps/storybook/stories/ChatAdditions.stories.tsx (examples)
20
22
  */
21
23
 
22
- import {useMemo} from 'react';
24
+ import {useEffect, useMemo, useRef} from 'react';
23
25
  import * as stylex from '@stylexjs/stylex';
24
26
  import {
25
27
  colorVars,
@@ -30,7 +32,8 @@ import {
30
32
  typeScaleVars,
31
33
  } from '@astryxdesign/core/theme/tokens.stylex';
32
34
  import type {BaseProps} from '@astryxdesign/core';
33
- import {mergeProps} from '@astryxdesign/core/utils';
35
+ import {mergeProps, mergeRefs} from '@astryxdesign/core/utils';
36
+ import {useAnnounce} from '@astryxdesign/core/hooks';
34
37
  import {themeProps} from '@astryxdesign/core/utils';
35
38
  import {useLocale, useTranslator} from '@astryxdesign/core/i18n';
36
39
  import type {TranslatorFn} from '@astryxdesign/core/i18n';
@@ -132,6 +135,14 @@ function typingLabel(
132
135
  });
133
136
  }
134
137
 
138
+ /**
139
+ * An enclosing live container (a chat transcript's role="log", or any element
140
+ * the page made live) already delivers content added inside it, so the
141
+ * indicator must not announce the same words again (spec:AST-086 FR4).
142
+ */
143
+ const LIVE_CONTAINER_SELECTOR =
144
+ '[role="log"], [role="status"], [role="alert"], [aria-live="polite"], [aria-live="assertive"]';
145
+
135
146
  // =============================================================================
136
147
  // Component
137
148
  // =============================================================================
@@ -140,8 +151,9 @@ function typingLabel(
140
151
  * Animated three-dot typing hint with a name-aware label.
141
152
  *
142
153
  * The dots bounce with staggered delays (disabled under
143
- * prefers-reduced-motion) and the label is announced politely to
144
- * screen readers via role="status".
154
+ * prefers-reduced-motion). Each new label is announced politely to screen
155
+ * readers through the shared announcement channel, unless an enclosing live
156
+ * container such as a chat log delivers it.
145
157
  *
146
158
  * @example
147
159
  * ```
@@ -164,11 +176,33 @@ export function ChatTypingIndicator({
164
176
  [locale],
165
177
  );
166
178
  const label = typingLabel(names, t, listFormat);
179
+
180
+ const rootRef = useRef<HTMLDivElement>(null);
181
+ const mergedRef = useMemo(() => mergeRefs(rootRef, ref), [ref]);
182
+ const announce = useAnnounce();
183
+ // The label last announced, so a rerender or a StrictMode effect replay does
184
+ // not repeat it; cleared when nobody is typing, so the same people starting
185
+ // again is a new occasion (spec:AST-086 FR6).
186
+ const announcedLabelRef = useRef<string | null>(null);
187
+ useEffect(() => {
188
+ if (label == null) {
189
+ announcedLabelRef.current = null;
190
+ return;
191
+ }
192
+ if (announcedLabelRef.current === label) {
193
+ return;
194
+ }
195
+ announcedLabelRef.current = label;
196
+ const isInsideLiveContainer =
197
+ rootRef.current?.parentElement?.closest(LIVE_CONTAINER_SELECTOR) != null;
198
+ if (!isInsideLiveContainer) {
199
+ announce(label, 'polite');
200
+ }
201
+ }, [announce, label]);
202
+
167
203
  return (
168
204
  <div
169
- ref={ref}
170
- role="status"
171
- aria-live="polite"
205
+ ref={mergedRef}
172
206
  data-testid={testId}
173
207
  {...mergeProps(
174
208
  themeProps('chat-typing-indicator'),
@@ -4,7 +4,8 @@
4
4
 
5
5
  /** Shared jsdom and Storybook renderings for ChatTypingIndicator status states. */
6
6
 
7
- import {useState, type ReactElement} from 'react';
7
+ import {useEffect, useState, type ReactElement} from 'react';
8
+ import {useAnnounce} from '@astryxdesign/core/hooks';
8
9
  import {ChatTypingIndicator} from '../ChatTypingIndicator';
9
10
  import type {ChatTypingStatusStateId} from './ChatTypingIndicator.a11y.states';
10
11
 
@@ -27,12 +28,35 @@ function TransitionButton({
27
28
  );
28
29
  }
29
30
 
30
- function TypingHarness({startsNamed}: {startsNamed: boolean}) {
31
- const initial = startsNamed ? ['Ana'] : [];
32
- const [names, setNames] = useState<string[]>(initial);
31
+ /**
32
+ * Establishes the singleton polite channel the indicator announces through,
33
+ * then clears the seed, so the binding inspects the pre-existing empty region.
34
+ */
35
+ function PrimePoliteChannel() {
36
+ const announce = useAnnounce();
37
+ const [isReady, setIsReady] = useState(false);
38
+ useEffect(() => {
39
+ let secondFrame = 0;
40
+ announce('Preparing status channel', 'polite');
41
+ const firstFrame = requestAnimationFrame(() => {
42
+ secondFrame = requestAnimationFrame(() => {
43
+ announce('', 'polite');
44
+ setIsReady(true);
45
+ });
46
+ });
47
+ return () => {
48
+ cancelAnimationFrame(firstFrame);
49
+ cancelAnimationFrame(secondFrame);
50
+ };
51
+ }, [announce]);
52
+ return <span data-a11y-ready={String(isReady)} hidden />;
53
+ }
54
+
55
+ function TypingHarness() {
56
+ const [names, setNames] = useState<string[]>([]);
33
57
  return (
34
58
  <div>
35
- <span data-a11y-ready="true" hidden />
59
+ <PrimePoliteChannel />
36
60
  <button data-a11y-relation="focus-anchor" type="button">
37
61
  Keep focus
38
62
  </button>
@@ -53,8 +77,7 @@ export const CHAT_TYPING_STATUS_STATE_RENDERS: Record<
53
77
  ChatTypingStatusStateId,
54
78
  ChatTypingStatusStateRender
55
79
  > = {
56
- 'chat-typing-empty-to-message': () => <TypingHarness startsNamed={false} />,
57
- 'chat-typing-name-mounted': () => <TypingHarness startsNamed />,
80
+ 'chat-typing-empty-to-message': () => <TypingHarness />,
58
81
  };
59
82
 
60
83
  export {transitionTestId};
@@ -9,12 +9,11 @@
9
9
 
10
10
  import type {StatusMessageStateFacts} from '@astryxdesign/a11y-spec';
11
11
 
12
- export type ChatTypingStatusStateId =
13
- 'chat-typing-empty-to-message' | 'chat-typing-name-mounted';
12
+ export type ChatTypingStatusStateId = 'chat-typing-empty-to-message';
14
13
 
15
14
  export interface ChatTypingStatusBindingDefinition {
16
15
  readonly id: ChatTypingStatusStateId;
17
- readonly binding: 'ChatTypingIndicator';
16
+ readonly binding: 'ChatTypingIndicator announcement channel';
18
17
  readonly summary: string;
19
18
  readonly facts: StatusMessageStateFacts;
20
19
  readonly storyId: string;
@@ -28,30 +27,19 @@ const LIFECYCLE_FACTS: StatusMessageStateFacts = {
28
27
  initialMessage: '',
29
28
  message: 'Ana is typing…',
30
29
  replacement: 'Ana and Ben are typing…',
31
- semanticTransitions: ['show', 'replace', 'clear'],
30
+ semanticTransitions: ['show', 'replace'],
32
31
  };
33
32
 
34
33
  export const CHAT_TYPING_STATUS_BINDING_STATES: ReadonlyArray<ChatTypingStatusBindingDefinition> =
35
34
  [
36
35
  {
37
36
  id: 'chat-typing-empty-to-message',
38
- binding: 'ChatTypingIndicator',
37
+ binding: 'ChatTypingIndicator announcement channel',
39
38
  summary:
40
- 'the mounted empty indicator receives one name, replaces it with two, then clears',
39
+ 'one name, then two, reach the persistent polite channel as the indicator updates',
41
40
  facts: LIFECYCLE_FACTS,
42
41
  storyId: 'a11y-status-message-pattern--chat-typing-empty-to-message',
43
42
  },
44
- {
45
- id: 'chat-typing-name-mounted',
46
- binding: 'ChatTypingIndicator',
47
- summary: 'the documented named indicator is mounted with its status text',
48
- facts: {
49
- ...LIFECYCLE_FACTS,
50
- initialMessage: 'Ana is typing…',
51
- semanticTransitions: ['replace'],
52
- },
53
- storyId: 'a11y-status-message-pattern--chat-typing-name-mounted',
54
- },
55
43
  ];
56
44
 
57
45
  export type ChatTypingStatusBindingState = ChatTypingStatusBindingDefinition;
@@ -61,7 +49,19 @@ export const CHAT_TYPING_STATUS_EXCLUSIONS = [
61
49
  owner: 'ChatTypingIndicator',
62
50
  part: 'omitted names',
63
51
  reason:
64
- 'Omitted names and an empty names array render the same empty persistent status state, so one binding row covers both inputs.',
52
+ 'Omitted names and an empty names array render the same dots-only indicator and announce nothing, so one binding row covers both inputs.',
53
+ },
54
+ {
55
+ owner: 'ChatTypingIndicator',
56
+ part: 'indicator mounted with names',
57
+ reason:
58
+ 'A named indicator that mounts announces its label once through the same channel; the channel text clears on its own timer, so the mount occasion is asserted with a channel spy in ChatTypingIndicator.test.tsx rather than as a bound initial state.',
59
+ },
60
+ {
61
+ owner: 'useAnnounce',
62
+ part: 'clearing the names',
63
+ reason:
64
+ 'Clearing removes the visible label and announces nothing; the channel clears its own text after each announcement, so no clear transition is observable on it.',
65
65
  },
66
66
  {
67
67
  owner: 'ChatTypingIndicator and theme',
@@ -73,6 +73,6 @@ export const CHAT_TYPING_STATUS_EXCLUSIONS = [
73
73
  owner: 'ChatMessageList',
74
74
  part: 'transcript log announcements',
75
75
  reason:
76
- 'The parent role=log owns sequential message updates and stream batching, which are distinct from this one atomic typing status.',
76
+ 'Inside a role=log the indicator announces nothing and the log delivers the label as added content (spec:AST-086 FR4), asserted in ChatTypingIndicator.test.tsx.',
77
77
  },
78
78
  ] as const;
@@ -71,6 +71,8 @@ describe('ChatTypingIndicator status-message binding — jsdom lane', () => {
71
71
  it('records equivalent and separately owned states explicitly', () => {
72
72
  expect(CHAT_TYPING_STATUS_EXCLUSIONS.map(entry => entry.part)).toEqual([
73
73
  'omitted names',
74
+ 'indicator mounted with names',
75
+ 'clearing the names',
74
76
  'typing-dot animation and reduced motion',
75
77
  'transcript log announcements',
76
78
  ]);
@@ -402,9 +402,7 @@ describe('Comments rendering', () => {
402
402
  it('removes the fading New badge from layout when its animation ends', () => {
403
403
  render(
404
404
  <Sized>
405
- <Comments
406
- comments={[{...COMMENTS[0], isUnread: true, replies: []}]}
407
- />
405
+ <Comments comments={[{...COMMENTS[0], isUnread: true, replies: []}]} />
408
406
  </Sized>,
409
407
  );
410
408
  const badge = document.querySelector('[data-comment-unread-badge="true"]');
@@ -586,8 +584,9 @@ describe('Comments live collaboration seams', () => {
586
584
  await click(screen.getByRole('button', {name: 'Thread by Avery Kim'}));
587
585
  const typingStatus = screen
588
586
  .getByText('Maya Chen and Release Assistant are typing…')
589
- .closest('[role="status"]');
587
+ .closest('.astryx-chat-typing-indicator');
590
588
  expect(typingStatus).not.toBeNull();
589
+ expect(typingStatus).not.toHaveAttribute('role');
591
590
  expect(
592
591
  (typingStatus as HTMLElement).style.getPropertyValue(
593
592
  '--astryx-chat-typing-indicator-dots-column-width',
@@ -867,11 +866,7 @@ describe('Comments threads', () => {
867
866
  const onReact = vi.fn().mockResolvedValue(undefined);
868
867
  render(
869
868
  <Sized>
870
- <Comments
871
- comments={COMMENTS}
872
- currentUser={VIEWER}
873
- onReact={onReact}
874
- />
869
+ <Comments comments={COMMENTS} currentUser={VIEWER} onReact={onReact} />
875
870
  </Sized>,
876
871
  );
877
872
  const article = articleByAuthor('Avery Kim');
@@ -896,11 +891,7 @@ describe('Comments threads', () => {
896
891
  .mockResolvedValue(undefined);
897
892
  render(
898
893
  <Sized>
899
- <Comments
900
- comments={COMMENTS}
901
- currentUser={VIEWER}
902
- onReact={onReact}
903
- />
894
+ <Comments comments={COMMENTS} currentUser={VIEWER} onReact={onReact} />
904
895
  </Sized>,
905
896
  );
906
897
  await click(
@@ -461,7 +461,9 @@ describe('TransferListSelector', () => {
461
461
  expect(cancel).toBeEnabled();
462
462
  expect(fieldset).not.toContainElement(cancel);
463
463
  expect(apply).toBeDisabled();
464
- expect(screen.getByRole('button', {name: 'Visible fields'})).toBeDisabled();
464
+ expect(
465
+ screen.getByRole('button', {name: 'Visible fields'}),
466
+ ).toHaveAttribute('aria-disabled', 'true');
465
467
 
466
468
  view.rerender(
467
469
  <TransferListSelector