@astryxdesign/core 0.7.1 → 0.7.2-canary.3aca324
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/Button/Button.d.ts.map +1 -1
- package/dist/Button/Button.js +38 -14
- package/dist/HoverCard/useHoverCard.d.ts.map +1 -1
- package/dist/HoverCard/useHoverCard.js +10 -2
- package/dist/Markdown/plugins/frontmatter.d.ts.map +1 -1
- package/dist/Markdown/plugins/frontmatter.js +7 -3
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +22 -8
- package/dist/MultiSelector/OptionActions.a11y.chromium.spec.js +100 -2
- package/dist/MultiSelector/hooks.d.ts.map +1 -1
- package/dist/MultiSelector/hooks.js +3 -1
- package/dist/Pagination/Pagination.d.ts +8 -5
- package/dist/Pagination/Pagination.d.ts.map +1 -1
- package/dist/Pagination/Pagination.js +21 -8
- package/dist/PowerSearch/PowerSearch.js +1 -1
- package/dist/Selector/hooks.d.ts.map +1 -1
- package/dist/Selector/hooks.js +6 -2
- package/dist/Switch/Switch.d.ts +1 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +13 -1
- package/dist/Text/useTruncation.d.ts +7 -5
- package/dist/Text/useTruncation.d.ts.map +1 -1
- package/dist/Text/useTruncation.js +16 -5
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +11 -0
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +26 -0
- package/dist/astryx.css +5 -4
- package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/en.generated.js +112 -1
- package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
- package/dist/i18n/generated-locales/pseudo.generated.js +112 -1
- package/dist/utils/focusOutline.stylex.d.ts +4 -0
- package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
- package/dist/utils/focusOutline.stylex.js +4 -0
- package/dist/utils/sharedMutationObserver.d.ts +21 -0
- package/dist/utils/sharedMutationObserver.d.ts.map +1 -0
- package/dist/utils/sharedMutationObserver.js +69 -0
- package/locales/en.json +444 -0
- package/locales/pseudo.json +333 -0
- package/package.json +4 -4
- package/src/Button/Button.doc.mjs +2 -2
- package/src/Button/Button.test.tsx +155 -9
- package/src/Button/Button.tsx +39 -16
- package/src/Button/__tests__/Button.a11y.known-failures.ts +1 -62
- package/src/Button/__tests__/Button.a11y.states.ts +0 -10
- package/src/HoverCard/HoverCard.test.tsx +58 -0
- package/src/HoverCard/useHoverCard.tsx +10 -2
- package/src/IconButton/IconButton.doc.mjs +2 -2
- package/src/IconButton/IconButton.test.tsx +3 -1
- package/src/List/ListItem.doc.mjs +8 -5
- package/src/Markdown/plugins/frontmatter.test.tsx +25 -0
- package/src/Markdown/plugins/frontmatter.ts +7 -3
- package/src/MultiSelector/MultiSelector.test.tsx +159 -0
- package/src/MultiSelector/MultiSelector.tsx +40 -7
- package/src/MultiSelector/OptionActions.a11y.chromium.spec.ts +112 -2
- package/src/MultiSelector/hooks.ts +3 -1
- package/src/MultiSelector/useMultiCombobox.doc.mjs +267 -0
- package/src/Pagination/Pagination.doc.mjs +7 -7
- package/src/Pagination/Pagination.spec.md +9 -9
- package/src/Pagination/Pagination.test.tsx +242 -0
- package/src/Pagination/Pagination.tsx +27 -11
- package/src/PowerSearch/PowerSearch.test.tsx +85 -0
- package/src/PowerSearch/PowerSearch.tsx +2 -2
- package/src/Selector/hooks.ts +6 -2
- package/src/Selector/useCombobox.doc.mjs +237 -0
- package/src/Switch/Switch.test.tsx +49 -0
- package/src/Switch/Switch.tsx +11 -0
- package/src/Text/useTruncation.test.tsx +69 -0
- package/src/Text/useTruncation.ts +16 -5
- package/src/Tokenizer/Tokenizer.test.tsx +54 -1
- package/src/Tooltip/useTooltip.test.tsx +13 -0
- package/src/Tooltip/useTooltip.tsx +11 -0
- package/src/Typeahead/BaseTypeahead.tsx +26 -0
- package/src/Typeahead/Typeahead.test.tsx +260 -2
- package/src/i18n/InternationalizationProvider.doc.mjs +8 -0
- package/src/i18n/InternationalizationProvider.spec.md +252 -0
- package/src/i18n/__tests__/InternationalizationProvider.test.tsx +128 -0
- package/src/i18n/generated-locales/en.generated.ts +121 -0
- package/src/i18n/generated-locales/pseudo.generated.ts +121 -0
- package/src/i18n/useTranslator.test.tsx +37 -0
- package/src/utils/focusOutline.stylex.ts +4 -0
- package/src/utils/sharedMutationObserver.test.ts +53 -0
- package/src/utils/sharedMutationObserver.ts +78 -0
package/src/Button/Button.tsx
CHANGED
|
@@ -594,26 +594,37 @@ export function Button({
|
|
|
594
594
|
// the consumer is deliberately showing it.
|
|
595
595
|
const delaySpinner = isPending || isInterruptible;
|
|
596
596
|
const groupDisabled = buttonGroup?.isDisabled ?? false;
|
|
597
|
-
|
|
598
|
-
//
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
|
|
597
|
+
const isTrueDisabled = isDisabled || groupDisabled;
|
|
598
|
+
// Busy-only: the button can't fire again purely because an action is in
|
|
599
|
+
// flight, not because it's genuinely isDisabled/group-disabled. Per the API
|
|
600
|
+
// Conventions "Disabled vs Busy" rule, busy is visual-only (aria-busy +
|
|
601
|
+
// spinner) and must never take focusability away — the click-handler ref
|
|
602
|
+
// guard (actionInFlightRef) is what actually makes a fire-once action fire
|
|
603
|
+
// once, so the native disabled attribute adds nothing but a focus loss
|
|
604
|
+
// here. When interruptible, the loading state drives the spinner and
|
|
605
|
+
// aria-busy but not disabled at all, so clicks keep landing and can
|
|
606
|
+
// interrupt the in-flight action.
|
|
607
|
+
const isBusyOnlyDisabled =
|
|
608
|
+
!isTrueDisabled && isLoadingState && !isInterruptible;
|
|
609
|
+
const buttonDisabled = isTrueDisabled || isBusyOnlyDisabled;
|
|
605
610
|
// isIconOnly prop is the source of truth for icon-only rendering.
|
|
606
611
|
// When false (default), label is always rendered as visible text.
|
|
607
612
|
|
|
608
613
|
const LinkComponent = useLinkComponent(as);
|
|
609
614
|
|
|
610
|
-
// Render as link when href is provided and button is not
|
|
611
|
-
// Disabled links are an accessibility anti-pattern
|
|
612
|
-
|
|
615
|
+
// Render as link when href is provided and the button is not TRULY
|
|
616
|
+
// disabled. Disabled links are an accessibility anti-pattern, so a
|
|
617
|
+
// genuinely isDisabled/group-disabled button falls back to <button>.
|
|
618
|
+
// Busy-only does NOT fall back: swapping <a> for a disabled <button>
|
|
619
|
+
// mid-action would drop focus the same way the native attribute does
|
|
620
|
+
// (credit @AKnassa, #4879), which is exactly the bug this PR fixes.
|
|
621
|
+
const renderAsLink = href != null && !isTrueDisabled;
|
|
613
622
|
|
|
614
|
-
// Use aria-disabled
|
|
615
|
-
//
|
|
616
|
-
|
|
623
|
+
// Use aria-disabled (button stays focusable) when busy-only, or when a
|
|
624
|
+
// tooltip is present so keyboard users can still reach it. A genuinely
|
|
625
|
+
// isDisabled/group-disabled button (not busy) keeps native disabled.
|
|
626
|
+
const useAriaDisabled =
|
|
627
|
+
buttonDisabled && (isBusyOnlyDisabled || tooltip != null);
|
|
617
628
|
|
|
618
629
|
// Attach tooltip behavior via the hook rather than wrapping the button in a
|
|
619
630
|
// <Tooltip> element. The hook adds hover/focus triggers to the button itself,
|
|
@@ -630,6 +641,16 @@ export function Button({
|
|
|
630
641
|
// a re-click while pending starts a fresh action that interrupts the prior.
|
|
631
642
|
if (buttonDisabled || (actionInFlightRef.current && !isInterruptible)) {
|
|
632
643
|
e.preventDefault();
|
|
644
|
+
// A native `disabled` element never dispatches a click at all, so it
|
|
645
|
+
// never reaches a wrapping element's own handler either. An
|
|
646
|
+
// aria-disabled button (busy-only, or with a tooltip) is still a real,
|
|
647
|
+
// interactive element the DOM keeps dispatching clicks on —
|
|
648
|
+
// preventDefault alone only cancels the button's OWN default action
|
|
649
|
+
// (form submit, link navigation), not propagation. Without this, an
|
|
650
|
+
// unavailable Button inside a clickable row/card/backdrop still
|
|
651
|
+
// triggers that ancestor's action, which the previously native-disabled
|
|
652
|
+
// control never did.
|
|
653
|
+
e.stopPropagation();
|
|
633
654
|
return;
|
|
634
655
|
}
|
|
635
656
|
props.onClick?.(e);
|
|
@@ -669,7 +690,7 @@ export function Button({
|
|
|
669
690
|
isIconOnly && styles.iconOnly,
|
|
670
691
|
interactionOverlayStyles.backgroundImage,
|
|
671
692
|
buttonDisabled && styles.inactive,
|
|
672
|
-
|
|
693
|
+
isTrueDisabled && styles.disabled,
|
|
673
694
|
useAriaDisabled && styles.ariaDisabled,
|
|
674
695
|
renderAsLink && styles.link,
|
|
675
696
|
!buttonGroup && styles.pressable,
|
|
@@ -797,7 +818,9 @@ export function Button({
|
|
|
797
818
|
{...describedByProp}
|
|
798
819
|
{...edgeCompAttr}
|
|
799
820
|
aria-busy={isLoadingState || undefined}
|
|
800
|
-
|
|
821
|
+
aria-disabled={useAriaDisabled || undefined}
|
|
822
|
+
onClick={handleClick}
|
|
823
|
+
{...(handleKeyDown ? {onKeyDown: handleKeyDown} : null)}>
|
|
801
824
|
{buttonContent}
|
|
802
825
|
</LinkComponent>
|
|
803
826
|
);
|
|
@@ -22,65 +22,4 @@
|
|
|
22
22
|
|
|
23
23
|
import type {KnownFailure} from '@astryxdesign/a11y-spec';
|
|
24
24
|
|
|
25
|
-
export const BUTTON_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [
|
|
26
|
-
// ---- Button and IconButton go natively disabled while busy --------------
|
|
27
|
-
// One defect, one record per component.
|
|
28
|
-
// They are written out rather than generated, because a record has to name
|
|
29
|
-
// exactly one thing that is broken — a loop would make it easy to widen this
|
|
30
|
-
// later without anyone noticing.
|
|
31
|
-
{
|
|
32
|
-
expectation: 'button.focus.reachable-and-escapable',
|
|
33
|
-
binding: 'Button',
|
|
34
|
-
state: 'button-loading',
|
|
35
|
-
evidenceLayer: 'real-browser',
|
|
36
|
-
failureEquals:
|
|
37
|
-
'10 presses of Tab from the start of the document never reached the button, so a keyboard user cannot get to this action',
|
|
38
|
-
standardsReference:
|
|
39
|
-
'WCAG 2.2 2.1.1 Keyboard and 2.1.2 No Keyboard Trap (Level A)',
|
|
40
|
-
userImpact:
|
|
41
|
-
'A keyboard user who activates Save and waits is dropped to the top of the document the moment the action starts, and the button leaves the tab sequence entirely — so they cannot get back to it, to see that it is busy or to interrupt it. The next Tab restarts from the beginning of the page.',
|
|
42
|
-
reason:
|
|
43
|
-
'Button sets the native disabled attribute while a clickAction is pending. A natively disabled element is neither focusable nor a tab stop. Separate implementation work will turn this record into an unexpected pass.',
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
expectation: 'button.focus.reachable-and-escapable',
|
|
47
|
-
binding: 'IconButton',
|
|
48
|
-
state: 'icon-button-loading',
|
|
49
|
-
evidenceLayer: 'real-browser',
|
|
50
|
-
failureEquals:
|
|
51
|
-
'10 presses of Tab from the start of the document never reached the button, so a keyboard user cannot get to this action',
|
|
52
|
-
standardsReference:
|
|
53
|
-
'WCAG 2.2 2.1.1 Keyboard and 2.1.2 No Keyboard Trap (Level A)',
|
|
54
|
-
userImpact:
|
|
55
|
-
'The same drop from an icon button, where it is worse: an icon button is usually one of several in a row, so the user loses their place among them.',
|
|
56
|
-
reason:
|
|
57
|
-
'IconButton is a thin wrapper over Button and inherits the behaviour exactly.',
|
|
58
|
-
},
|
|
59
|
-
|
|
60
|
-
{
|
|
61
|
-
expectation: 'button.unavailable.inert',
|
|
62
|
-
binding: 'Button',
|
|
63
|
-
state: 'button-loading',
|
|
64
|
-
evidenceLayer: 'real-browser',
|
|
65
|
-
failureEquals:
|
|
66
|
-
'this state is declared focusable so its reason stays reachable, but it cannot take focus — so a keyboard user can neither read why it is unavailable nor reach it to confirm it refuses to act',
|
|
67
|
-
standardsReference:
|
|
68
|
-
'Current Astryx family:buttons FR3 and WAI-ARIA APG Button unavailable-state requirement; supports WCAG 2.2 4.1.2 Name, Role, Value (Level A)',
|
|
69
|
-
userImpact:
|
|
70
|
-
'The third face of the same defect: because the busy button cannot take focus, there is no way to confirm from the keyboard that it declines a second press — the user can neither reach it nor see why it is unavailable.',
|
|
71
|
-
reason:
|
|
72
|
-
'Same native disabled attribute. Recorded separately because a record names exactly one outcome, and a fix that restored focus without keeping the button inert would satisfy one of these and not the other.',
|
|
73
|
-
},
|
|
74
|
-
{
|
|
75
|
-
expectation: 'button.unavailable.inert',
|
|
76
|
-
binding: 'IconButton',
|
|
77
|
-
state: 'icon-button-loading',
|
|
78
|
-
evidenceLayer: 'real-browser',
|
|
79
|
-
failureEquals:
|
|
80
|
-
'this state is declared focusable so its reason stays reachable, but it cannot take focus — so a keyboard user can neither read why it is unavailable nor reach it to confirm it refuses to act',
|
|
81
|
-
standardsReference:
|
|
82
|
-
'Current Astryx family:buttons FR3 and WAI-ARIA APG Button unavailable-state requirement; supports WCAG 2.2 4.1.2 Name, Role, Value (Level A)',
|
|
83
|
-
userImpact: 'The same, from an icon button.',
|
|
84
|
-
reason: 'Inherited from Button, as above.',
|
|
85
|
-
},
|
|
86
|
-
];
|
|
25
|
+
export const BUTTON_KNOWN_FAILURES: ReadonlyArray<KnownFailure> = [];
|
|
@@ -177,16 +177,9 @@ export const BUTTON_BINDING_STATES = [
|
|
|
177
177
|
binding: 'Button',
|
|
178
178
|
summary:
|
|
179
179
|
'a button waiting on the action it started: the action is unavailable, and it is meant to stay focusable so the user can see that and interrupt it',
|
|
180
|
-
// `focusable: true` is the declaration, and the component does not deliver
|
|
181
|
-
// it — see the known-failure records. Declaring what the component does
|
|
182
|
-
// instead would make the expectations that catch it not-applicable, which
|
|
183
|
-
// is how a contract quietly stops noticing a defect.
|
|
184
180
|
facts: facts({operable: false, unavailable: true}),
|
|
185
181
|
visibleLabel: null,
|
|
186
182
|
storyId: 'a11y-button-pattern--button-loading',
|
|
187
|
-
declaredNotDelivered: [
|
|
188
|
-
{fact: 'focusable', owned: 'button.focus.reachable-and-escapable'},
|
|
189
|
-
],
|
|
190
183
|
},
|
|
191
184
|
|
|
192
185
|
// ---- IconButton ---------------------------------------------------------
|
|
@@ -214,9 +207,6 @@ export const BUTTON_BINDING_STATES = [
|
|
|
214
207
|
facts: facts({operable: false, unavailable: true}),
|
|
215
208
|
visibleLabel: null,
|
|
216
209
|
storyId: 'a11y-button-pattern--icon-button-loading',
|
|
217
|
-
declaredNotDelivered: [
|
|
218
|
-
{fact: 'focusable', owned: 'button.focus.reachable-and-escapable'},
|
|
219
|
-
],
|
|
220
210
|
},
|
|
221
211
|
|
|
222
212
|
// ---- ClickableCard ------------------------------------------------------
|
|
@@ -109,6 +109,64 @@ describe('useHoverCard identity', () => {
|
|
|
109
109
|
});
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
// A card closed while the pointer rests on it unmounts without a mouseleave,
|
|
113
|
+
// so the hook must not keep believing the pointer is over the card: if it
|
|
114
|
+
// does, the next open of the same trigger never closes on leave.
|
|
115
|
+
describe('useHoverCard closing while the pointer is on the card', () => {
|
|
116
|
+
function Harness() {
|
|
117
|
+
const card = useHoverCard({delay: 0, hideDelay: 0});
|
|
118
|
+
return (
|
|
119
|
+
<div>
|
|
120
|
+
<span ref={card.ref}>Trigger</span>
|
|
121
|
+
<output data-testid="state">{card.isOpen ? 'open' : 'closed'}</output>
|
|
122
|
+
{card.renderHoverCard(
|
|
123
|
+
<button type="button" onClick={card.hide}>
|
|
124
|
+
Pick
|
|
125
|
+
</button>,
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function openThenRestOnCard() {
|
|
132
|
+
const state = screen.getByTestId('state');
|
|
133
|
+
fireEvent.mouseEnter(screen.getByText('Trigger'));
|
|
134
|
+
await waitFor(() => expect(state).toHaveTextContent('open'));
|
|
135
|
+
const pick = screen.getByRole('button', {name: 'Pick', hidden: true});
|
|
136
|
+
fireEvent.mouseLeave(screen.getByText('Trigger'));
|
|
137
|
+
fireEvent.mouseEnter(pick.parentElement!);
|
|
138
|
+
return {state, pick};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function expectNextOpenClosesOnLeave(state: HTMLElement) {
|
|
142
|
+
const trigger = screen.getByText('Trigger');
|
|
143
|
+
fireEvent.mouseEnter(trigger);
|
|
144
|
+
await waitFor(() => expect(state).toHaveTextContent('open'));
|
|
145
|
+
fireEvent.mouseLeave(trigger);
|
|
146
|
+
await waitFor(() => expect(state).toHaveTextContent('closed'));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
it('closes the next open on leave after hide() from inside the card', async () => {
|
|
150
|
+
render(<Harness />);
|
|
151
|
+
const {state, pick} = await openThenRestOnCard();
|
|
152
|
+
|
|
153
|
+
fireEvent.click(pick);
|
|
154
|
+
await waitFor(() => expect(state).toHaveTextContent('closed'));
|
|
155
|
+
|
|
156
|
+
await expectNextOpenClosesOnLeave(state);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('closes the next open on leave after Escape dismisses the card', async () => {
|
|
160
|
+
render(<Harness />);
|
|
161
|
+
const {state} = await openThenRestOnCard();
|
|
162
|
+
|
|
163
|
+
fireEvent.keyDown(document.body, {key: 'Escape'});
|
|
164
|
+
await waitFor(() => expect(state).toHaveTextContent('closed'));
|
|
165
|
+
|
|
166
|
+
await expectNextOpenClosesOnLeave(state);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
112
170
|
describe('HoverCard', () => {
|
|
113
171
|
it('renders trigger element', () => {
|
|
114
172
|
render(
|
|
@@ -345,6 +345,14 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
|
|
|
345
345
|
hideLayer();
|
|
346
346
|
}, [clearTimeouts, hideLayer]);
|
|
347
347
|
|
|
348
|
+
// The imperative hide. A card closed while the pointer rests on it unmounts
|
|
349
|
+
// without a mouseleave, so the content-hover latch is cleared here; left set,
|
|
350
|
+
// it would keep the trigger's next hover-open from closing on leave.
|
|
351
|
+
const hide = useCallback(() => {
|
|
352
|
+
isHoveringContentRef.current = false;
|
|
353
|
+
hideLayer();
|
|
354
|
+
}, [hideLayer]);
|
|
355
|
+
|
|
348
356
|
const {
|
|
349
357
|
isTouchPointerRef,
|
|
350
358
|
isTouchInteraction,
|
|
@@ -508,7 +516,7 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
|
|
|
508
516
|
? null
|
|
509
517
|
: document.getElementById(layerId);
|
|
510
518
|
const hadFocus = card?.contains(document.activeElement) ?? false;
|
|
511
|
-
|
|
519
|
+
hide();
|
|
512
520
|
if (hadFocus) {
|
|
513
521
|
isEscapeDismissingRef.current = true;
|
|
514
522
|
triggerRef.current?.focus();
|
|
@@ -720,6 +728,6 @@ export function useHoverCard(options: HoverCardOptions = {}): HoverCardReturn {
|
|
|
720
728
|
isOpen: isLayerOpen,
|
|
721
729
|
renderHoverCard,
|
|
722
730
|
show: showLayer,
|
|
723
|
-
hide
|
|
731
|
+
hide,
|
|
724
732
|
};
|
|
725
733
|
}
|
|
@@ -46,7 +46,7 @@ export const docs = {
|
|
|
46
46
|
{
|
|
47
47
|
name: 'isLoading',
|
|
48
48
|
type: 'boolean',
|
|
49
|
-
description: 'Shows a loading spinner and
|
|
49
|
+
description: 'Shows a loading spinner and blocks re-activation while staying focusable (aria-busy + aria-disabled, not native disabled).',
|
|
50
50
|
default: 'false',
|
|
51
51
|
},
|
|
52
52
|
{
|
|
@@ -141,7 +141,7 @@ export const docsDense = {
|
|
|
141
141
|
variant: 'visual style variant',
|
|
142
142
|
size: 'size variant',
|
|
143
143
|
elevation: 'resting shadow depth: none|low|med|high; raise for a floating action button (FAB)',
|
|
144
|
-
isLoading: 'shows loading spinner
|
|
144
|
+
isLoading: 'shows loading spinner, blocks re-activation but stays focusable (aria-disabled, not native disabled)',
|
|
145
145
|
isDisabled: 'disables button',
|
|
146
146
|
tooltip: 'tooltip text shown on hover',
|
|
147
147
|
onClick: 'standard click handler',
|
|
@@ -124,7 +124,9 @@ describe('IconButton', () => {
|
|
|
124
124
|
it('shows loading state', () => {
|
|
125
125
|
render(<IconButton label="Save" icon={<span>💾</span>} isLoading />);
|
|
126
126
|
const button = screen.getByRole('button');
|
|
127
|
-
|
|
127
|
+
// Busy is aria-disabled (focusable), not native disabled — see #4871.
|
|
128
|
+
expect(button).not.toBeDisabled();
|
|
129
|
+
expect(button).toHaveAttribute('aria-disabled', 'true');
|
|
128
130
|
});
|
|
129
131
|
|
|
130
132
|
it('forwards ref correctly', () => {
|
|
@@ -12,8 +12,9 @@ export const docs = {
|
|
|
12
12
|
props: [
|
|
13
13
|
{
|
|
14
14
|
name: 'label',
|
|
15
|
-
type: '
|
|
16
|
-
description:
|
|
15
|
+
type: 'ReactNode',
|
|
16
|
+
description:
|
|
17
|
+
'Primary content. A plain string gets single-line truncation automatically; a ReactNode renders as given, so child components control their own text behavior.',
|
|
17
18
|
required: true,
|
|
18
19
|
},
|
|
19
20
|
{
|
|
@@ -123,8 +124,9 @@ export const docsZh = {
|
|
|
123
124
|
props: [
|
|
124
125
|
{
|
|
125
126
|
name: 'label',
|
|
126
|
-
type: '
|
|
127
|
-
description:
|
|
127
|
+
type: 'ReactNode',
|
|
128
|
+
description:
|
|
129
|
+
'主要内容。纯字符串会自动应用单行截断;ReactNode 按原样渲染,由子组件自行控制文本行为。',
|
|
128
130
|
required: true,
|
|
129
131
|
},
|
|
130
132
|
{
|
|
@@ -205,7 +207,8 @@ export const docsDense = {
|
|
|
205
207
|
description:
|
|
206
208
|
'List item w/ label, description, start/end content slots, interactive patterns.',
|
|
207
209
|
propDescriptions: {
|
|
208
|
-
label:
|
|
210
|
+
label:
|
|
211
|
+
'Primary content. String auto-truncates; ReactNode renders as given.',
|
|
209
212
|
description: 'Secondary text below label.',
|
|
210
213
|
startContent: 'Content before label area (e.g. icon, avatar).',
|
|
211
214
|
endContent: 'Content after label area (e.g. badge, chevron).',
|
|
@@ -117,6 +117,31 @@ describe('createMarkdownFrontmatter', () => {
|
|
|
117
117
|
).not.toThrow();
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
it('accepts the decoded fields record as metadata (identity parse)', () => {
|
|
121
|
+
const fields = createMarkdownFrontmatter({
|
|
122
|
+
name: 'fields',
|
|
123
|
+
parse: record => record,
|
|
124
|
+
});
|
|
125
|
+
const source = '---\ntitle: Release notes\n__proto__: kept\n---\n# Shipped';
|
|
126
|
+
|
|
127
|
+
const parsed = fields.parse(source);
|
|
128
|
+
expect(parsed).toEqual({
|
|
129
|
+
status: 'match',
|
|
130
|
+
contentStart: source.indexOf('# Shipped'),
|
|
131
|
+
metadata: {title: 'Release notes', ['__proto__']: 'kept'},
|
|
132
|
+
});
|
|
133
|
+
if (parsed.status !== 'match') {
|
|
134
|
+
throw new Error('expected a frontmatter match');
|
|
135
|
+
}
|
|
136
|
+
// A field named like a prototype key stays an ordinary own field.
|
|
137
|
+
expect(Object.hasOwn(parsed.metadata, '__proto__')).toBe(true);
|
|
138
|
+
expect(Object.getPrototypeOf(parsed.metadata)).toBe(Object.prototype);
|
|
139
|
+
|
|
140
|
+
const root = parseMarkdownAst(source, {plugins: [fields.plugin]});
|
|
141
|
+
expect(fields.getMetadata(root)).toEqual(parsed.metadata);
|
|
142
|
+
expect(JSON.stringify(root)).not.toContain('title: Release notes');
|
|
143
|
+
});
|
|
144
|
+
|
|
120
145
|
it('returns a transform plugin that composes in the ordered pipeline', () => {
|
|
121
146
|
expectTypeOf<MarkdownTransform>().toBeFunction();
|
|
122
147
|
const root = parseMarkdownAst(SOURCE, {plugins: [documentMetadata.plugin]});
|
|
@@ -104,18 +104,22 @@ function readFrontmatter(source: string):
|
|
|
104
104
|
if (close == null) {
|
|
105
105
|
return {status: 'defer'};
|
|
106
106
|
}
|
|
107
|
-
const
|
|
107
|
+
const entries = new Map<string, string>();
|
|
108
108
|
for (const line of body.slice(0, close.index).split(/\r?\n/)) {
|
|
109
109
|
if (line.trim() === '') {
|
|
110
110
|
continue;
|
|
111
111
|
}
|
|
112
112
|
const separator = line.indexOf(':');
|
|
113
113
|
const key = line.slice(0, separator).trim();
|
|
114
|
-
if (separator <= 0 || key === '' ||
|
|
114
|
+
if (separator <= 0 || key === '' || entries.has(key)) {
|
|
115
115
|
return {status: 'none'};
|
|
116
116
|
}
|
|
117
|
-
|
|
117
|
+
entries.set(key, line.slice(separator + 1).trim());
|
|
118
118
|
}
|
|
119
|
+
// A plain object, so a parser that returns the record unchanged passes the
|
|
120
|
+
// JSON-like metadata check. `fromEntries` defines every key as an own data
|
|
121
|
+
// property, so a field named `__proto__` stays a field.
|
|
122
|
+
const fields: Record<string, string> = Object.fromEntries(entries);
|
|
119
123
|
let end = openingLength + close.index + close[0].length;
|
|
120
124
|
if (source.startsWith('\r\n', end)) {
|
|
121
125
|
end += 2;
|
|
@@ -35,6 +35,7 @@ import {generateThemeCSS} from '../theme/generateThemeRules';
|
|
|
35
35
|
import {selectorPresentationStyles} from '../Selector/selectorPresentation.stylex';
|
|
36
36
|
import {groupStyles} from '../InputGroup/groupStyles';
|
|
37
37
|
import {InputGroup, InputGroupText} from '../InputGroup';
|
|
38
|
+
import {focusVars} from '../theme/tokens.stylex';
|
|
38
39
|
|
|
39
40
|
function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
|
|
40
41
|
const {prose, component} = generateThemeCSS(theme);
|
|
@@ -4083,6 +4084,164 @@ describe('MultiSelector option actions (grid)', () => {
|
|
|
4083
4084
|
expect(onChange).not.toHaveBeenCalled();
|
|
4084
4085
|
});
|
|
4085
4086
|
|
|
4087
|
+
it('draws the standard focus ring on the active action cell, from the focus tokens', async () => {
|
|
4088
|
+
// jsdom resolves no custom property, so the ring is read from StyleX
|
|
4089
|
+
// classes: a class name hashes one property and its value, and the same
|
|
4090
|
+
// declaration yields the same class wherever it is written.
|
|
4091
|
+
const ring = stylex.create({
|
|
4092
|
+
active: {
|
|
4093
|
+
outlineWidth: focusVars['--focus-outline-width'],
|
|
4094
|
+
outlineStyle: focusVars['--focus-outline-style'],
|
|
4095
|
+
outlineColor: focusVars['--focus-outline-color'],
|
|
4096
|
+
outlineOffset: focusVars['--focus-outline-offset'],
|
|
4097
|
+
},
|
|
4098
|
+
});
|
|
4099
|
+
const ringClasses = stylex
|
|
4100
|
+
.props(ring.active)
|
|
4101
|
+
.className!.split(' ')
|
|
4102
|
+
.filter(token => /^x[a-z0-9]+$/.test(token));
|
|
4103
|
+
expect(ringClasses).toHaveLength(4);
|
|
4104
|
+
|
|
4105
|
+
const user = userEvent.setup();
|
|
4106
|
+
render(
|
|
4107
|
+
<MultiSelector
|
|
4108
|
+
label="Labels"
|
|
4109
|
+
options={OPTIONS}
|
|
4110
|
+
value={[]}
|
|
4111
|
+
onChange={() => {}}
|
|
4112
|
+
/>,
|
|
4113
|
+
);
|
|
4114
|
+
const trigger = screen.getByRole('combobox', {name: 'Labels'});
|
|
4115
|
+
await user.click(trigger);
|
|
4116
|
+
const bug = screen.getByRole('row', {name: 'Bug', ...h});
|
|
4117
|
+
const actionCell = bug.children[1] as HTMLElement;
|
|
4118
|
+
for (const token of ringClasses) {
|
|
4119
|
+
expect(actionCell).not.toHaveClass(token);
|
|
4120
|
+
}
|
|
4121
|
+
fireEvent.keyDown(trigger, {key: 'ArrowRight'});
|
|
4122
|
+
expect(trigger).toHaveAttribute('aria-activedescendant', actionCell.id);
|
|
4123
|
+
for (const token of ringClasses) {
|
|
4124
|
+
expect(actionCell).toHaveClass(token);
|
|
4125
|
+
}
|
|
4126
|
+
});
|
|
4127
|
+
|
|
4128
|
+
it('draws no focus ring on an action cell highlighted after pointer input, until a key moves the highlight', async () => {
|
|
4129
|
+
const ring = stylex.create({
|
|
4130
|
+
active: {
|
|
4131
|
+
outlineWidth: focusVars['--focus-outline-width'],
|
|
4132
|
+
outlineStyle: focusVars['--focus-outline-style'],
|
|
4133
|
+
outlineColor: focusVars['--focus-outline-color'],
|
|
4134
|
+
outlineOffset: focusVars['--focus-outline-offset'],
|
|
4135
|
+
},
|
|
4136
|
+
});
|
|
4137
|
+
const ringClasses = stylex
|
|
4138
|
+
.props(ring.active)
|
|
4139
|
+
.className!.split(' ')
|
|
4140
|
+
.filter(token => /^x[a-z0-9]+$/.test(token));
|
|
4141
|
+
expect(ringClasses).toHaveLength(4);
|
|
4142
|
+
|
|
4143
|
+
const user = userEvent.setup();
|
|
4144
|
+
render(
|
|
4145
|
+
<MultiSelector
|
|
4146
|
+
label="Labels"
|
|
4147
|
+
options={OPTIONS}
|
|
4148
|
+
value={[]}
|
|
4149
|
+
onChange={() => {}}
|
|
4150
|
+
/>,
|
|
4151
|
+
);
|
|
4152
|
+
const trigger = screen.getByRole('combobox', {name: 'Labels'});
|
|
4153
|
+
await user.click(trigger);
|
|
4154
|
+
const bug = screen.getByRole('row', {name: 'Bug', ...h});
|
|
4155
|
+
const actionCell = bug.children[1] as HTMLElement;
|
|
4156
|
+
fireEvent.mouseEnter(actionCell);
|
|
4157
|
+
// The hover moved the highlight onto the action cell, without the ring.
|
|
4158
|
+
expect(trigger).toHaveAttribute('aria-activedescendant', actionCell.id);
|
|
4159
|
+
for (const token of ringClasses) {
|
|
4160
|
+
expect(actionCell).not.toHaveClass(token);
|
|
4161
|
+
}
|
|
4162
|
+
// Keyboard navigation back onto the cell draws it.
|
|
4163
|
+
fireEvent.keyDown(trigger, {key: 'ArrowLeft'});
|
|
4164
|
+
fireEvent.keyDown(trigger, {key: 'ArrowRight'});
|
|
4165
|
+
expect(trigger).toHaveAttribute('aria-activedescendant', actionCell.id);
|
|
4166
|
+
for (const token of ringClasses) {
|
|
4167
|
+
expect(actionCell).toHaveClass(token);
|
|
4168
|
+
}
|
|
4169
|
+
});
|
|
4170
|
+
|
|
4171
|
+
describe('ring under mixed input', () => {
|
|
4172
|
+
const ringClasses = () => {
|
|
4173
|
+
const ring = stylex.create({
|
|
4174
|
+
active: {
|
|
4175
|
+
outlineWidth: focusVars['--focus-outline-width'],
|
|
4176
|
+
outlineStyle: focusVars['--focus-outline-style'],
|
|
4177
|
+
outlineColor: focusVars['--focus-outline-color'],
|
|
4178
|
+
outlineOffset: focusVars['--focus-outline-offset'],
|
|
4179
|
+
},
|
|
4180
|
+
});
|
|
4181
|
+
return stylex
|
|
4182
|
+
.props(ring.active)
|
|
4183
|
+
.className!.split(' ')
|
|
4184
|
+
.filter(token => /^x[a-z0-9]+$/.test(token));
|
|
4185
|
+
};
|
|
4186
|
+
const hasRing = (cell: HTMLElement) =>
|
|
4187
|
+
ringClasses().every(token => cell.classList.contains(token));
|
|
4188
|
+
|
|
4189
|
+
async function openWithKeyboardOnBugAction() {
|
|
4190
|
+
const user = userEvent.setup();
|
|
4191
|
+
render(
|
|
4192
|
+
<MultiSelector
|
|
4193
|
+
label="Labels"
|
|
4194
|
+
options={OPTIONS}
|
|
4195
|
+
value={[]}
|
|
4196
|
+
onChange={() => {}}
|
|
4197
|
+
/>,
|
|
4198
|
+
);
|
|
4199
|
+
const trigger = screen.getByRole('combobox', {name: 'Labels'});
|
|
4200
|
+
await user.click(trigger);
|
|
4201
|
+
const row = (name: string) => screen.getByRole('row', {name, ...h});
|
|
4202
|
+
fireEvent.keyDown(trigger, {key: 'ArrowRight'});
|
|
4203
|
+
const bugAction = row('Bug').children[1] as HTMLElement;
|
|
4204
|
+
expect(trigger).toHaveAttribute('aria-activedescendant', bugAction.id);
|
|
4205
|
+
expect(hasRing(bugAction)).toBe(true);
|
|
4206
|
+
return {trigger, row, bugAction};
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
it('keeps the ring when the pointer crosses a disabled option the highlight ignores', async () => {
|
|
4210
|
+
const {trigger, row, bugAction} = await openWithKeyboardOnBugAction();
|
|
4211
|
+
fireEvent.mouseEnter(row('Docs').children[0]);
|
|
4212
|
+
expect(trigger).toHaveAttribute('aria-activedescendant', bugAction.id);
|
|
4213
|
+
expect(hasRing(bugAction)).toBe(true);
|
|
4214
|
+
});
|
|
4215
|
+
|
|
4216
|
+
it('follows the keyboard modality when a hover moves the highlight to another action', async () => {
|
|
4217
|
+
const {trigger, row} = await openWithKeyboardOnBugAction();
|
|
4218
|
+
const docsAction = row('Docs').children[1] as HTMLElement;
|
|
4219
|
+
fireEvent.mouseEnter(docsAction);
|
|
4220
|
+
expect(trigger).toHaveAttribute('aria-activedescendant', docsAction.id);
|
|
4221
|
+
expect(hasRing(docsAction)).toBe(true);
|
|
4222
|
+
});
|
|
4223
|
+
|
|
4224
|
+
it('draws no ring for a modifier chord after pointer input', async () => {
|
|
4225
|
+
const user = userEvent.setup();
|
|
4226
|
+
render(
|
|
4227
|
+
<MultiSelector
|
|
4228
|
+
label="Labels"
|
|
4229
|
+
options={OPTIONS}
|
|
4230
|
+
value={[]}
|
|
4231
|
+
onChange={() => {}}
|
|
4232
|
+
/>,
|
|
4233
|
+
);
|
|
4234
|
+
const trigger = screen.getByRole('combobox', {name: 'Labels'});
|
|
4235
|
+
await user.click(trigger);
|
|
4236
|
+
const bugAction = screen.getByRole('row', {name: 'Bug', ...h})
|
|
4237
|
+
.children[1] as HTMLElement;
|
|
4238
|
+
fireEvent.mouseEnter(bugAction);
|
|
4239
|
+
expect(hasRing(bugAction)).toBe(false);
|
|
4240
|
+
fireEvent.keyDown(trigger, {key: 'ArrowRight', ctrlKey: true});
|
|
4241
|
+
expect(hasRing(bugAction)).toBe(false);
|
|
4242
|
+
});
|
|
4243
|
+
});
|
|
4244
|
+
|
|
4086
4245
|
it('the inline-end arrow reaches the action cell, Enter fires it, inline-start returns', async () => {
|
|
4087
4246
|
const user = userEvent.setup();
|
|
4088
4247
|
const onChange = vi.fn();
|