Skip to content

Commit a7b8580

Browse files
LFDanLudevongovett
andauthored
feat: Add subdialog support to Menu and Autocomplete (#7561)
* rough start * fix contain * debug ESC handler * fix rendering of subdialog in autocomplete * debug dynamic case and fix context * handle submenutriggers in base collection filtering and fix id issue for trigger * prevent focus from being lost to the body when submenutrigger is virtually focused typically submenus dont have focus restore turned on since it would move focus manually back to the trigger when keyboard closing the menu. However, we cant move focus to virtually focused triggers so enable focus restore on the submenu in these cases * ensure that the focused subdialogtrigger item remains focused after opening and closing the subdialog * close submenu when virtual focus moves off trigger and properly dispatch events to the temporarily cleared focus item the second part of this message refers to the following flow: over submenu via hover in autocomplete, use arrowLeft to close it and then try to reopen it via arrowRight * fix hover for now so I can debug the focus issues * fix tab handling in subdialogs and opening submenus on mobile when using virtual focus * Fix to close submenus/dialogs when in a Autocomplete that isnt in a popover itself * fix issue where you cant shift tab from the Autocompletes text field * fix shift tabbing from closing the subdialogs when rendered by Autocomplete menu * make sure expanded triggers dont have visible focus ring * fix all subdialog being closed when using ESC on a subMenuTrigger and partial fix for only closing submenu via ESC if opened from sub dialog this defers ESC handling to the menu/dialog at all times. The previous bug 3f8e6e0 seems to not happen anymore * update mobile screen reader behavior so focus is restored to the subtrigger when dimissing the submenu/dialog also subdialog component renaming from team discussion and adding enterkeyhint for autocomplete * only close outer most submenu/subdialog when using ESC for consistency between a chain of submenus and a mixed chain of submenus/subdialogs. This is consistent with Windows as well, but not with MacOS * partial fix to only persist a single focus ring on item or input for virtual focus * Making focus ring appear on virtually focused items and on input field if none exist Applies to virtual focus menus and listboxes. Only affects RAC for now * simplifiying useSelectableItem change in favor of proper manager.isFocused tracking this includes a fix to where hovering an adjacent item to close a open submenu in an autocomplete wasnt making the hovered item properly focused * saving point for tests * Fix focusscope for subdialogs and add test * Fix S2 autocomplete double focus ring * adding tests for subdialog and testing useInteractOutside for subdialogs again * add submenu tests * fix ESC closing multiple levels of subdialogs/menus due to collection event leak to test, open a subdialog with auto complete -> submenu -> subdialog with autocomplete and hits ESC * hack around react 16 failures for now * fix lint * fix react 16, close menu on hovering different item * fixing lint * wip: refactor to fire fake focus/blur events * fix more * implement keepVisible for non-modal popovers * refactor * update lock * lint * fix 16 * fix test * Clear MenuContext in SubDialog Otherwise menus in SubDialogs inside SubMenus will also handle keyboard events, resulting in multiple levels closing at once * Fix dismiss on interact outside * Use focusedNodeId ref for keyboard handlers to avoid race condition * refactor listbox filtering to support usage inside Select * Make sure props are sent to the right elements * fix RSP * cleanup * fix react 16/17 * Revert "fix react 16/17" This reverts commit e03daa4. * Revert "fix RSP" This reverts commit 77148f5. * Revert "Fix dismiss on interact outside" This reverts commit f6b7272. * Fix interact outside for RAC submenu/subdialogs specifically * revert dialog change * more cleanup * Use getActiveElement utility * focus input when clicking on collection * fix merge * Refactor left/right arrow key behavior to be less menu specific * Remove onDismissButtonPress for now Can't reproduce original issue anymore * lint --------- Co-authored-by: Devon Govett <[email protected]>
1 parent f0234ec commit a7b8580

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+2033
-357
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
diff --git a/dist/cjs/document/prepareDocument.js b/dist/cjs/document/prepareDocument.js
2+
index 39a24b8f2ccdc52739d130480ab18975073616cb..0c3f5199401c15b90230c25a02de364eeef3e297 100644
3+
--- a/dist/cjs/document/prepareDocument.js
4+
+++ b/dist/cjs/document/prepareDocument.js
5+
@@ -30,7 +30,7 @@ function prepareDocument(document) {
6+
const initialValue = UI.getInitialValue(el);
7+
if (initialValue !== undefined) {
8+
if (el.value !== initialValue) {
9+
- dispatchEvent.dispatchDOMEvent(el, 'change');
10+
+ el.dispatchEvent(new Event('change'));
11+
}
12+
UI.clearInitialValue(el);
13+
}
14+
diff --git a/dist/cjs/utils/focus/getActiveElement.js b/dist/cjs/utils/focus/getActiveElement.js
15+
index d25f3a8ef67e856e43614559f73012899c0b53d7..4ed9ee45565ed438ee9284d8d3043c0bd50463eb 100644
16+
--- a/dist/cjs/utils/focus/getActiveElement.js
17+
+++ b/dist/cjs/utils/focus/getActiveElement.js
18+
@@ -6,6 +6,8 @@ function getActiveElement(document) {
19+
const activeElement = document.activeElement;
20+
if (activeElement === null || activeElement === undefined ? undefined : activeElement.shadowRoot) {
21+
return getActiveElement(activeElement.shadowRoot);
22+
+ } else if (activeElement && activeElement.tagName === 'IFRAME') {
23+
+ return getActiveElement(activeElement.contentWindow.document);
24+
} else {
25+
// Browser does not yield disabled elements as document.activeElement - jsdom does
26+
if (isDisabled.isDisabled(activeElement)) {

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@
126126
"@testing-library/dom": "^10.1.0",
127127
"@testing-library/jest-dom": "^5.16.5",
128128
"@testing-library/react": "^15.0.7",
129-
"@testing-library/user-event": "^14.6.1",
129+
"@testing-library/user-event": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch",
130130
"@types/react": "npm:[email protected]",
131131
"@types/react-dom": "npm:[email protected]",
132132
"@types/storybook__react": "^4.0.2",
@@ -234,7 +234,8 @@
234234
"@types/react-dom": "npm:[email protected]",
235235
"recast": "0.23.6",
236236
"ast-types": "0.16.1",
237-
"svgo": "^3"
237+
"svgo": "^3",
238+
"@testing-library/user-event@npm:^14.4.0": "patch:@testing-library/user-event@npm%3A14.6.1#~/.yarn/patches/@testing-library-user-event-npm-14.6.1-5da7e1d4e2.patch"
238239
},
239240
"@parcel/transformer-css": {
240241
"cssModules": {

packages/@react-aria/autocomplete/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
},
2424
"dependencies": {
2525
"@react-aria/combobox": "^3.11.1",
26+
"@react-aria/focus": "^3.19.1",
2627
"@react-aria/i18n": "^3.12.5",
2728
"@react-aria/interactions": "^3.23.0",
2829
"@react-aria/listbox": "^3.14.0",

packages/@react-aria/autocomplete/src/useAutocomplete.ts

Lines changed: 97 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
import {AriaLabelingProps, BaseEvent, DOMProps, RefObject} from '@react-types/shared';
1414
import {AriaTextFieldProps} from '@react-aria/textfield';
1515
import {AutocompleteProps, AutocompleteState} from '@react-stately/autocomplete';
16-
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, isCtrlKeyPressed, mergeProps, mergeRefs, UPDATE_ACTIVEDESCENDANT, useEffectEvent, useId, useLabels, useObjectRef} from '@react-aria/utils';
16+
import {CLEAR_FOCUS_EVENT, FOCUS_EVENT, getActiveElement, getOwnerDocument, isCtrlKeyPressed, mergeProps, mergeRefs, useEffectEvent, useId, useLabels, useObjectRef} from '@react-aria/utils';
17+
import {dispatchVirtualBlur, dispatchVirtualFocus, moveVirtualFocus} from '@react-aria/focus';
18+
import {getInteractionModality} from '@react-aria/interactions';
1719
// @ts-ignore
1820
import intlMessages from '../intl/*.json';
19-
import {KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef} from 'react';
21+
import React, {FocusEvent as ReactFocusEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef} from 'react';
2022
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2123

2224
export interface CollectionOptions extends DOMProps, AriaLabelingProps {
@@ -34,6 +36,8 @@ export interface AriaAutocompleteProps extends AutocompleteProps {
3436
}
3537

3638
export interface AriaAutocompleteOptions extends Omit<AriaAutocompleteProps, 'children'> {
39+
/** The ref for the wrapped collection element. */
40+
inputRef: RefObject<HTMLInputElement | null>,
3741
/** The ref for the wrapped collection element. */
3842
collectionRef: RefObject<HTMLElement | null>
3943
}
@@ -57,36 +61,49 @@ export interface AutocompleteAria {
5761
*/
5862
export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state: AutocompleteState): AutocompleteAria {
5963
let {
64+
inputRef,
6065
collectionRef,
6166
filter
6267
} = props;
6368

6469
let collectionId = useId();
6570
let timeout = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
6671
let delayNextActiveDescendant = useRef(false);
67-
let queuedActiveDescendant = useRef(null);
72+
let queuedActiveDescendant = useRef<string | null>(null);
6873
let lastCollectionNode = useRef<HTMLElement>(null);
6974

70-
let updateActiveDescendant = useEffectEvent((e) => {
71-
let {target} = e;
72-
if (queuedActiveDescendant.current === target.id) {
73-
return;
75+
// For mobile screen readers, we don't want virtual focus, instead opting to disable FocusScope's restoreFocus and manually
76+
// moving focus back to the subtriggers
77+
let shouldUseVirtualFocus = getInteractionModality() !== 'virtual';
78+
79+
useEffect(() => {
80+
return () => clearTimeout(timeout.current);
81+
}, []);
82+
83+
let updateActiveDescendant = useEffectEvent((e: Event) => {
84+
// Ensure input is focused if the user clicks on the collection directly.
85+
if (!e.isTrusted && shouldUseVirtualFocus && inputRef.current && getActiveElement(getOwnerDocument(inputRef.current)) !== inputRef.current) {
86+
inputRef.current.focus();
7487
}
7588

89+
let target = e.target as Element | null;
90+
if (e.isTrusted || !target || queuedActiveDescendant.current === target.id) {
91+
return;
92+
}
93+
7694
clearTimeout(timeout.current);
77-
e.stopPropagation();
78-
7995
if (target !== collectionRef.current) {
8096
if (delayNextActiveDescendant.current) {
8197
queuedActiveDescendant.current = target.id;
8298
timeout.current = setTimeout(() => {
8399
state.setFocusedNodeId(target.id);
84-
queuedActiveDescendant.current = null;
85100
}, 500);
86101
} else {
102+
queuedActiveDescendant.current = target.id;
87103
state.setFocusedNodeId(target.id);
88104
}
89105
} else {
106+
queuedActiveDescendant.current = null;
90107
state.setFocusedNodeId(null);
91108
}
92109

@@ -96,14 +113,14 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
96113
let callbackRef = useCallback((collectionNode) => {
97114
if (collectionNode != null) {
98115
// When typing forward, we want to delay the setting of active descendant to not interrupt the native screen reader announcement
99-
// of the letter you just typed. If we recieve another UPDATE_ACTIVEDESCENDANT call then we clear the queued update
116+
// of the letter you just typed. If we recieve another focus event then we clear the queued update
100117
// We track lastCollectionNode to do proper cleanup since callbackRefs just pass null when unmounting. This also handles
101118
// React 19's extra call of the callback ref in strict mode
102-
lastCollectionNode.current?.removeEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant);
119+
lastCollectionNode.current?.removeEventListener('focusin', updateActiveDescendant);
103120
lastCollectionNode.current = collectionNode;
104-
collectionNode.addEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant);
121+
collectionNode.addEventListener('focusin', updateActiveDescendant);
105122
} else {
106-
lastCollectionNode.current?.removeEventListener(UPDATE_ACTIVEDESCENDANT, updateActiveDescendant);
123+
lastCollectionNode.current?.removeEventListener('focusin', updateActiveDescendant);
107124
}
108125
}, [updateActiveDescendant]);
109126

@@ -123,11 +140,16 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
123140
);
124141
});
125142

126-
let clearVirtualFocus = useEffectEvent(() => {
143+
let clearVirtualFocus = useEffectEvent((clearFocusKey?: boolean) => {
144+
moveVirtualFocus(getActiveElement());
145+
queuedActiveDescendant.current = null;
127146
state.setFocusedNodeId(null);
128147
let clearFocusEvent = new CustomEvent(CLEAR_FOCUS_EVENT, {
129148
cancelable: true,
130-
bubbles: true
149+
bubbles: true,
150+
detail: {
151+
clearFocusKey
152+
}
131153
});
132154
clearTimeout(timeout.current);
133155
delayNextActiveDescendant.current = false;
@@ -141,7 +163,8 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
141163
if (state.inputValue !== value && state.inputValue.length <= value.length) {
142164
focusFirstItem();
143165
} else {
144-
clearVirtualFocus();
166+
// Fully clear focused key when backspacing since the list may change and thus we'd want to start fresh again
167+
clearVirtualFocus(true);
145168
}
146169

147170
state.setInputValue(value);
@@ -155,6 +178,7 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
155178
return;
156179
}
157180

181+
let focusedNodeId = queuedActiveDescendant.current;
158182
switch (e.key) {
159183
case 'a':
160184
if (isCtrlKeyPressed(e)) {
@@ -185,7 +209,7 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
185209
case 'PageUp':
186210
case 'ArrowUp':
187211
case 'ArrowDown': {
188-
if ((e.key === 'Home' || e.key === 'End') && state.focusedNodeId == null && e.shiftKey) {
212+
if ((e.key === 'Home' || e.key === 'End') && focusedNodeId == null && e.shiftKey) {
189213
return;
190214
}
191215

@@ -200,13 +224,6 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
200224
collectionRef.current?.dispatchEvent(focusCollection);
201225
break;
202226
}
203-
case 'ArrowLeft':
204-
case 'ArrowRight':
205-
// TODO: will need to special case this so it doesn't clear the focused key if we are currently
206-
// focused on a submenutrigger? May not need to since focus would
207-
// But what about wrapped grids where ArrowLeft and ArrowRight should navigate left/right
208-
clearVirtualFocus();
209-
break;
210227
}
211228

212229
// Emulate the keyboard events that happen in the input field in the wrapped collection. This is for triggering things like onAction via Enter
@@ -217,15 +234,28 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
217234
e.stopPropagation();
218235
}
219236

220-
if (state.focusedNodeId == null) {
221-
collectionRef.current?.dispatchEvent(
237+
let shouldPerformDefaultAction = true;
238+
if (focusedNodeId == null) {
239+
shouldPerformDefaultAction = collectionRef.current?.dispatchEvent(
222240
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
223-
);
241+
) || false;
224242
} else {
225-
let item = document.getElementById(state.focusedNodeId);
226-
item?.dispatchEvent(
243+
let item = document.getElementById(focusedNodeId);
244+
shouldPerformDefaultAction = item?.dispatchEvent(
227245
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
228-
);
246+
) || false;
247+
}
248+
249+
if (shouldPerformDefaultAction) {
250+
switch (e.key) {
251+
case 'ArrowLeft':
252+
case 'ArrowRight': {
253+
// Clear the activedescendant so NVDA announcements aren't interrupted but retain the focused key in the collection so the
254+
// user's keyboard navigation restarts from where they left off
255+
clearVirtualFocus();
256+
break;
257+
}
258+
}
229259
}
230260
};
231261

@@ -235,12 +265,13 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
235265
// is detected by usePress instead of the original keyup originating from the input
236266
if (e.target === keyDownTarget.current) {
237267
e.stopImmediatePropagation();
238-
if (state.focusedNodeId == null) {
268+
let focusedNodeId = queuedActiveDescendant.current;
269+
if (focusedNodeId == null) {
239270
collectionRef.current?.dispatchEvent(
240271
new KeyboardEvent(e.type, e)
241272
);
242273
} else {
243-
let item = document.getElementById(state.focusedNodeId);
274+
let item = document.getElementById(focusedNodeId);
244275
item?.dispatchEvent(
245276
new KeyboardEvent(e.type, e)
246277
);
@@ -269,6 +300,34 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
269300
return true;
270301
}, [state.inputValue, filter]);
271302

303+
// Be sure to clear/restore the virtual + collection focus when blurring/refocusing the field so we only show the
304+
// focus ring on the virtually focused collection when are actually interacting with the Autocomplete
305+
let onBlur = (e: ReactFocusEvent) => {
306+
if (!e.isTrusted) {
307+
return;
308+
}
309+
310+
let lastFocusedNode = queuedActiveDescendant.current ? document.getElementById(queuedActiveDescendant.current) : null;
311+
if (lastFocusedNode) {
312+
dispatchVirtualBlur(lastFocusedNode, e.relatedTarget);
313+
}
314+
};
315+
316+
let onFocus = (e: ReactFocusEvent) => {
317+
if (!e.isTrusted) {
318+
return;
319+
}
320+
321+
let curFocusedNode = queuedActiveDescendant.current ? document.getElementById(queuedActiveDescendant.current) : null;
322+
if (curFocusedNode) {
323+
let target = e.target;
324+
queueMicrotask(() => {
325+
dispatchVirtualBlur(target, curFocusedNode);
326+
dispatchVirtualFocus(curFocusedNode, target);
327+
});
328+
}
329+
};
330+
272331
return {
273332
textFieldProps: {
274333
value: state.inputValue,
@@ -283,11 +342,13 @@ export function UNSTABLE_useAutocomplete(props: AriaAutocompleteOptions, state:
283342
// This disable's iOS's autocorrect suggestions, since the autocomplete provides its own suggestions.
284343
autoCorrect: 'off',
285344
// This disable's the macOS Safari spell check auto corrections.
286-
spellCheck: 'false'
345+
spellCheck: 'false',
346+
[parseInt(React.version, 10) >= 17 ? 'enterKeyHint' : 'enterkeyhint']: 'enter',
347+
onBlur,
348+
onFocus
287349
},
288350
collectionProps: mergeProps(collectionProps, {
289-
// TODO: shouldFocusOnHover? shouldFocusWrap? Should it be up to the wrapped collection?
290-
shouldUseVirtualFocus: true,
351+
shouldUseVirtualFocus,
291352
disallowTypeAhead: true
292353
}),
293354
collectionRef: mergedCollectionRef,

packages/@react-aria/collections/src/BaseCollection.ts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ export class BaseCollection<T> implements ICollection<Node<T>> {
228228
let clonedSection: Mutable<CollectionNode<T>> = (node as CollectionNode<T>).clone();
229229
let lastChildInSection: Mutable<CollectionNode<T>> | null = null;
230230
for (let child of this.getChildren(node.key)) {
231-
if (filterFn(child.textValue) || child.type === 'header') {
231+
if (shouldKeepNode(child, filterFn, this, newCollection)) {
232232
let clonedChild: Mutable<CollectionNode<T>> = (child as CollectionNode<T>).clone();
233233
// eslint-disable-next-line max-depth
234234
if (lastChildInSection == null) {
@@ -288,22 +288,25 @@ export class BaseCollection<T> implements ICollection<Node<T>> {
288288
lastNode = clonedSeparator;
289289
newCollection.addNode(clonedSeparator);
290290
}
291-
} else if (filterFn(node.textValue)) {
291+
} else {
292+
// At this point, the node is either a subdialogtrigger node or a standard row/item
292293
let clonedNode: Mutable<CollectionNode<T>> = (node as CollectionNode<T>).clone();
293-
if (newCollection.firstKey == null) {
294-
newCollection.firstKey = clonedNode.key;
295-
}
294+
if (shouldKeepNode(clonedNode, filterFn, this, newCollection)) {
295+
if (newCollection.firstKey == null) {
296+
newCollection.firstKey = clonedNode.key;
297+
}
296298

297-
if (lastNode != null && (lastNode.type !== 'section' && lastNode.type !== 'separator') && lastNode.parentKey === clonedNode.parentKey) {
298-
lastNode.nextKey = clonedNode.key;
299-
clonedNode.prevKey = lastNode.key;
300-
} else {
301-
clonedNode.prevKey = null;
302-
}
299+
if (lastNode != null && (lastNode.type !== 'section' && lastNode.type !== 'separator') && lastNode.parentKey === clonedNode.parentKey) {
300+
lastNode.nextKey = clonedNode.key;
301+
clonedNode.prevKey = lastNode.key;
302+
} else {
303+
clonedNode.prevKey = null;
304+
}
303305

304-
clonedNode.nextKey = null;
305-
newCollection.addNode(clonedNode);
306-
lastNode = clonedNode;
306+
clonedNode.nextKey = null;
307+
newCollection.addNode(clonedNode);
308+
lastNode = clonedNode;
309+
}
307310
}
308311
}
309312

@@ -322,3 +325,22 @@ export class BaseCollection<T> implements ICollection<Node<T>> {
322325
return newCollection;
323326
}
324327
}
328+
329+
function shouldKeepNode<T>(node: Node<T>, filterFn: (nodeValue: string) => boolean, oldCollection: BaseCollection<T>, newCollection: BaseCollection<T>): boolean {
330+
if (node.type === 'subdialogtrigger' || node.type === 'submenutrigger') {
331+
// Subdialog wrapper should only have one child, if it passes the filter add it to the new collection since we don't need to
332+
// do any extra handling for its first/next key
333+
let triggerChild = [...oldCollection.getChildren(node.key)][0];
334+
if (triggerChild && filterFn(triggerChild.textValue)) {
335+
let clonedChild: Mutable<CollectionNode<T>> = (triggerChild as CollectionNode<T>).clone();
336+
newCollection.addNode(clonedChild);
337+
return true;
338+
} else {
339+
return false;
340+
}
341+
} else if (node.type === 'header') {
342+
return true;
343+
} else {
344+
return filterFn(node.textValue);
345+
}
346+
}

packages/@react-aria/dialog/src/useDialog.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ export interface DialogAria {
3030
* A dialog is an overlay shown above other content in an application.
3131
*/
3232
export function useDialog(props: AriaDialogProps, ref: RefObject<FocusableElement | null>): DialogAria {
33-
let {role = 'dialog'} = props;
33+
let {
34+
role = 'dialog'
35+
} = props;
3436
let titleId: string | undefined = useSlotId();
3537
titleId = props['aria-label'] ? undefined : titleId;
3638

0 commit comments

Comments
 (0)