@astryxdesign/core 0.7.0 → 0.7.1-canary.7e441be
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/BottomSheet/BottomSheet.d.ts +1 -1
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +12 -2
- package/dist/ContextMenu/ContextMenu.d.ts +1 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +22 -8
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +3 -26
- package/dist/DropdownMenu/DropdownMenu.d.ts.map +1 -1
- package/dist/DropdownMenu/DropdownMenu.js +10 -3
- package/dist/Typeahead/BaseTypeahead.d.ts +1 -1
- package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +12 -3
- package/dist/hooks/useIsomorphicLayoutEffect.d.ts +2 -2
- package/dist/hooks/useIsomorphicLayoutEffect.d.ts.map +1 -1
- package/dist/utils/focusReturn.d.ts +12 -8
- package/dist/utils/focusReturn.d.ts.map +1 -1
- package/dist/utils/focusReturn.js +44 -3
- package/package.json +4 -4
- package/src/BottomSheet/BottomSheet.test.tsx +114 -0
- package/src/BottomSheet/BottomSheet.tsx +12 -2
- package/src/BottomSheet/__tests__/BottomSheetFocusReturn.a11y.browser.spec.ts +96 -0
- package/src/Chat/__tests__/ChatLayoutScrollButtonSurface.a11y.chromium.spec.ts +5 -6
- package/src/CodeBlock/CodeBlock.spec.md +54 -44
- package/src/ContextMenu/ContextMenu.spec.md +1 -1
- package/src/ContextMenu/ContextMenu.test.tsx +36 -3
- package/src/ContextMenu/ContextMenu.tsx +27 -11
- package/src/ContextMenu/__tests__/ContextMenuCursorAnchor.a11y.browser.spec.ts +153 -0
- package/src/Dialog/Dialog.tsx +2 -30
- package/src/DropdownMenu/DropdownMenu.test.tsx +97 -1
- package/src/DropdownMenu/DropdownMenu.tsx +10 -3
- package/src/DropdownMenu/__tests__/DropdownMenuFocusReturn.a11y.browser.spec.ts +73 -0
- package/src/Markdown/Markdown.spec.md +37 -23
- package/src/PowerSearch/PowerSearch.test.tsx +45 -0
- package/src/Typeahead/BaseTypeahead.test.tsx +99 -1
- package/src/Typeahead/BaseTypeahead.tsx +17 -3
- package/src/utils/focusReturn.test.ts +79 -2
- package/src/utils/focusReturn.ts +48 -3
|
@@ -733,3 +733,48 @@ describe('field menu grouping', () => {
|
|
|
733
733
|
});
|
|
734
734
|
});
|
|
735
735
|
});
|
|
736
|
+
|
|
737
|
+
describe('field menu after the search source changes', () => {
|
|
738
|
+
it('opens the new field menu on the first click after a config change', async () => {
|
|
739
|
+
const user = userEvent.setup();
|
|
740
|
+
// A caller that loads field options asynchronously hands PowerSearch a
|
|
741
|
+
// new config, which builds a new search source.
|
|
742
|
+
const loadedConfig: PowerSearchConfig = {
|
|
743
|
+
...config,
|
|
744
|
+
fields: [
|
|
745
|
+
...config.fields,
|
|
746
|
+
{
|
|
747
|
+
key: 'owner',
|
|
748
|
+
label: 'Owner',
|
|
749
|
+
defaultOperator: 'is',
|
|
750
|
+
operators: [{key: 'is', label: 'is', value: {type: 'string'}}],
|
|
751
|
+
},
|
|
752
|
+
],
|
|
753
|
+
};
|
|
754
|
+
const renderSearch = (searchConfig: PowerSearchConfig) => (
|
|
755
|
+
<>
|
|
756
|
+
<PowerSearch config={searchConfig} filters={[]} onChange={() => {}} />
|
|
757
|
+
<button type="button">Sort</button>
|
|
758
|
+
</>
|
|
759
|
+
);
|
|
760
|
+
const {rerender} = render(renderSearch(config));
|
|
761
|
+
const input = screen.getByRole('combobox');
|
|
762
|
+
|
|
763
|
+
await user.click(input);
|
|
764
|
+
await waitFor(() => {
|
|
765
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
766
|
+
});
|
|
767
|
+
await user.click(screen.getByRole('button', {name: 'Sort'}));
|
|
768
|
+
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
769
|
+
|
|
770
|
+
rerender(renderSearch(loadedConfig));
|
|
771
|
+
await user.click(input);
|
|
772
|
+
|
|
773
|
+
await waitFor(() => {
|
|
774
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
775
|
+
expect(
|
|
776
|
+
screen.getByRole('option', {name: /Owner/, hidden: true}),
|
|
777
|
+
).toBeInTheDocument();
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
});
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @file BaseTypeahead.test.tsx
|
|
5
5
|
* @input BaseTypeahead public props and a synchronous SearchSource
|
|
6
|
-
* @output Combobox contract tests, including stale source refresh on reopen
|
|
6
|
+
* @output Combobox contract tests, including stale source refresh on focus and reopen
|
|
7
7
|
* @position Colocated verification for BaseTypeahead
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -209,6 +209,104 @@ describe('BaseTypeahead', () => {
|
|
|
209
209
|
expect(input).toHaveFocus();
|
|
210
210
|
});
|
|
211
211
|
|
|
212
|
+
it.each([
|
|
213
|
+
['a click', 'click'],
|
|
214
|
+
['keyboard focus', 'tab'],
|
|
215
|
+
] as const)(
|
|
216
|
+
'bootstraps the replacement source when %s refocuses the input',
|
|
217
|
+
async (_label, refocus) => {
|
|
218
|
+
const user = userEvent.setup();
|
|
219
|
+
const originalSource: SearchSource<SearchableItem> = {
|
|
220
|
+
search: () => [],
|
|
221
|
+
bootstrap: () => [resultItem],
|
|
222
|
+
};
|
|
223
|
+
const updatedBootstrap = vi.fn(() => [
|
|
224
|
+
{id: '2', label: 'Updated result'},
|
|
225
|
+
]);
|
|
226
|
+
const updatedSource: SearchSource<SearchableItem> = {
|
|
227
|
+
search: () => [],
|
|
228
|
+
bootstrap: updatedBootstrap,
|
|
229
|
+
};
|
|
230
|
+
const renderField = (searchSource: SearchSource<SearchableItem>) => (
|
|
231
|
+
<>
|
|
232
|
+
<BaseTypeahead
|
|
233
|
+
searchSource={searchSource}
|
|
234
|
+
value={null}
|
|
235
|
+
onChange={() => {}}
|
|
236
|
+
hasEntriesOnFocus
|
|
237
|
+
/>
|
|
238
|
+
<button type="button">Sort</button>
|
|
239
|
+
</>
|
|
240
|
+
);
|
|
241
|
+
const {rerender} = render(renderField(originalSource));
|
|
242
|
+
const input = screen.getByRole('combobox');
|
|
243
|
+
const sortButton = screen.getByRole('button', {name: 'Sort'});
|
|
244
|
+
|
|
245
|
+
await user.click(input);
|
|
246
|
+
await waitFor(() => {
|
|
247
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Focus moves to another control, which then hands the field a new
|
|
251
|
+
// source (a sort or filter change rebuilding the caller's source).
|
|
252
|
+
await user.click(sortButton);
|
|
253
|
+
expect(input).not.toHaveFocus();
|
|
254
|
+
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
255
|
+
rerender(renderField(updatedSource));
|
|
256
|
+
|
|
257
|
+
if (refocus === 'click') {
|
|
258
|
+
await user.click(input);
|
|
259
|
+
} else {
|
|
260
|
+
await user.tab({shift: true});
|
|
261
|
+
}
|
|
262
|
+
expect(input).toHaveFocus();
|
|
263
|
+
await waitFor(() => {
|
|
264
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
265
|
+
expect(
|
|
266
|
+
screen.getByRole('option', {name: 'Updated result', hidden: true}),
|
|
267
|
+
).toBeInTheDocument();
|
|
268
|
+
});
|
|
269
|
+
expect(
|
|
270
|
+
screen.queryByRole('option', {name: 'Result', hidden: true}),
|
|
271
|
+
).not.toBeInTheDocument();
|
|
272
|
+
expect(updatedBootstrap).toHaveBeenCalledTimes(1);
|
|
273
|
+
},
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
it('re-shows cached results without bootstrapping again when the source is unchanged', async () => {
|
|
277
|
+
const user = userEvent.setup();
|
|
278
|
+
const bootstrap = vi.fn(() => [resultItem]);
|
|
279
|
+
const source: SearchSource<SearchableItem> = {search: () => [], bootstrap};
|
|
280
|
+
render(
|
|
281
|
+
<>
|
|
282
|
+
<BaseTypeahead
|
|
283
|
+
searchSource={source}
|
|
284
|
+
value={null}
|
|
285
|
+
onChange={() => {}}
|
|
286
|
+
hasEntriesOnFocus
|
|
287
|
+
/>
|
|
288
|
+
<button type="button">Sort</button>
|
|
289
|
+
</>,
|
|
290
|
+
);
|
|
291
|
+
const input = screen.getByRole('combobox');
|
|
292
|
+
|
|
293
|
+
await user.click(input);
|
|
294
|
+
await waitFor(() => {
|
|
295
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
296
|
+
});
|
|
297
|
+
await user.click(screen.getByRole('button', {name: 'Sort'}));
|
|
298
|
+
expect(input).toHaveAttribute('aria-expanded', 'false');
|
|
299
|
+
|
|
300
|
+
await user.click(input);
|
|
301
|
+
await waitFor(() => {
|
|
302
|
+
expect(input).toHaveAttribute('aria-expanded', 'true');
|
|
303
|
+
});
|
|
304
|
+
expect(
|
|
305
|
+
screen.getByRole('option', {name: 'Result', hidden: true}),
|
|
306
|
+
).toBeInTheDocument();
|
|
307
|
+
expect(bootstrap).toHaveBeenCalledTimes(1);
|
|
308
|
+
});
|
|
309
|
+
|
|
212
310
|
it('counts grapheme clusters when enforcing minQueryLength', async () => {
|
|
213
311
|
const search = vi.fn(() => [resultItem]);
|
|
214
312
|
render(
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* @file BaseTypeahead.tsx
|
|
7
7
|
* @input Uses React, StyleX, usePopover, TypeaheadItem
|
|
8
|
-
* @output Exports BaseTypeahead
|
|
8
|
+
* @output Exports BaseTypeahead; focus bootstraps a replaced search source
|
|
9
9
|
* @position Core implementation; used by Typeahead and Tokenizer
|
|
10
10
|
*
|
|
11
11
|
* Pure combobox engine: input, search, keyboard navigation, dropdown.
|
|
@@ -610,6 +610,10 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
610
610
|
// in the closure are stale (a selection cleared them) and shouldn't
|
|
611
611
|
// be re-shown.
|
|
612
612
|
const resultsGenRef = useRef(0);
|
|
613
|
+
// The generation at which the current searchSource took over. Results
|
|
614
|
+
// populated before it (resultsGenRef below this) came from a replaced
|
|
615
|
+
// source: they can never be re-shown, so focus bootstraps the new source.
|
|
616
|
+
const sourceGenRef = useRef(0);
|
|
613
617
|
|
|
614
618
|
// Results still arriving from a replaced source must not land in the new
|
|
615
619
|
// one's menu.
|
|
@@ -617,7 +621,7 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
617
621
|
if (prevSearchSourceRef.current !== searchSource) {
|
|
618
622
|
prevSearchSourceRef.current.cancel?.();
|
|
619
623
|
prevSearchSourceRef.current = searchSource;
|
|
620
|
-
searchGenRef.current
|
|
624
|
+
sourceGenRef.current = ++searchGenRef.current;
|
|
621
625
|
}
|
|
622
626
|
|
|
623
627
|
// Layer for dropdown
|
|
@@ -889,11 +893,21 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
889
893
|
// Shared by handleFocus and handleClick: bootstrap entries if none are
|
|
890
894
|
// loaded yet, or re-show cached results that haven't been invalidated by
|
|
891
895
|
// a selection since (comparing the two generation refs catches that).
|
|
896
|
+
// Results cached from a replaced search source count as none, so focusing
|
|
897
|
+
// an empty field bootstraps the new source instead of opening nothing.
|
|
898
|
+
// Results a selection invalidated still keep the menu closed when the
|
|
899
|
+
// selection returns focus to the input.
|
|
892
900
|
const openIfEligible = useCallback(() => {
|
|
893
901
|
if (isDisabled) {
|
|
894
902
|
return;
|
|
895
903
|
}
|
|
896
|
-
|
|
904
|
+
const hasResultsFromCurrentSource =
|
|
905
|
+
results.length > 0 && resultsGenRef.current >= sourceGenRef.current;
|
|
906
|
+
if (
|
|
907
|
+
hasEntriesOnFocus &&
|
|
908
|
+
!hasResultsFromCurrentSource &&
|
|
909
|
+
query.length === 0
|
|
910
|
+
) {
|
|
897
911
|
void performBootstrap();
|
|
898
912
|
} else if (
|
|
899
913
|
results.length > 0 &&
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
2
|
|
|
3
|
-
import {describe, it, expect, afterEach} from 'vitest';
|
|
4
|
-
import {isFocusDetached} from './focusReturn';
|
|
3
|
+
import {describe, it, expect, afterEach, beforeEach, vi} from 'vitest';
|
|
4
|
+
import {focusForLastInput, isFocusDetached} from './focusReturn';
|
|
5
|
+
import {
|
|
6
|
+
__resetInteractionModalityForTest,
|
|
7
|
+
__startInteractionModalityTrackingForTest,
|
|
8
|
+
} from './interactionModality';
|
|
5
9
|
|
|
6
10
|
describe('isFocusDetached', () => {
|
|
7
11
|
afterEach(() => {
|
|
@@ -40,3 +44,76 @@ describe('isFocusDetached', () => {
|
|
|
40
44
|
expect(isFocusDetached(doc)).toBe(true);
|
|
41
45
|
});
|
|
42
46
|
});
|
|
47
|
+
|
|
48
|
+
describe('focusForLastInput', () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
__startInteractionModalityTrackingForTest();
|
|
51
|
+
__resetInteractionModalityForTest();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
vi.restoreAllMocks();
|
|
56
|
+
document.body.innerHTML = '';
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function button(): HTMLButtonElement {
|
|
60
|
+
const element = document.createElement('button');
|
|
61
|
+
document.body.appendChild(element);
|
|
62
|
+
return element;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
it('leaves the ring to the browser after keyboard input, passing the options through', () => {
|
|
66
|
+
const element = button();
|
|
67
|
+
const focus = vi.spyOn(element, 'focus');
|
|
68
|
+
focusForLastInput(element);
|
|
69
|
+
focusForLastInput(element, {preventScroll: true});
|
|
70
|
+
expect(focus.mock.calls).toEqual([[], [{preventScroll: true}]]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('asks the browser for no ring after pointer input, keeping the options', () => {
|
|
74
|
+
const element = button();
|
|
75
|
+
document.dispatchEvent(new Event('pointerdown'));
|
|
76
|
+
const focus = vi.spyOn(element, 'focus');
|
|
77
|
+
focusForLastInput(element);
|
|
78
|
+
focusForLastInput(element, {preventScroll: true});
|
|
79
|
+
expect(focus.mock.calls).toEqual([
|
|
80
|
+
[{focusVisible: false}],
|
|
81
|
+
[{preventScroll: true, focusVisible: false}],
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('keeps plain focus on a text field after pointer input', () => {
|
|
86
|
+
const input = document.createElement('input');
|
|
87
|
+
document.body.appendChild(input);
|
|
88
|
+
document.dispatchEvent(new Event('pointerdown'));
|
|
89
|
+
const focus = vi.spyOn(input, 'focus');
|
|
90
|
+
focusForLastInput(input, {preventScroll: true});
|
|
91
|
+
expect(focus.mock.calls).toEqual([[{preventScroll: true}]]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('clears a ring the browser already drew before refocusing after pointer input', () => {
|
|
95
|
+
const element = button();
|
|
96
|
+
element.focus();
|
|
97
|
+
document.dispatchEvent(new Event('pointerdown'));
|
|
98
|
+
const matches = HTMLElement.prototype.matches;
|
|
99
|
+
vi.spyOn(HTMLElement.prototype, 'matches').mockImplementation(function (
|
|
100
|
+
this: HTMLElement,
|
|
101
|
+
selector: string,
|
|
102
|
+
) {
|
|
103
|
+
return selector === ':focus-visible'
|
|
104
|
+
? this === element
|
|
105
|
+
: matches.call(this, selector);
|
|
106
|
+
});
|
|
107
|
+
const blur = vi.spyOn(element, 'blur');
|
|
108
|
+
const focus = vi.spyOn(element, 'focus');
|
|
109
|
+
focusForLastInput(element, {preventScroll: true});
|
|
110
|
+
expect(blur).toHaveBeenCalledTimes(1);
|
|
111
|
+
expect(focus.mock.calls).toEqual([
|
|
112
|
+
[{preventScroll: true, focusVisible: false}],
|
|
113
|
+
]);
|
|
114
|
+
expect(blur.mock.invocationCallOrder[0]).toBeLessThan(
|
|
115
|
+
focus.mock.invocationCallOrder[0] ?? 0,
|
|
116
|
+
);
|
|
117
|
+
expect(document.activeElement).toBe(element);
|
|
118
|
+
});
|
|
119
|
+
});
|
package/src/utils/focusReturn.ts
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* @file focusReturn.ts
|
|
5
|
-
* @input The document's current active element
|
|
6
|
-
*
|
|
5
|
+
* @input The document's current active element and the shared interaction
|
|
6
|
+
* modality.
|
|
7
|
+
* @output Whether focus is currently "detached" — on nothing in particular —
|
|
8
|
+
* and a focus move whose ring follows the last input.
|
|
7
9
|
* @position Utility; used by overlay triggers (e.g. DateInput, DateTimeInput)
|
|
8
10
|
* that return focus to their control when the overlay closes, but only when
|
|
9
|
-
* the dismiss did not already move focus somewhere the user chose
|
|
11
|
+
* the dismiss did not already move focus somewhere the user chose, and by
|
|
12
|
+
* overlays (Dialog, DropdownMenu) that move focus programmatically.
|
|
10
13
|
*/
|
|
11
14
|
|
|
15
|
+
import {getInteractionModality} from './interactionModality';
|
|
16
|
+
|
|
12
17
|
/**
|
|
13
18
|
* True when focus rests on nothing in particular — no active element, or the
|
|
14
19
|
* document body / root element.
|
|
@@ -29,3 +34,43 @@ export function isFocusDetached(doc: Document = document): boolean {
|
|
|
29
34
|
active == null || active === doc.body || active === doc.documentElement
|
|
30
35
|
);
|
|
31
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Move focus programmatically so its ring follows the last input
|
|
40
|
+
* (`architecture:interaction-modality` INV1). After pointer input the browser's
|
|
41
|
+
* focus option keeps the element from matching `:focus-visible`, which is the
|
|
42
|
+
* one way to reach an element the overlay does not render: the consumer's
|
|
43
|
+
* autofocus target, the control that opened a Dialog, or a menu trigger the
|
|
44
|
+
* caller renders. Pass `options` (for example `preventScroll`) as to `focus()`.
|
|
45
|
+
*
|
|
46
|
+
* Make sure modality tracking is active (`useInteractionModalityTracking()`)
|
|
47
|
+
* in the component that calls this.
|
|
48
|
+
*/
|
|
49
|
+
export function focusForLastInput(
|
|
50
|
+
element: HTMLElement,
|
|
51
|
+
options?: FocusOptions,
|
|
52
|
+
): void {
|
|
53
|
+
// A text-entry field paints its own focus treatment to show where typing
|
|
54
|
+
// goes, and an extra blur there can run validation or mark it touched.
|
|
55
|
+
const isTextEntry =
|
|
56
|
+
element instanceof HTMLInputElement ||
|
|
57
|
+
element instanceof HTMLTextAreaElement ||
|
|
58
|
+
element instanceof HTMLSelectElement ||
|
|
59
|
+
element.isContentEditable;
|
|
60
|
+
if (isTextEntry || getInteractionModality() !== 'pointer') {
|
|
61
|
+
if (options === undefined) {
|
|
62
|
+
element.focus();
|
|
63
|
+
} else {
|
|
64
|
+
element.focus(options);
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Focusing the element that already has focus does nothing, so a ring the
|
|
69
|
+
// browser already drew would stay. WebKit draws one when its own focus
|
|
70
|
+
// restoration (a dialog's close, a popover's hide) moves focus itself, so
|
|
71
|
+
// clear that focus first.
|
|
72
|
+
if (element.matches(':focus-visible')) {
|
|
73
|
+
element.blur();
|
|
74
|
+
}
|
|
75
|
+
element.focus({...options, focusVisible: false});
|
|
76
|
+
}
|