@astryxdesign/lab 0.7.6-canary.df8928a → 0.7.6-canary.eaae8e3

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.
@@ -1 +1 @@
1
- {"version":3,"file":"CommentPin.d.ts","sourceRoot":"","sources":["../../src/CommentPin/CommentPin.tsx"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AA4BlD,qEAAqE;AACrE,MAAM,MAAM,cAAc,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAEhD,sEAAsE;AACtE,MAAM,WAAW,qBAAqB;IACpC,gEAAgE;IAChE,EAAE,EAAE,MAAM,CAAC;IACX,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAgB,SAAQ,IAAI,CAC3C,SAAS,CAAC,iBAAiB,CAAC,EAC5B,UAAU,CACX;IACC,0CAA0C;IAC1C,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;OAOG;IACH,YAAY,EAAE,SAAS,CAAC,qBAAqB,EAAE,GAAG,qBAAqB,EAAE,CAAC,CAAC;IAC3E;;;;OAIG;IACH,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yCAAyC;IACzC,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AA8ND;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,YAAY,EACZ,IAAW,EACX,WAAe,EACf,WAAe,EACf,UAAkB,EAClB,QAAgB,EAChB,UAAkB,EAClB,UAAkB,EAClB,GAAG,EACH,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,IAAI,EACR,EAAE,eAAe,GAAG,KAAK,CAAC,SAAS,CA4JnC;yBA3Ke,UAAU"}
1
+ {"version":3,"file":"CommentPin.d.ts","sourceRoot":"","sources":["../../src/CommentPin/CommentPin.tsx"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AA4BlD,qEAAqE;AACrE,MAAM,MAAM,cAAc,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAEhD,sEAAsE;AACtE,MAAM,WAAW,qBAAqB;IACpC,gEAAgE;IAChE,EAAE,EAAE,MAAM,CAAC;IACX,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAgB,SAAQ,IAAI,CAC3C,SAAS,CAAC,iBAAiB,CAAC,EAC5B,UAAU,CACX;IACC,0CAA0C;IAC1C,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IACnC;;;;OAIG;IACH,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;OAOG;IACH,YAAY,EAAE,SAAS,CAAC,qBAAqB,EAAE,GAAG,qBAAqB,EAAE,CAAC,CAAC;IAC3E;;;;OAIG;IACH,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oEAAoE;IACpE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yCAAyC;IACzC,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AA6OD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,UAAU,CAAC,EACzB,KAAK,EACL,YAAY,EACZ,IAAW,EACX,WAAe,EACf,WAAe,EACf,UAAkB,EAClB,QAAgB,EAChB,UAAkB,EAClB,UAAkB,EAClB,GAAG,EACH,MAAM,EACN,SAAS,EACT,KAAK,EACL,GAAG,IAAI,EACR,EAAE,eAAe,GAAG,KAAK,CAAC,SAAS,CAwLnC;yBAvMe,UAAU"}
@@ -88,6 +88,11 @@ const styles = {
88
88
  kSiTet: "xyd83as x1tbh5bv",
89
89
  $$css: true
90
90
  },
91
+ disabledUnselected: {
92
+ kVAM5u: "x9r1u3d xmnqt3w",
93
+ kMwMTN: "xv1l7n4 x1bhcspo",
94
+ $$css: true
95
+ },
91
96
  overflowCircle: {
92
97
  kg3NbH: "xnjsko4",
93
98
  $$css: true
@@ -269,8 +274,37 @@ export function CommentPin({
269
274
  ref: mergedRef,
270
275
  type: "button",
271
276
  "aria-label": label,
272
- "aria-pressed": isSelected,
273
- disabled: isDisabled,
277
+ "aria-pressed": isSelected
278
+ // A disabled pin keeps its tab stop and is announced as disabled through
279
+ // aria-disabled instead of the native attribute (spec:AST-085 FR1). The
280
+ // activation guards below keep it inert (FR2); focus is kept when the
281
+ // pin becomes disabled while focused (FR4), and the shared focus outline
282
+ // still paints, dimmed with the rest of the pin (FR6).
283
+ ,
284
+ "aria-disabled": isDisabled ? 'true' : undefined,
285
+ onClick: e => {
286
+ if (isDisabled) {
287
+ // A natively disabled element dispatched no click at all, so an
288
+ // aria-disabled pin must not reach the caller's handler, and the
289
+ // click must not leak to a clickable ancestor (annotation surface)
290
+ // the native attribute never let it reach either (FR2).
291
+ e.preventDefault();
292
+ e.stopPropagation();
293
+ return;
294
+ }
295
+ rest.onClick?.(e);
296
+ }
297
+ // The keyboard paths to activation are Enter and Space; other keys still
298
+ // reach caller handlers, as on an enabled pin (FR2).
299
+ ,
300
+ onKeyDown: e => {
301
+ if (isDisabled && (e.key === 'Enter' || e.key === ' ')) {
302
+ e.preventDefault();
303
+ e.stopPropagation();
304
+ return;
305
+ }
306
+ rest.onKeyDown?.(e);
307
+ },
274
308
  ...mergeProps(themeProps('comment-pin', {
275
309
  size: resolvedSize,
276
310
  grouped: isGrouped ? 'grouped' : null,
@@ -279,7 +313,7 @@ export function CommentPin({
279
313
  dimmed: isDimmed ? 'dimmed' : null,
280
314
  resolved: isResolved ? 'resolved' : null,
281
315
  disabled: isDisabled ? 'disabled' : null
282
- }), focusOutlineProps.focusVisible(styles.root, sizeStyles[resolvedSize], interactionOverlayStyles.backgroundImage, (isGrouped || hasUnread) && styles.withCount, isDimmed && !isSelected && styles.dimmed, isSelected && styles.selected, isDisabled && styles.disabled, xstyle), className, style),
316
+ }), focusOutlineProps.focusVisible(styles.root, sizeStyles[resolvedSize], interactionOverlayStyles.backgroundImage, (isGrouped || hasUnread) && styles.withCount, isDimmed && !isSelected && styles.dimmed, isSelected && styles.selected, isDisabled && styles.disabled, isDisabled && !isSelected && styles.disabledUnselected, xstyle), className, style),
283
317
  children: [/*#__PURE__*/_jsx("span", {
284
318
  "aria-hidden": "true",
285
319
  ...{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/lab",
3
- "version": "0.7.6-canary.df8928a",
3
+ "version": "0.7.6-canary.eaae8e3",
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.6-canary.df8928a",
52
+ "@astryxdesign/core": "0.7.6-canary.eaae8e3",
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.6-canary.df8928a",
63
- "@astryxdesign/cli": "0.7.6-canary.df8928a",
62
+ "@astryxdesign/a11y-spec": "0.7.6-canary.eaae8e3",
63
+ "@astryxdesign/cli": "0.7.6-canary.eaae8e3",
64
64
  "@babel/cli": "^7.29.7",
65
65
  "@babel/core": "^7.29.7",
66
66
  "@babel/preset-react": "^7.29.7",
@@ -122,7 +122,7 @@ export const docs = {
122
122
  name: 'isDisabled',
123
123
  type: 'boolean',
124
124
  description:
125
- 'Disables activation with native button semantics. Useful for a pending anchor before its thread exists.',
125
+ 'Disables activation: the pin exposes aria-disabled, keeps its tab stop, and blocks every activation path. Useful for a pending anchor before its thread exists.',
126
126
  default: 'false',
127
127
  },
128
128
  {
@@ -164,7 +164,7 @@ export const docs = {
164
164
  category: 'Keyboard',
165
165
  criterion: '2.1.1 Keyboard',
166
166
  description:
167
- 'CommentPin is one native button. Tab reaches an enabled pin; Enter and Space activate it. A disabled pin uses native disabled semantics and is removed from sequential focus order.',
167
+ 'CommentPin is one native button. Tab reaches an enabled pin; Enter and Space activate it. A disabled pin stays in the tab order and is announced as disabled through aria-disabled, and every activation path is blocked.',
168
168
  },
169
169
  {
170
170
  name: 'Selected state and name',
@@ -323,7 +323,8 @@ export const docsZh = {
323
323
  isDimmed:
324
324
  '其他标记被选中时降低此标记的视觉突出度,但不禁用操作;未选中任何标记时不应降低。',
325
325
  isResolved: '所代表的线程是否都已解决;已解决的标记除非被选中否则不渲染。',
326
- isDisabled: '使用原生按钮语义禁用激活。',
326
+ isDisabled:
327
+ '通过 aria-disabled 禁用激活:保留 Tab 焦点,拦截所有激活路径。',
327
328
  onClick: '由产品拥有的激活处理函数。',
328
329
  xstyle: '根按钮的 StyleX 布局样式;由宿主决定内容上方或边距中的位置。',
329
330
  },
@@ -369,7 +370,8 @@ export const docsDense = {
369
370
  isDimmed:
370
371
  'controlled de-emphasis for non-selected siblings only while another pin is selected; remains interactive',
371
372
  isResolved: 'resolved pins render nothing unless selected',
372
- isDisabled: 'native disabled marker',
373
+ isDisabled:
374
+ 'disabled marker: aria-disabled, keeps its tab stop, blocks every activation path',
373
375
  onClick: 'host activation handler',
374
376
  xstyle: 'root layout/positioning styles; marker owns no coordinates',
375
377
  },
@@ -156,18 +156,18 @@ intentional decision. A `current` contract contains no unresolved rows.
156
156
 
157
157
  ### Representative states
158
158
 
159
- | State | Required invariant | Allowed variation |
160
- | ------------------------ | --------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
161
- | Single | first participant Avatar; no visible count; an accent dot after it when unread | identity, selected/dimmed/resolved/disabled state, placement |
162
- | Grouped, one identity | one Avatar plus total thread count | count and states |
163
- | Grouped, two or three | every distinct participant Avatar plus total thread count | ordered identities, count, and states |
164
- | Grouped, more than three | first two distinct Avatars, an ellipsis overflow circle, and total thread count | ordered identities and states |
165
- | Unread | accent dot after the total, or after the participant on a single marker | other states |
166
- | Sizes | 28, 32, or 36px tall with 22, 24, or 28px circles and the same inset on every side | every other state |
167
- | Selected | active accent treatment preserves Avatar and count legibility and wins over dimming | theme paint |
168
- | Dimmed | lower opacity identifies a non-selected sibling without disabling activation | disabled may also apply |
169
- | Resolved | renders nothing unless selected; selected, it takes the selected treatment | disabled may also apply |
170
- | Disabled | native disabled button does not activate and is removed from the sequential focus order | selected representation remains visible |
159
+ | State | Required invariant | Allowed variation |
160
+ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
161
+ | Single | first participant Avatar; no visible count; an accent dot after it when unread | identity, selected/dimmed/resolved/disabled state, placement |
162
+ | Grouped, one identity | one Avatar plus total thread count | count and states |
163
+ | Grouped, two or three | every distinct participant Avatar plus total thread count | ordered identities, count, and states |
164
+ | Grouped, more than three | first two distinct Avatars, an ellipsis overflow circle, and total thread count | ordered identities and states |
165
+ | Unread | accent dot after the total, or after the participant on a single marker | other states |
166
+ | Sizes | 28, 32, or 36px tall with 22, 24, or 28px circles and the same inset on every side | every other state |
167
+ | Selected | active accent treatment preserves Avatar and count legibility and wins over dimming | theme paint |
168
+ | Dimmed | lower opacity identifies a non-selected sibling without disabling activation | disabled may also apply |
169
+ | Resolved | renders nothing unless selected; selected, it takes the selected treatment | disabled may also apply |
170
+ | Disabled | an aria-disabled button does not activate but keeps its tab stop, so keyboard and screen-reader users reach it and hear it is unavailable; an unselected disabled pin paints GrayText color and border under forced colors | selected representation remains visible, keeping its Highlight treatment |
171
171
 
172
172
  ### Transformation and precedence order
173
173
 
@@ -203,7 +203,8 @@ durable constraints and their verification target.
203
203
  ## Accessibility contract
204
204
 
205
205
  - **AR1 — Action.** The root is one native `button type="button"` named by the
206
- required `label`; `isDisabled` uses native disabled behavior.
206
+ required `label`; `isDisabled` exposes `aria-disabled="true"` and blocks every
207
+ activation path while the pin keeps its tab stop (`spec:AST-085`).
207
208
  - **AR2 — Selection.** `isSelected` is exposed through `aria-pressed`, one solid
208
209
  2px accent outline outside the pin, and a forced-colors Highlight treatment;
209
210
  the outline hugs the pin while the shared focus-visible outline sits farther
@@ -3,6 +3,8 @@
3
3
  import {describe, expect, it, vi} from 'vitest';
4
4
  import {fireEvent, render, screen, waitFor} from '@testing-library/react';
5
5
  import userEvent from '@testing-library/user-event';
6
+ import * as stylex from '@stylexjs/stylex';
7
+ import {colorVars} from '@astryxdesign/core/theme/tokens.stylex';
6
8
  import {CommentPin} from './CommentPin';
7
9
 
8
10
  const ADA = {id: 'ada', name: 'Ada Lovelace'};
@@ -229,7 +231,8 @@ describe('CommentPin', () => {
229
231
  name: 'Resolved selected annotation',
230
232
  });
231
233
  expect(marker).toHaveAttribute('aria-pressed', 'true');
232
- expect(marker).toBeDisabled();
234
+ expect(marker).not.toBeDisabled();
235
+ expect(marker).toHaveAttribute('aria-disabled', 'true');
233
236
  expect(marker).toHaveAttribute('data-selected', 'selected');
234
237
  expect(marker).toHaveAttribute('data-resolved', 'resolved');
235
238
  expect(marker).toHaveAttribute('data-disabled', 'disabled');
@@ -313,6 +316,33 @@ describe('CommentPin', () => {
313
316
  expect(onClick).toHaveBeenCalledTimes(2);
314
317
  });
315
318
 
319
+ it('exposes aria-disabled instead of the native attribute when disabled', () => {
320
+ render(
321
+ <CommentPin label="Pending annotation" participants={[ADA]} isDisabled />,
322
+ );
323
+
324
+ const pin = screen.getByRole('button', {name: 'Pending annotation'});
325
+ expect(pin).not.toBeDisabled();
326
+ expect(pin).toHaveAttribute('aria-disabled', 'true');
327
+ });
328
+
329
+ it('keeps a disabled pin in the tab order (spec:AST-085 FR1)', async () => {
330
+ const user = userEvent.setup();
331
+ render(<CommentPin label="Open annotation" participants={[ADA]} />);
332
+ render(
333
+ <CommentPin label="Pending annotation" participants={[LIN]} isDisabled />,
334
+ );
335
+ render(<CommentPin label="Another annotation" participants={[MIRA]} />);
336
+
337
+ await user.tab();
338
+ // The first tab stop is the enabled pin; the disabled one keeps its place
339
+ // right after it rather than being skipped.
340
+ await user.tab();
341
+ expect(
342
+ screen.getByRole('button', {name: 'Pending annotation'}),
343
+ ).toHaveFocus();
344
+ });
345
+
316
346
  it('does not call the click handler when disabled', () => {
317
347
  const onClick = vi.fn();
318
348
  render(
@@ -328,6 +358,152 @@ describe('CommentPin', () => {
328
358
  expect(onClick).not.toHaveBeenCalled();
329
359
  });
330
360
 
361
+ it('blocks Enter and Space on a disabled pin (spec:AST-085 FR2)', async () => {
362
+ const user = userEvent.setup();
363
+ const onClick = vi.fn();
364
+ render(
365
+ <CommentPin
366
+ label="Pending annotation"
367
+ participants={[ADA]}
368
+ isDisabled
369
+ onClick={onClick}
370
+ />,
371
+ );
372
+
373
+ // user-event only synthesizes the button's Enter/Space click when the
374
+ // keydown was not defaultPrevented, and a native listener on the pin sees
375
+ // any click that does reach the DOM. No click at all is what proves the
376
+ // keydown guard blocked activation itself, not just the click guard.
377
+ const nativeClicks = vi.fn();
378
+ const pin = screen.getByRole('button', {name: 'Pending annotation'});
379
+ pin.addEventListener('click', nativeClicks);
380
+
381
+ pin.focus();
382
+ await user.keyboard('{Enter}');
383
+ await user.keyboard(' ');
384
+ pin.removeEventListener('click', nativeClicks);
385
+
386
+ expect(onClick).not.toHaveBeenCalled();
387
+ expect(nativeClicks).not.toHaveBeenCalled();
388
+ });
389
+
390
+ it('does not let a disabled pin activate a clickable ancestor (spec:AST-085 FR2)', () => {
391
+ const surfaceClick = vi.fn();
392
+ const onClick = vi.fn();
393
+ render(
394
+ <div role="button" onClick={surfaceClick} aria-label="Annotation surface">
395
+ <CommentPin
396
+ label="Pending annotation"
397
+ participants={[ADA]}
398
+ isDisabled
399
+ onClick={onClick}
400
+ />
401
+ </div>,
402
+ );
403
+
404
+ fireEvent.click(screen.getByRole('button', {name: 'Pending annotation'}));
405
+ expect(onClick).not.toHaveBeenCalled();
406
+ expect(surfaceClick).not.toHaveBeenCalled();
407
+ });
408
+
409
+ it('keeps focus when the pin becomes disabled while focused (spec:AST-085 FR4)', () => {
410
+ // jsdom keeps focus on a natively disabled button too, so on its own this
411
+ // cannot tell the two apart; the FR1 tests above pin the mechanism (no
412
+ // native `disabled`), which is what keeps focus here. A real-browser
413
+ // probe would be needed to prove FR4 independently.
414
+ const {rerender} = render(
415
+ <CommentPin label="Open annotation" participants={[ADA]} />,
416
+ );
417
+
418
+ const pin = screen.getByRole('button', {name: 'Open annotation'});
419
+ pin.focus();
420
+ expect(pin).toHaveFocus();
421
+
422
+ rerender(
423
+ <CommentPin label="Open annotation" participants={[ADA]} isDisabled />,
424
+ );
425
+ expect(pin).toHaveFocus();
426
+ expect(pin).toHaveAttribute('aria-disabled', 'true');
427
+ });
428
+
429
+ it('paints an unselected disabled pin GrayText in forced colors (AST-085 FR6)', () => {
430
+ // The same declarations as the component's styles compile to the same
431
+ // atomic classes. Null defaults emit no atom of their own, so these are
432
+ // exactly the forced-colors arms — the shared default-mode atoms
433
+ // (borderColor: transparent) cannot make this check pass vacuously.
434
+ const atoms = (style: stylex.StyleXStyles) =>
435
+ stylex
436
+ .props(style)
437
+ .className!.split(' ')
438
+ .filter(name => name.includes('x') && !name.includes('__'));
439
+ const forced = stylex.create({
440
+ disabled: {
441
+ borderColor: {
442
+ default: null,
443
+ '@media (forced-colors: active)': 'GrayText',
444
+ },
445
+ color: {default: null, '@media (forced-colors: active)': 'GrayText'},
446
+ },
447
+ });
448
+ const forcedAtoms = atoms(forced.disabled);
449
+ expect(forcedAtoms.length).toBeGreaterThan(0);
450
+
451
+ const {rerender} = render(
452
+ <CommentPin label="Pending annotation" participants={[ADA]} isDisabled />,
453
+ );
454
+
455
+ const pin = screen.getByRole('button', {name: 'Pending annotation'});
456
+ for (const name of forcedAtoms) {
457
+ expect(pin).toHaveClass(name);
458
+ }
459
+
460
+ // A selected pin keeps its forced-colors Highlight treatment instead; the
461
+ // disabled affordance rides on the selected presentation staying intact.
462
+ rerender(
463
+ <CommentPin
464
+ label="Pending annotation"
465
+ participants={[ADA]}
466
+ isDisabled
467
+ isSelected
468
+ />,
469
+ );
470
+ for (const name of forcedAtoms) {
471
+ expect(pin).not.toHaveClass(name);
472
+ }
473
+ });
474
+
475
+ it('keeps the root text colour on an unselected disabled pin outside forced colors', () => {
476
+ // disabledUnselected must repeat the root's colour token as its default:
477
+ // a null default would replace it under StyleX's per-property merge and
478
+ // the count would fall back to the raw theme text colour.
479
+ const atoms = (style: stylex.StyleXStyles) =>
480
+ stylex
481
+ .props(style)
482
+ .className!.split(' ')
483
+ .filter(name => name.includes('x') && !name.includes('__'));
484
+ const rootColor = stylex.create({
485
+ color: {color: colorVars['--color-text-secondary']},
486
+ });
487
+ const colorAtoms = atoms(rootColor.color);
488
+ expect(colorAtoms.length).toBeGreaterThan(0);
489
+
490
+ render(
491
+ <CommentPin
492
+ label="Open 3 grouped annotations"
493
+ participants={[ADA]}
494
+ threadCount={3}
495
+ isDisabled
496
+ />,
497
+ );
498
+
499
+ const pin = screen.getByRole('button', {
500
+ name: 'Open 3 grouped annotations',
501
+ });
502
+ for (const name of colorAtoms) {
503
+ expect(pin).toHaveClass(name);
504
+ }
505
+ });
506
+
331
507
  it('forwards the ref and neutral button props', () => {
332
508
  const ref = {current: null as HTMLButtonElement | null};
333
509
  render(
@@ -206,6 +206,21 @@ const styles = stylex.create({
206
206
  '@media (forced-colors: active)': 1,
207
207
  },
208
208
  },
209
+ // An aria-disabled button gets no system disabled paint in forced colors;
210
+ // GrayText is the platform's disabled affordance there. Defaults repeat the
211
+ // base values, since StyleX merges per property. Only unselected pins take
212
+ // it — the selected treatment keeps its forced-colors Highlight, so a
213
+ // selected disabled pin stays prominent (ORD4).
214
+ disabledUnselected: {
215
+ borderColor: {
216
+ default: 'transparent',
217
+ '@media (forced-colors: active)': 'GrayText',
218
+ },
219
+ color: {
220
+ default: colorVars['--color-text-secondary'],
221
+ '@media (forced-colors: active)': 'GrayText',
222
+ },
223
+ },
209
224
  avatarRail: {
210
225
  display: 'inline-flex',
211
226
  alignItems: 'center',
@@ -463,7 +478,34 @@ export function CommentPin({
463
478
  type="button"
464
479
  aria-label={label}
465
480
  aria-pressed={isSelected}
466
- disabled={isDisabled}
481
+ // A disabled pin keeps its tab stop and is announced as disabled through
482
+ // aria-disabled instead of the native attribute (spec:AST-085 FR1). The
483
+ // activation guards below keep it inert (FR2); focus is kept when the
484
+ // pin becomes disabled while focused (FR4), and the shared focus outline
485
+ // still paints, dimmed with the rest of the pin (FR6).
486
+ aria-disabled={isDisabled ? 'true' : undefined}
487
+ onClick={e => {
488
+ if (isDisabled) {
489
+ // A natively disabled element dispatched no click at all, so an
490
+ // aria-disabled pin must not reach the caller's handler, and the
491
+ // click must not leak to a clickable ancestor (annotation surface)
492
+ // the native attribute never let it reach either (FR2).
493
+ e.preventDefault();
494
+ e.stopPropagation();
495
+ return;
496
+ }
497
+ rest.onClick?.(e);
498
+ }}
499
+ // The keyboard paths to activation are Enter and Space; other keys still
500
+ // reach caller handlers, as on an enabled pin (FR2).
501
+ onKeyDown={e => {
502
+ if (isDisabled && (e.key === 'Enter' || e.key === ' ')) {
503
+ e.preventDefault();
504
+ e.stopPropagation();
505
+ return;
506
+ }
507
+ rest.onKeyDown?.(e);
508
+ }}
467
509
  {...mergeProps(
468
510
  themeProps('comment-pin', {
469
511
  size: resolvedSize,
@@ -482,6 +524,7 @@ export function CommentPin({
482
524
  isDimmed && !isSelected && styles.dimmed,
483
525
  isSelected && styles.selected,
484
526
  isDisabled && styles.disabled,
527
+ isDisabled && !isSelected && styles.disabledUnselected,
485
528
  xstyle,
486
529
  ),
487
530
  className,