@spaced-out/ui-design-system 0.6.34 → 0.6.35-beta.0
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/.cspell/custom-words.txt +9 -0
- package/CHANGELOG.md +7 -0
- package/lib/components/Menu/Menu.d.ts +71 -0
- package/lib/components/Menu/Menu.d.ts.map +1 -1
- package/lib/components/Menu/Menu.js +1028 -185
- package/lib/components/Menu/Menu.module.css +148 -27
- package/lib/components/Menu/MenuOptionButton.d.ts +23 -2
- package/lib/components/Menu/MenuOptionButton.d.ts.map +1 -1
- package/lib/components/Menu/MenuOptionButton.js +87 -38
- package/lib/components/Menu/index.d.ts +2 -0
- package/lib/components/Menu/index.d.ts.map +1 -1
- package/lib/components/Menu/index.js +22 -0
- package/lib/components/Menu/menuOptionId.d.ts +12 -0
- package/lib/components/Menu/menuOptionId.d.ts.map +1 -0
- package/lib/components/Menu/menuOptionId.js +18 -0
- package/lib/components/Menu/useMenuKeyboardNavigation.d.ts +93 -0
- package/lib/components/Menu/useMenuKeyboardNavigation.d.ts.map +1 -0
- package/lib/components/Menu/useMenuKeyboardNavigation.js +471 -0
- package/lib/components/Menu/useMenuTrigger.d.ts +183 -0
- package/lib/components/Menu/useMenuTrigger.d.ts.map +1 -0
- package/lib/components/Menu/useMenuTrigger.js +347 -0
- package/lib/hooks/index.d.ts +1 -0
- package/lib/hooks/index.d.ts.map +1 -1
- package/lib/hooks/index.js +11 -0
- package/lib/hooks/useInteractionModality/index.d.ts +2 -0
- package/lib/hooks/useInteractionModality/index.d.ts.map +1 -0
- package/lib/hooks/useInteractionModality/index.js +16 -0
- package/lib/hooks/useInteractionModality/useInteractionModality.d.ts +15 -0
- package/lib/hooks/useInteractionModality/useInteractionModality.d.ts.map +1 -0
- package/lib/hooks/useInteractionModality/useInteractionModality.js +103 -0
- package/lib/utils/click-away/click-away.d.ts +3 -0
- package/lib/utils/click-away/click-away.d.ts.map +1 -1
- package/lib/utils/click-away/click-away.js +49 -0
- package/mcp/package.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.useMenuTrigger = exports.useComboboxMenuTrigger = void 0;
|
|
7
|
+
var React = _interopRequireWildcard(require("react"));
|
|
8
|
+
var _Menu = require("./Menu");
|
|
9
|
+
var _menuOptionId = require("./menuOptionId");
|
|
10
|
+
var _useMenuKeyboardNavigation = require("./useMenuKeyboardNavigation");
|
|
11
|
+
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
12
|
+
/**
|
|
13
|
+
* Drop undefined-valued keys. The props these hooks return are spread AFTER the
|
|
14
|
+
* consumer's own `menu` props, and an own property holding `undefined` would
|
|
15
|
+
* erase a value the consumer set — `{...menu, ...{ariaLabel: undefined}}` has no
|
|
16
|
+
* label at all.
|
|
17
|
+
*/
|
|
18
|
+
const compact = obj => Object.fromEntries(Object.entries(obj).filter(_ref => {
|
|
19
|
+
let [, value] = _ref;
|
|
20
|
+
return value !== undefined;
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wires a trigger to the Menu it opens.
|
|
25
|
+
*
|
|
26
|
+
* Three things have to live on the trigger rather than in Menu, because they
|
|
27
|
+
* exist while the menu does NOT: `aria-haspopup`, `aria-expanded="false"`, and
|
|
28
|
+
* the ArrowDown/ArrowUp that opens the popup in the first place. That is the
|
|
29
|
+
* floor — no Menu prop can supply them — so these hooks hand them over as props
|
|
30
|
+
* to spread instead, and own everything else: the ids, the tie between the
|
|
31
|
+
* trigger's name and the listbox, and the ref Menu focuses into on open.
|
|
32
|
+
*
|
|
33
|
+
* `useMenuTrigger` is for triggers whose popup options own focus — a button, a
|
|
34
|
+
* tab overflow chevron, an inline label — so arrows move real DOM focus.
|
|
35
|
+
* `useComboboxMenuTrigger` is for triggers that are text inputs, where focus
|
|
36
|
+
* stays put and the highlight is virtual (`aria-activedescendant`).
|
|
37
|
+
*
|
|
38
|
+
* `getTriggerProps` is a getter rather than a static object because open state
|
|
39
|
+
* belongs to the consumer's `ClickAway`, which exposes it through a render
|
|
40
|
+
* callback — where a hook cannot be called.
|
|
41
|
+
*
|
|
42
|
+
* const menuTrigger = useMenuTrigger({id: props.id, ariaLabel: menu?.ariaLabel});
|
|
43
|
+
* <ClickAway>
|
|
44
|
+
* {({isOpen, onOpen, clickAway}) => (
|
|
45
|
+
* <>
|
|
46
|
+
* <Button {...props} {...menuTrigger.getTriggerProps({isOpen, onOpen})} />
|
|
47
|
+
* {isOpen && <Menu {...menu} {...menuTrigger.menuProps} onTabOut={clickAway} />}
|
|
48
|
+
* </>
|
|
49
|
+
* )}
|
|
50
|
+
* </ClickAway>
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generic in the trigger element so the returned handler matches whatever it is
|
|
55
|
+
* spread onto — inferred from the trigger's own `onKeyDown` where it has one.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const useMenuTrigger = function () {
|
|
59
|
+
let {
|
|
60
|
+
id,
|
|
61
|
+
menuId: consumerMenuId,
|
|
62
|
+
ariaLabel,
|
|
63
|
+
ariaLabelledBy
|
|
64
|
+
} = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
|
|
65
|
+
const generatedMenuId = React.useId();
|
|
66
|
+
const generatedTriggerId = React.useId();
|
|
67
|
+
const menuId = consumerMenuId ?? generatedMenuId;
|
|
68
|
+
const triggerId = id ?? generatedTriggerId;
|
|
69
|
+
const initialFocusRef = React.useRef(null);
|
|
70
|
+
const getTriggerProps = _ref2 => {
|
|
71
|
+
let {
|
|
72
|
+
isOpen,
|
|
73
|
+
onOpen,
|
|
74
|
+
onKeyDown
|
|
75
|
+
} = _ref2;
|
|
76
|
+
return {
|
|
77
|
+
id: triggerId,
|
|
78
|
+
'aria-haspopup': 'listbox',
|
|
79
|
+
'aria-expanded': isOpen,
|
|
80
|
+
// Only while the listbox is actually rendered: an IDREF that resolves to
|
|
81
|
+
// nothing is an invalid value, not an empty one.
|
|
82
|
+
'aria-controls': isOpen ? menuId : undefined,
|
|
83
|
+
onKeyDown: event => {
|
|
84
|
+
onKeyDown?.(event);
|
|
85
|
+
// Opening on an arrow key is the trigger's job precisely because the menu
|
|
86
|
+
// does not exist yet to handle it. Leave the key alone if the trigger's
|
|
87
|
+
// own handler already claimed it.
|
|
88
|
+
if (event.defaultPrevented || isOpen) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
onOpen();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
getTriggerProps,
|
|
100
|
+
menuProps: compact({
|
|
101
|
+
menuId,
|
|
102
|
+
// An explicit label suppresses the idref, so the two never compete.
|
|
103
|
+
ariaLabelledBy: ariaLabel ? undefined : ariaLabelledBy ?? triggerId,
|
|
104
|
+
ariaLabel,
|
|
105
|
+
initialFocusRef
|
|
106
|
+
})
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Which of ARIA's two combobox patterns the trigger implements.
|
|
112
|
+
*
|
|
113
|
+
* - `select` — a read-only field standing in for a `<select>`. An option is
|
|
114
|
+
* highlighted as soon as the list opens, Space selects it (the field has no
|
|
115
|
+
* typing role), and Enter or Space opens the list in the first place.
|
|
116
|
+
* - `autocomplete` — an editable field that filters the list as you type.
|
|
117
|
+
* Nothing is highlighted until the user arrows to it (APG calls this manual
|
|
118
|
+
* selection: auto-highlighting would let Enter select something they never
|
|
119
|
+
* chose), Space types a space, and only the arrow keys open the list.
|
|
120
|
+
*/
|
|
121
|
+
exports.useMenuTrigger = useMenuTrigger;
|
|
122
|
+
/**
|
|
123
|
+
* Wires a COMBOBOX trigger — a text input — to the Menu it opens. Focus stays
|
|
124
|
+
* on the input and the highlight is virtual, reported through
|
|
125
|
+
* `aria-activedescendant`, because the input has to keep receiving the keys it
|
|
126
|
+
* filters with.
|
|
127
|
+
*
|
|
128
|
+
* Driving the highlight from outside Menu means owning the active option as
|
|
129
|
+
* state, and owning it means deriving the same option list, initial highlight
|
|
130
|
+
* and navigation list that Menu derives internally. Both combobox consumers had
|
|
131
|
+
* grown their own copies of all three; they come from Menu itself now.
|
|
132
|
+
*
|
|
133
|
+
* A menu that carries its own filter field (`menu.allowSearch`) inverts the
|
|
134
|
+
* model: navigation lives inside Menu, so the trigger stops driving it and
|
|
135
|
+
* hands over an `initialFocusRef` instead. Both shapes come out of this hook.
|
|
136
|
+
*/
|
|
137
|
+
const useComboboxMenuTrigger = _ref3 => {
|
|
138
|
+
let {
|
|
139
|
+
pattern = 'select',
|
|
140
|
+
menu,
|
|
141
|
+
options,
|
|
142
|
+
canOpen,
|
|
143
|
+
menuRef,
|
|
144
|
+
isOpen,
|
|
145
|
+
onSelectOption,
|
|
146
|
+
onClose,
|
|
147
|
+
focusTrigger,
|
|
148
|
+
triggerAriaLabelledBy,
|
|
149
|
+
triggerAriaLabel,
|
|
150
|
+
defaultAriaLabel = 'Options'
|
|
151
|
+
} = _ref3;
|
|
152
|
+
const generatedMenuId = React.useId();
|
|
153
|
+
// The consumer's own id wins: it is what their `aria-controls` elsewhere, or
|
|
154
|
+
// their tests, already point at.
|
|
155
|
+
const menuId = menu?.menuId ?? generatedMenuId;
|
|
156
|
+
const initialFocusRef = React.useRef(null);
|
|
157
|
+
const [activeOptionKey, setActiveOptionKey] = React.useState(null);
|
|
158
|
+
const isAutocomplete = pattern === 'autocomplete';
|
|
159
|
+
|
|
160
|
+
// A searchable menu drives navigation internally from its own filter field; a
|
|
161
|
+
// select-only menu is driven by this trigger instead, with focus never
|
|
162
|
+
// entering the listbox. An autocomplete trigger IS the filter field, so it
|
|
163
|
+
// always drives navigation itself.
|
|
164
|
+
const usesExternalNav = isAutocomplete || !menu?.allowSearch;
|
|
165
|
+
|
|
166
|
+
/** The options the keyboard navigates, whatever supplied them. */
|
|
167
|
+
const optionSource = React.useMemo(() => options ?? (menu ? (0, _Menu.getFlatOptions)(menu) : []), [options, menu]);
|
|
168
|
+
|
|
169
|
+
/*
|
|
170
|
+
* `listbox` is an accessible-name-required role, so the popup must be named.
|
|
171
|
+
* Best name first:
|
|
172
|
+
* 1. what the consumer said (`menu.ariaLabelledBy` / `menu.ariaLabel`),
|
|
173
|
+
* 2. whatever already labels the trigger — a field label is the most useful
|
|
174
|
+
* name there is,
|
|
175
|
+
* 3. a literal fallback. Deliberately NOT the placeholder: echoing
|
|
176
|
+
* "Select..." names the list after an instruction, not its contents.
|
|
177
|
+
* An explicit label suppresses the idref, since a listbox drops `aria-label`
|
|
178
|
+
* whenever `aria-labelledby` is present.
|
|
179
|
+
*/
|
|
180
|
+
const menuAriaLabelledBy = menu?.ariaLabelledBy ?? (menu?.ariaLabel ? undefined : triggerAriaLabelledBy);
|
|
181
|
+
const menuAriaLabel = menu?.ariaLabel ?? (menuAriaLabelledBy ? undefined : triggerAriaLabel ?? defaultAriaLabel);
|
|
182
|
+
const selectByKey = key => {
|
|
183
|
+
const option = optionSource.find(opt => opt.key === key);
|
|
184
|
+
if (option) {
|
|
185
|
+
onSelectOption(option);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/*
|
|
190
|
+
* A virtualized menu keeps only a window of rows in the DOM, so the keyboard
|
|
191
|
+
* hook would otherwise clamp End / PageDown / wrap-around / type-ahead to that
|
|
192
|
+
* window. Hand it the real option list in that case — whichever source it came
|
|
193
|
+
* from, and flat or grouped alike, since Menu handles both — and Menu
|
|
194
|
+
* scrolls the active row into view. Plain menus stay on the DOM-driven path,
|
|
195
|
+
* which keeps grouped layouts and `resolveLabel` type-ahead working off the
|
|
196
|
+
* rendered text.
|
|
197
|
+
*/
|
|
198
|
+
const resolveLabel = menu?.resolveLabel;
|
|
199
|
+
const navigationOptions = React.useMemo(() => {
|
|
200
|
+
// A wholly disabled menu has no navigable options at all.
|
|
201
|
+
if (!menu?.virtualization?.enable || menu.menuDisabled) {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
return optionSource.filter(option => !option.disabled).map(option => {
|
|
205
|
+
// Type-ahead must match the text the user can actually read.
|
|
206
|
+
const resolved = resolveLabel?.(option);
|
|
207
|
+
return {
|
|
208
|
+
key: option.key,
|
|
209
|
+
label: typeof resolved === 'string' ? resolved : option.label ?? ''
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
}, [menu?.virtualization?.enable, menu?.menuDisabled, resolveLabel, optionSource]);
|
|
213
|
+
const {
|
|
214
|
+
onKeyDown: onComboboxKeyDown
|
|
215
|
+
} = (0, _useMenuKeyboardNavigation.useMenuKeyboardNavigation)({
|
|
216
|
+
containerRef: menuRef,
|
|
217
|
+
enabled: isOpen && usesExternalNav && !menu?.menuDisabled,
|
|
218
|
+
// An editable trigger keeps printable keys for filtering; a read-only one
|
|
219
|
+
// spends them on type-ahead.
|
|
220
|
+
mode: isAutocomplete ? 'search' : 'activedescendant',
|
|
221
|
+
activeOptionKey,
|
|
222
|
+
onSelectKey: selectByKey,
|
|
223
|
+
setActiveOptionKey,
|
|
224
|
+
options: navigationOptions
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
/*
|
|
228
|
+
* Seed the highlight when the menu opens, drop it when it closes, and keep it
|
|
229
|
+
* valid while the option set changes underneath it (async or cascading
|
|
230
|
+
* options). Menu skips its own re-validation under `externalKeyboardNav`, so
|
|
231
|
+
* the trigger owns it — without this, `aria-activedescendant` could point at
|
|
232
|
+
* the id of an option that no longer exists. A layout effect, so the id is in
|
|
233
|
+
* place before the browser paints the open menu.
|
|
234
|
+
*/
|
|
235
|
+
const selectedKeys = menu?.selectedKeys;
|
|
236
|
+
const selectedOption = menu?.selectedOption;
|
|
237
|
+
React.useLayoutEffect(() => {
|
|
238
|
+
// A closed list has no active option, whichever pattern this is.
|
|
239
|
+
if (!isOpen) {
|
|
240
|
+
setActiveOptionKey(null);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const enabledKeys = optionSource.filter(option => !option.disabled).map(option => option.key);
|
|
244
|
+
const surviving = prev => prev && enabledKeys.includes(prev) ? prev : null;
|
|
245
|
+
if (isAutocomplete) {
|
|
246
|
+
// Manual selection: never highlight anything on the user's behalf, only
|
|
247
|
+
// drop a highlight that the filtering has removed under them.
|
|
248
|
+
setActiveOptionKey(surviving);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (!usesExternalNav) {
|
|
252
|
+
setActiveOptionKey(null);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
setActiveOptionKey(prev => surviving(prev) ?? (0, _Menu.getInitialActiveOptionKey)({
|
|
256
|
+
options: optionSource,
|
|
257
|
+
selectedKeys,
|
|
258
|
+
selectedOption
|
|
259
|
+
}));
|
|
260
|
+
}, [isAutocomplete, isOpen, usesExternalNav, optionSource, selectedKeys, selectedOption]);
|
|
261
|
+
const getTriggerProps = _ref4 => {
|
|
262
|
+
let {
|
|
263
|
+
isOpen: triggerIsOpen,
|
|
264
|
+
isExpanded,
|
|
265
|
+
onOpen,
|
|
266
|
+
onKeyDown
|
|
267
|
+
} = _ref4;
|
|
268
|
+
const expanded = isExpanded ?? triggerIsOpen;
|
|
269
|
+
return {
|
|
270
|
+
// The one combobox for both shapes: the menu's own filter field is a
|
|
271
|
+
// `searchbox`, so nothing competes for the listbox. Keeping the role
|
|
272
|
+
// unconditional also keeps `aria-expanded` valid — it is not an allowed
|
|
273
|
+
// attribute on a plain textbox.
|
|
274
|
+
role: 'combobox',
|
|
275
|
+
'aria-haspopup': 'listbox',
|
|
276
|
+
'aria-expanded': expanded,
|
|
277
|
+
// Only reference the listbox while it is actually rendered. The hook always
|
|
278
|
+
// hands `menuId` to Menu, so the element exists whenever `expanded` says so,
|
|
279
|
+
// whichever option source was supplied.
|
|
280
|
+
'aria-controls': expanded ? menuId : undefined,
|
|
281
|
+
// 'list' whenever typing narrows the list: the autocomplete pattern, or a
|
|
282
|
+
// menu carrying its own filter field.
|
|
283
|
+
'aria-autocomplete': isAutocomplete || !usesExternalNav ? 'list' : 'none',
|
|
284
|
+
'aria-activedescendant': expanded && usesExternalNav && activeOptionKey ? (0, _menuOptionId.menuOptionId)(menuId, activeOptionKey) : undefined,
|
|
285
|
+
onKeyDown: event => {
|
|
286
|
+
onKeyDown?.(event);
|
|
287
|
+
if (event.defaultPrevented) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (!triggerIsOpen) {
|
|
291
|
+
// Closed: the arrow keys open the list, and a read-only trigger takes
|
|
292
|
+
// Enter and Space for it too — a typed space belongs to an editable
|
|
293
|
+
// one. This is the part no Menu prop can supply: the menu does not
|
|
294
|
+
// exist yet.
|
|
295
|
+
const opensList = event.key === 'ArrowDown' || event.key === 'ArrowUp' || !isAutocomplete && (event.key === 'Enter' || event.key === ' ');
|
|
296
|
+
if (opensList && (canOpen?.() ?? true)) {
|
|
297
|
+
event.preventDefault();
|
|
298
|
+
onOpen();
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
// Open + searchable: focus is in the menu's own filter field, which owns
|
|
303
|
+
// navigation — nothing to do here.
|
|
304
|
+
if (!usesExternalNav) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Tab moves into the menu's footer, Shift+Tab into its header, when that
|
|
308
|
+
// region has focusable controls. With none there, the listbox closes
|
|
309
|
+
// (APG: the popup closes when the combobox loses focus) and focus moves
|
|
310
|
+
// on.
|
|
311
|
+
if (event.key === 'Tab') {
|
|
312
|
+
if ((0, _useMenuKeyboardNavigation.moveFocusToMenuChrome)(menuRef.current, event.shiftKey)) {
|
|
313
|
+
event.preventDefault();
|
|
314
|
+
} else {
|
|
315
|
+
onClose();
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
// Space selects the active option — only where it has no typing role.
|
|
320
|
+
if (!isAutocomplete && event.key === ' ') {
|
|
321
|
+
event.preventDefault();
|
|
322
|
+
if (activeOptionKey) {
|
|
323
|
+
selectByKey(activeOptionKey);
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// Arrows / Home / End / Enter / type-ahead.
|
|
328
|
+
onComboboxKeyDown(event);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
};
|
|
332
|
+
return {
|
|
333
|
+
getTriggerProps,
|
|
334
|
+
menuProps: compact({
|
|
335
|
+
menuId,
|
|
336
|
+
ariaLabelledBy: menuAriaLabelledBy,
|
|
337
|
+
ariaLabel: menuAriaLabel,
|
|
338
|
+
externalKeyboardNav: usesExternalNav,
|
|
339
|
+
activeOptionKey: usesExternalNav ? activeOptionKey : undefined,
|
|
340
|
+
onActiveOptionKeyChange: usesExternalNav ? setActiveOptionKey : undefined,
|
|
341
|
+
onReturnFocusToInput: usesExternalNav ? focusTrigger : undefined,
|
|
342
|
+
initialFocusRef: usesExternalNav ? undefined : initialFocusRef
|
|
343
|
+
}),
|
|
344
|
+
activeOptionKey
|
|
345
|
+
};
|
|
346
|
+
};
|
|
347
|
+
exports.useComboboxMenuTrigger = useComboboxMenuTrigger;
|
package/lib/hooks/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from '../hooks/useDebounce';
|
|
|
4
4
|
export * from '../hooks/useFileUpload';
|
|
5
5
|
export * from '../hooks/useFilteredOptions';
|
|
6
6
|
export * from '../hooks/useInfiniteScroll';
|
|
7
|
+
export * from '../hooks/useInteractionModality';
|
|
7
8
|
export * from '../hooks/useInputState';
|
|
8
9
|
export * from '../hooks/useLockedBody';
|
|
9
10
|
export * from '../hooks/useModal';
|
package/lib/hooks/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,cAAc,sCAAsC,CAAC;AACrD,cAAc,8BAA8B,CAAC;AAC7C,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,yBAAyB,CAAC;AACxC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,yBAAyB,CAAC;AACxC,cAAc,oCAAoC,CAAC;AACnD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,0BAA0B,CAAC;AACzC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,cAAc,sCAAsC,CAAC;AACrD,cAAc,8BAA8B,CAAC;AAC7C,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,kCAAkC,CAAC;AACjD,cAAc,yBAAyB,CAAC;AACxC,cAAc,yBAAyB,CAAC;AACxC,cAAc,oBAAoB,CAAC;AACnC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,yBAAyB,CAAC;AACxC,cAAc,oCAAoC,CAAC;AACnD,cAAc,6BAA6B,CAAC;AAC5C,cAAc,0BAA0B,CAAC;AACzC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC"}
|
package/lib/hooks/index.js
CHANGED
|
@@ -69,6 +69,17 @@ Object.keys(_useInfiniteScroll).forEach(function (key) {
|
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
71
|
});
|
|
72
|
+
var _useInteractionModality = require("./useInteractionModality");
|
|
73
|
+
Object.keys(_useInteractionModality).forEach(function (key) {
|
|
74
|
+
if (key === "default" || key === "__esModule") return;
|
|
75
|
+
if (key in exports && exports[key] === _useInteractionModality[key]) return;
|
|
76
|
+
Object.defineProperty(exports, key, {
|
|
77
|
+
enumerable: true,
|
|
78
|
+
get: function () {
|
|
79
|
+
return _useInteractionModality[key];
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
72
83
|
var _useInputState = require("./useInputState");
|
|
73
84
|
Object.keys(_useInputState).forEach(function (key) {
|
|
74
85
|
if (key === "default" || key === "__esModule") return;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/hooks/useInteractionModality/index.ts"],"names":[],"mappings":"AAAA,cAAc,yDAAyD,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
var _useInteractionModality = require("./useInteractionModality");
|
|
7
|
+
Object.keys(_useInteractionModality).forEach(function (key) {
|
|
8
|
+
if (key === "default" || key === "__esModule") return;
|
|
9
|
+
if (key in exports && exports[key] === _useInteractionModality[key]) return;
|
|
10
|
+
Object.defineProperty(exports, key, {
|
|
11
|
+
enumerable: true,
|
|
12
|
+
get: function () {
|
|
13
|
+
return _useInteractionModality[key];
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type InteractionModality = 'keyboard' | 'pointer';
|
|
2
|
+
/**
|
|
3
|
+
* How the user last interacted with the page, read imperatively.
|
|
4
|
+
*
|
|
5
|
+
* Use this when the answer is needed during render or in an effect rather than
|
|
6
|
+
* as reactive state — for example a popup deciding on mount whether it was opened
|
|
7
|
+
* by keyboard, where re-rendering on later modality changes would be wrong.
|
|
8
|
+
*/
|
|
9
|
+
export declare const getInteractionModality: () => InteractionModality;
|
|
10
|
+
/**
|
|
11
|
+
* How the user last interacted with the page, as reactive state. Re-renders the
|
|
12
|
+
* caller when the user switches between keyboard and pointer.
|
|
13
|
+
*/
|
|
14
|
+
export declare const useInteractionModality: () => InteractionModality;
|
|
15
|
+
//# sourceMappingURL=useInteractionModality.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useInteractionModality.d.ts","sourceRoot":"","sources":["../../../src/hooks/useInteractionModality/useInteractionModality.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,mBAAmB,GAAG,UAAU,GAAG,SAAS,CAAC;AA6FzD;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,QAAO,mBAGzC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,sBAAsB,QAAO,mBAMzC,CAAC"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.useInteractionModality = exports.getInteractionModality = void 0;
|
|
7
|
+
var _react = require("react");
|
|
8
|
+
/*
|
|
9
|
+
* Input modality is a property of the SESSION, not of any one component, so it
|
|
10
|
+
* lives in module scope rather than in state that each consumer would have to
|
|
11
|
+
* thread around. This is the same shape the platform itself uses: `:focus-visible`
|
|
12
|
+
* is implemented by the engine tracking the last interaction globally.
|
|
13
|
+
*
|
|
14
|
+
* Reach for this only when CSS cannot answer the question — `:focus-visible` is
|
|
15
|
+
* the right tool whenever the element you need to style is the one that was
|
|
16
|
+
* interacted with. This exists for the case where it is NOT: a popup deciding
|
|
17
|
+
* whether to show a keyboard cursor has to know how its TRIGGER was operated,
|
|
18
|
+
* and the trigger's own `:focus-visible` cannot be trusted for that, because
|
|
19
|
+
* browsers always match it on text inputs (typing is expected there) — so an
|
|
20
|
+
* input-triggered popup would always look keyboard-opened.
|
|
21
|
+
*/
|
|
22
|
+
let currentModality = 'pointer';
|
|
23
|
+
let listening = false;
|
|
24
|
+
const subscribers = new Set();
|
|
25
|
+
|
|
26
|
+
// Keys that navigate even when chorded with a modifier — Cmd+Down is "jump to
|
|
27
|
+
// the end" on macOS and the menus honour it as such.
|
|
28
|
+
const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown', 'Tab', 'Enter', ' ']);
|
|
29
|
+
const MODIFIER_KEYS = new Set(['Shift', 'Meta', 'Alt', 'Control']);
|
|
30
|
+
const set = modality => {
|
|
31
|
+
if (currentModality === modality) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
currentModality = modality;
|
|
35
|
+
subscribers.forEach(notify => notify(modality));
|
|
36
|
+
};
|
|
37
|
+
const handleKeyDown = event => {
|
|
38
|
+
// A modifier pressed on its own — Shift held for a click, say — is not an
|
|
39
|
+
// interaction, and a chorded shortcut (Cmd+C, Ctrl+S) is a command rather than
|
|
40
|
+
// navigation. Neither should make the session look keyboard-driven. A chorded
|
|
41
|
+
// NAVIGATION key (Cmd+Down) does count: it moves the highlight.
|
|
42
|
+
if (MODIFIER_KEYS.has(event.key)) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if ((event.metaKey || event.altKey || event.ctrlKey) && !NAVIGATION_KEYS.has(event.key)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
set('keyboard');
|
|
49
|
+
};
|
|
50
|
+
const handlePointerDown = () => set('pointer');
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
* Capture phase, so the modality is recorded before any component handler reacts
|
|
54
|
+
* to the same event.
|
|
55
|
+
*
|
|
56
|
+
* Listening starts when this module is imported, NOT lazily on first read. It has
|
|
57
|
+
* to: the interesting question is usually "how was the thing that just mounted
|
|
58
|
+
* opened?", and the keypress that opened it happened before it existed. Attaching
|
|
59
|
+
* on first read would miss exactly that event and report the default instead.
|
|
60
|
+
*/
|
|
61
|
+
const subscribe = notify => {
|
|
62
|
+
// Session-scoped by design: the listeners live for the life of the page, so
|
|
63
|
+
// there is nothing to tear down.
|
|
64
|
+
if (typeof document !== 'undefined' && !listening) {
|
|
65
|
+
listening = true;
|
|
66
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
67
|
+
document.addEventListener('pointerdown', handlePointerDown, true);
|
|
68
|
+
}
|
|
69
|
+
if (!notify) {
|
|
70
|
+
return () => undefined;
|
|
71
|
+
}
|
|
72
|
+
subscribers.add(notify);
|
|
73
|
+
return () => {
|
|
74
|
+
subscribers.delete(notify);
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// Start listening at import time — see the note on `subscribe`.
|
|
79
|
+
subscribe();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* How the user last interacted with the page, read imperatively.
|
|
83
|
+
*
|
|
84
|
+
* Use this when the answer is needed during render or in an effect rather than
|
|
85
|
+
* as reactive state — for example a popup deciding on mount whether it was opened
|
|
86
|
+
* by keyboard, where re-rendering on later modality changes would be wrong.
|
|
87
|
+
*/
|
|
88
|
+
const getInteractionModality = () => {
|
|
89
|
+
subscribe();
|
|
90
|
+
return currentModality;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* How the user last interacted with the page, as reactive state. Re-renders the
|
|
95
|
+
* caller when the user switches between keyboard and pointer.
|
|
96
|
+
*/
|
|
97
|
+
exports.getInteractionModality = getInteractionModality;
|
|
98
|
+
const useInteractionModality = () => {
|
|
99
|
+
const [modality, setModality] = (0, _react.useState)(() => getInteractionModality());
|
|
100
|
+
(0, _react.useEffect)(() => subscribe(setModality), []);
|
|
101
|
+
return modality;
|
|
102
|
+
};
|
|
103
|
+
exports.useInteractionModality = useInteractionModality;
|
|
@@ -56,11 +56,14 @@ export declare class ClickAway<TTrigger extends HTMLElement = HTMLButtonElement,
|
|
|
56
56
|
el: HTMLElement | null | undefined;
|
|
57
57
|
boundaryRef: BoundaryRefType<TBoundary>;
|
|
58
58
|
triggerRef: TriggerRefType<TTrigger>;
|
|
59
|
+
previouslyFocused: HTMLElement | null;
|
|
60
|
+
closedByOutsideClick: boolean;
|
|
59
61
|
componentDidMount(): void;
|
|
60
62
|
componentDidUpdate(_prevProps: Readonly<ClickAwayProps<TTrigger, TBoundary>>, prevState: Readonly<ClickAwayState>): void;
|
|
61
63
|
componentWillUnmount(): void;
|
|
62
64
|
render(): React.ReactNode;
|
|
63
65
|
handleOpenClick: () => void;
|
|
66
|
+
restoreFocus: () => void;
|
|
64
67
|
handleCloseClick: (evt: MouseEvent) => void;
|
|
65
68
|
handleCloseOnEscapeKeypress: (evt?: KeyboardEvent) => void;
|
|
66
69
|
forceClose: () => void;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"click-away.d.ts","sourceRoot":"","sources":["../../../src/utils/click-away/click-away.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAO/B,KAAK,eAAe,CAAC,CAAC,SAAS,WAAW,IAAI,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;AAExE,KAAK,cAAc,CAAC,CAAC,SAAS,WAAW,IAAI,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;AAEvE,MAAM,WAAW,UAAU,CACzB,QAAQ,SAAS,WAAW,GAAG,iBAAiB,EAChD,SAAS,SAAS,WAAW,GAAG,cAAc;IAE9C,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAClC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACtC,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,WAAW,EAAE,eAAe,CAAC,SAAS,CAAC,CAAC;IACxC,UAAU,EAAE,cAAc,CAAC,QAAQ,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,cAAc,CAC7B,QAAQ,SAAS,WAAW,GAAG,iBAAiB,EAChD,SAAS,SAAS,WAAW,GAAG,cAAc;IAE9C,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,QAAQ,EAAE,CAAC,KAAK,EAAE,UAAU,CAAC,QAAQ,EAAE,SAAS,CAAC,KAAK,KAAK,CAAC,SAAS,CAAC;IACtE,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,OAAO,CAAC;IACxC,YAAY,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;IACxD;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,6BAA6B,CAAC,EAAE,OAAO,CAAC;CACzC;AAED,UAAU,cAAc;IACtB,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAClC,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACvC;AAID,qBAAa,SAAS,CACpB,QAAQ,SAAS,WAAW,GAAG,iBAAiB,EAChD,SAAS,SAAS,WAAW,GAAG,cAAc,CAC9C,SAAQ,KAAK,CAAC,SAAS,CAAC,cAAc,CAAC,QAAQ,EAAE,SAAS,CAAC,EAAE,cAAc,CAAC;IAC5E,MAAM,CAAC,YAAY,EAAE;QACnB,6BAA6B,CAAC,EAAE,OAAO,CAAC;QACxC,qBAAqB,CAAC,EAAE,OAAO,CAAC;KACjC,CAGC;IAEF,KAAK,EAAE,cAAc,CAInB;IAEF,EAAE,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,CAAQ;IAC1C,WAAW,EAAE,eAAe,CAAC,SAAS,CAAC,CAAgC;IACvE,UAAU,EAAE,cAAc,CAAC,QAAQ,CAAC,CAA+B;
|
|
1
|
+
{"version":3,"file":"click-away.d.ts","sourceRoot":"","sources":["../../../src/utils/click-away/click-away.ts"],"names":[],"mappings":"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"}
|
|
@@ -24,6 +24,17 @@ class ClickAway extends React.Component {
|
|
|
24
24
|
el = null;
|
|
25
25
|
boundaryRef = /*#__PURE__*/React.createRef();
|
|
26
26
|
triggerRef = /*#__PURE__*/React.createRef();
|
|
27
|
+
// Element that had focus right before the popup opened. Restored on close so
|
|
28
|
+
// keyboard focus returns to the trigger (Escape, a selection, Tab past the
|
|
29
|
+
// popup's edge) — the job FloatingFocusManager's returnFocus does for the
|
|
30
|
+
// dropdown family until they move onto this.
|
|
31
|
+
previouslyFocused = null;
|
|
32
|
+
// Set by handleCloseClick for the close that follows. A click on the page is
|
|
33
|
+
// the user moving on: focus belongs wherever they clicked (or on <body>), and
|
|
34
|
+
// pulling it back onto the trigger would re-run the trigger's own focus
|
|
35
|
+
// handling — a Typeahead opens its menu on focus, so it would reopen the very
|
|
36
|
+
// popup the click dismissed.
|
|
37
|
+
closedByOutsideClick = false;
|
|
27
38
|
componentDidMount() {
|
|
28
39
|
if (this.el) {
|
|
29
40
|
this.setState({
|
|
@@ -49,6 +60,12 @@ class ClickAway extends React.Component {
|
|
|
49
60
|
capture: true
|
|
50
61
|
});
|
|
51
62
|
window.document.removeEventListener('keyup', this.handleCloseOnEscapeKeypress);
|
|
63
|
+
if (this.closedByOutsideClick) {
|
|
64
|
+
this.previouslyFocused = null;
|
|
65
|
+
} else {
|
|
66
|
+
this.restoreFocus();
|
|
67
|
+
}
|
|
68
|
+
this.closedByOutsideClick = false;
|
|
52
69
|
}
|
|
53
70
|
}
|
|
54
71
|
}
|
|
@@ -92,11 +109,42 @@ class ClickAway extends React.Component {
|
|
|
92
109
|
if (this.el) {
|
|
93
110
|
pageBottom = this.pageBottom();
|
|
94
111
|
}
|
|
112
|
+
|
|
113
|
+
// Capture the focused element before opening (still the trigger at this
|
|
114
|
+
// point, before the popup mounts and moves focus into itself) so it can be
|
|
115
|
+
// restored on close.
|
|
116
|
+
if (!this.state.isOpen && typeof window.document !== 'undefined') {
|
|
117
|
+
this.previouslyFocused = window.document.activeElement;
|
|
118
|
+
}
|
|
95
119
|
this.setState({
|
|
96
120
|
isOpen: !this.state.isOpen,
|
|
97
121
|
pageBottom
|
|
98
122
|
}, this.handleOnChange);
|
|
99
123
|
};
|
|
124
|
+
|
|
125
|
+
// Return focus to the trigger when the popup closes — but only if focus was
|
|
126
|
+
// lost to the document body (the focused popup element was unmounted). If the
|
|
127
|
+
// user moved focus elsewhere (e.g. clicked another control), leave it there.
|
|
128
|
+
restoreFocus = () => {
|
|
129
|
+
const previous = this.previouslyFocused;
|
|
130
|
+
this.previouslyFocused = null;
|
|
131
|
+
if (typeof window.document === 'undefined') {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const active = window.document.activeElement;
|
|
135
|
+
const focusLost = !active || active === window.document.body;
|
|
136
|
+
if (!focusLost) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
// `previouslyFocused` is <body> whenever the opening click did not land on a
|
|
140
|
+
// natively focusable element — notably Safari and Firefox on macOS, which do
|
|
141
|
+
// not focus a <button> on click. Restoring it would be a no-op that strands
|
|
142
|
+
// focus at the top of the document, so fall back to the trigger itself.
|
|
143
|
+
const target = previous && previous !== window.document.body ? previous : this.triggerRef.current;
|
|
144
|
+
if (target && window.document.contains(target)) {
|
|
145
|
+
target.focus();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
100
148
|
handleCloseClick = evt => {
|
|
101
149
|
if (evt.target instanceof Node && this.props.containsNestedFloatingPortals && this.boundaryRef.current?.parentElement && (this.boundaryRef.current.parentElement === evt.target || this.boundaryRef.current.parentElement.contains(evt.target))) {
|
|
102
150
|
return;
|
|
@@ -107,6 +155,7 @@ class ClickAway extends React.Component {
|
|
|
107
155
|
if (evt.target instanceof Node && this.triggerRef && (this.triggerRef.current === evt.target || this.triggerRef.current?.contains(evt.target))) {
|
|
108
156
|
return;
|
|
109
157
|
}
|
|
158
|
+
this.closedByOutsideClick = true;
|
|
110
159
|
this.setState({
|
|
111
160
|
isOpen: false
|
|
112
161
|
}, this.handleOnChange);
|
package/mcp/package.json
CHANGED