@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.
- package/dist/CommentPin/CommentPin.d.ts.map +1 -1
- package/dist/CommentPin/CommentPin.js +37 -3
- package/package.json +4 -4
- package/src/CommentPin/CommentPin.doc.mjs +6 -4
- package/src/CommentPin/CommentPin.spec.md +14 -13
- package/src/CommentPin/CommentPin.test.tsx +177 -1
- package/src/CommentPin/CommentPin.tsx +44 -1
|
@@ -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;
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
63
|
-
"@astryxdesign/cli": "0.7.6-canary.
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
160
|
-
| ------------------------ |
|
|
161
|
-
| Single | first participant Avatar; no visible count; an accent dot after it when unread
|
|
162
|
-
| Grouped, one identity | one Avatar plus total thread count
|
|
163
|
-
| Grouped, two or three | every distinct participant Avatar plus total thread count
|
|
164
|
-
| Grouped, more than three | first two distinct Avatars, an ellipsis overflow circle, and total thread count
|
|
165
|
-
| Unread | accent dot after the total, or after the participant on a single marker
|
|
166
|
-
| Sizes | 28, 32, or 36px tall with 22, 24, or 28px circles and the same inset on every side
|
|
167
|
-
| Selected | active accent treatment preserves Avatar and count legibility and wins over dimming
|
|
168
|
-
| Dimmed | lower opacity identifies a non-selected sibling without disabling activation
|
|
169
|
-
| Resolved | renders nothing unless selected; selected, it takes the selected treatment
|
|
170
|
-
| Disabled |
|
|
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`
|
|
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
|
|
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,
|