Implement Focus Zone circular navigation and tab key behaviors for macOS (#3688)
This commit is contained in:
Родитель
4d1c921736
Коммит
ddf90fafdf
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { Platform, View } from 'react-native';
|
||||
|
||||
import type { FocusZoneDirection, FocusZoneTabNavigation } from '@fluentui/react-native';
|
||||
import type { FocusZoneDirection, FocusZoneTabNavigation, IFocusable } from '@fluentui/react-native';
|
||||
import { FocusZone, MenuButton, Text } from '@fluentui/react-native';
|
||||
import type { ButtonProps } from '@fluentui-react-native/button';
|
||||
import { ButtonV1 as Button } from '@fluentui-react-native/button';
|
||||
|
@ -27,6 +27,13 @@ import { testProps } from '../Common/TestProps';
|
|||
export const FocusZoneDirections: FocusZoneDirection[] = ['bidirectional', 'horizontal', 'vertical', 'none'];
|
||||
export const FocusZoneTabNavigations: FocusZoneTabNavigation[] = ['None', 'NavigateWrap', 'NavigateStopAtEnds', 'Normal'];
|
||||
|
||||
// Buttons by default focus on click on Win32, but they don't on the Mac, so place focus explicitly for convenience
|
||||
function useFocusOnClickForMac(): Partial<ButtonProps> {
|
||||
const componentRef = React.useRef<IFocusable>();
|
||||
const onClick = React.useCallback(() => componentRef.current?.focus(), [componentRef]);
|
||||
return Platform.OS === 'macos' ? { componentRef, onClick } : {};
|
||||
}
|
||||
|
||||
type FocusZoneListWrapperProps = React.PropsWithChildren<{
|
||||
beforeID?: string;
|
||||
afterID?: string;
|
||||
|
@ -37,12 +44,14 @@ export const FocusZoneListWrapper: React.FunctionComponent<FocusZoneListWrapperP
|
|||
<>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
{...useFocusOnClickForMac()}
|
||||
/* For Android E2E testing purposes, testProps must be passed in after accessibilityLabel. */
|
||||
{...testProps(beforeID)}
|
||||
/>
|
||||
{children}
|
||||
<Button
|
||||
{...buttonProps}
|
||||
{...useFocusOnClickForMac()}
|
||||
/* For Android E2E testing purposes, testProps must be passed in after accessibilityLabel. */
|
||||
{...testProps(afterID)}
|
||||
/>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { StyleSheet, View } from 'react-native';
|
||||
import { Platform, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { Text } from '@fluentui/react-native';
|
||||
import { ButtonV1 as Button } from '@fluentui-react-native/button';
|
||||
|
@ -47,14 +47,16 @@ export const focusZoneTestStyles = StyleSheet.create({
|
|||
smallBoxStyle: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
backgroundColor: 'lightgrey',
|
||||
margin: 5,
|
||||
// something is wrong with setting the background color on this style on the Mac, and until we fix that, let's not do this
|
||||
...Platform.select({ macos: { borderWidth: 1 }, default: { backgroundColor: 'lightgrey' } }),
|
||||
},
|
||||
wideBoxStyle: {
|
||||
width: 150,
|
||||
height: 20,
|
||||
backgroundColor: 'lightgrey',
|
||||
margin: 5,
|
||||
// something is wrong with setting the background color on this style on the Mac, and until we fix that, let's not do this
|
||||
...Platform.select({ macos: { borderWidth: 1 }, default: { backgroundColor: 'lightgrey' } }),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "minor",
|
||||
"comment": "Implement Focus Zone circular navigation and tab key behaviors for macOS",
|
||||
"packageName": "@fluentui-react-native/focus-zone",
|
||||
"email": "nakambo@microsoft.com",
|
||||
"dependentChangeType": "patch"
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"type": "patch",
|
||||
"comment": "Minor tweaks for Mac behavior",
|
||||
"packageName": "@fluentui-react-native/tester",
|
||||
"email": "nakambo@microsoft.com",
|
||||
"dependentChangeType": "patch"
|
||||
}
|
|
@ -15,6 +15,7 @@ typedef NS_ENUM(NSInteger, FocusZoneDirection) {
|
|||
@property(nonatomic) BOOL disabled;
|
||||
@property(nonatomic) FocusZoneDirection focusZoneDirection;
|
||||
@property(nonatomic) NSString *navigateAtEnd;
|
||||
@property(nonatomic) NSString *tabKeyNavigation;
|
||||
@property(nonatomic) NSView *defaultResponder;
|
||||
|
||||
@end
|
||||
|
|
|
@ -308,6 +308,73 @@ static BOOL ShouldSkipFocusZone(NSView *view)
|
|||
return [self nextViewToFocusForCondition:block];
|
||||
}
|
||||
|
||||
- (NSView *)nextViewToFocusForCircularAction:(FocusZoneAction)action
|
||||
{
|
||||
BOOL isAdvance = IsAdvanceWithinZoneAction(action);
|
||||
BOOL isHorizontal = IsHorizontalNavigationWithinZoneAction(action);
|
||||
|
||||
NSView *firstResponder = GetFirstResponder([self window]);
|
||||
NSRect firstResponderRect = [firstResponder convertRect:[firstResponder bounds] toView:self];
|
||||
NSPoint anchorPoint = isHorizontal
|
||||
? NSMakePoint(isAdvance ? 0 : [self bounds].size.width, NSMidY(firstResponderRect))
|
||||
: NSMakePoint(NSMidX(firstResponderRect), isAdvance ? 0 : [self bounds].size.height);
|
||||
|
||||
__block CGFloat closestDistance = CGFLOAT_MAX;
|
||||
|
||||
IsViewLeadingCandidateForNextFocus block = ^BOOL(NSView *candidateView)
|
||||
{
|
||||
BOOL isLeadingCandidate = NO;
|
||||
BOOL skip = NO;
|
||||
NSRect candidateRect = [candidateView convertRect:[candidateView bounds] toView:self];
|
||||
BOOL subviewRelationExists = [candidateView isDescendantOf:firstResponder] || [firstResponder isDescendantOf:candidateView];
|
||||
|
||||
if (isHorizontal)
|
||||
{
|
||||
if (subviewRelationExists)
|
||||
{
|
||||
skip = (isAdvance && NSMinX(candidateRect) > NSMinX(firstResponderRect))
|
||||
|| (!isAdvance && NSMaxX(candidateRect) < NSMaxX(firstResponderRect));
|
||||
}
|
||||
else
|
||||
{
|
||||
skip = (isAdvance && NSMidX(candidateRect) > NSMidX(firstResponderRect))
|
||||
|| (!isAdvance && NSMidX(candidateRect) < NSMidX(firstResponderRect));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (subviewRelationExists)
|
||||
{
|
||||
skip = (isAdvance && NSMinY(candidateRect) > NSMinY(firstResponderRect))
|
||||
|| (!isAdvance && NSMaxY(candidateRect) < NSMaxY(firstResponderRect));
|
||||
}
|
||||
else
|
||||
{
|
||||
skip = (isAdvance && NSMidY(candidateRect) > NSMidY(firstResponderRect))
|
||||
|| (!isAdvance && NSMidY(candidateRect) < NSMidY(firstResponderRect));
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip)
|
||||
{
|
||||
NSPoint candidatePoint = isHorizontal
|
||||
? NSMakePoint(isAdvance ? NSMinX(candidateRect) : NSMaxX(candidateRect), NSMidY(candidateRect))
|
||||
: NSMakePoint(NSMidX(candidateRect), isAdvance ? NSMinY(candidateRect) : NSMaxY(candidateRect));
|
||||
|
||||
CGFloat distance = GetDistanceBetweenPoints(anchorPoint, candidatePoint);
|
||||
if (closestDistance > distance)
|
||||
{
|
||||
closestDistance = distance;
|
||||
isLeadingCandidate = YES;
|
||||
}
|
||||
}
|
||||
|
||||
return isLeadingCandidate;
|
||||
};
|
||||
|
||||
return [self nextViewToFocusForCondition:block];
|
||||
}
|
||||
|
||||
- (NSView *)nextViewToFocusForHorizontalNavigation:(FocusZoneAction)action
|
||||
{
|
||||
BOOL isAdvance = IsAdvanceWithinZoneAction(action);
|
||||
|
@ -347,9 +414,8 @@ static BOOL ShouldSkipFocusZone(NSView *view)
|
|||
return [self nextViewToFocusForCondition:block];
|
||||
}
|
||||
|
||||
- (NSView *)nextViewToFocusWithFallback:(FocusZoneAction)action
|
||||
- (NSView *)nextViewToFocusWithFallback:(FocusZoneAction)action considerCircular:(BOOL)shouldTryCircular
|
||||
{
|
||||
|
||||
// Special case if we're currently focused on self
|
||||
NSView *firstResponder = GetFirstResponder([self window]);
|
||||
if (self == firstResponder)
|
||||
|
@ -364,18 +430,37 @@ static BOOL ShouldSkipFocusZone(NSView *view)
|
|||
}
|
||||
}
|
||||
|
||||
BOOL isHorizontal = IsHorizontalNavigationWithinZoneAction(action);
|
||||
BOOL isAdvance = IsAdvanceWithinZoneAction(action);
|
||||
NSView *nextViewToFocus = [self nextViewToFocusForAction:action];
|
||||
|
||||
if (nextViewToFocus == nil)
|
||||
{
|
||||
if (IsHorizontalNavigationWithinZoneAction(action))
|
||||
if (isHorizontal)
|
||||
{
|
||||
nextViewToFocus = [self nextViewToFocusForHorizontalNavigation:action];
|
||||
}
|
||||
else
|
||||
{
|
||||
FocusZoneAction horizontalAction = IsAdvanceWithinZoneAction(action) ? FocusZoneActionRightArrow : FocusZoneActionLeftArrow;
|
||||
nextViewToFocus = [self nextViewToFocusWithFallback:horizontalAction];
|
||||
FocusZoneAction horizontalAction = isAdvance ? FocusZoneActionRightArrow : FocusZoneActionLeftArrow;
|
||||
nextViewToFocus = [self nextViewToFocusWithFallback:horizontalAction considerCircular:NO];
|
||||
}
|
||||
}
|
||||
|
||||
if (nextViewToFocus == nil && shouldTryCircular)
|
||||
{
|
||||
nextViewToFocus = [self nextViewToFocusForCircularAction:action];
|
||||
|
||||
if (nextViewToFocus == firstResponder)
|
||||
{
|
||||
nextViewToFocus = isHorizontal ?
|
||||
[self nextViewToFocusForCircularAction:isAdvance ? FocusZoneActionDownArrow : FocusZoneActionUpArrow] :
|
||||
[self nextViewToFocusForCircularAction:isAdvance ? FocusZoneActionRightArrow : FocusZoneActionLeftArrow];
|
||||
}
|
||||
|
||||
if (nextViewToFocus == firstResponder)
|
||||
{
|
||||
nextViewToFocus = nil;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -440,29 +525,42 @@ static BOOL ShouldSkipFocusZone(NSView *view)
|
|||
{
|
||||
FocusZoneAction action = GetActionForEvent(event);
|
||||
FocusZoneDirection focusZoneDirection = [self focusZoneDirection];
|
||||
NSString *navigateAtEnd = [self navigateAtEnd];
|
||||
NSString *tabKeyNavigation = [self tabKeyNavigation];
|
||||
|
||||
BOOL tabAllowed = [@"NavigateWrap" isEqual:tabKeyNavigation]
|
||||
|| [@"NavigateStopAtEnds" isEqual:tabKeyNavigation]
|
||||
|| [@"Normal" isEqual:tabKeyNavigation];
|
||||
|
||||
BOOL passthrough = NO;
|
||||
NSView *viewToFocus = nil;
|
||||
|
||||
if ([self disabled] || action == FocusZoneActionNone)
|
||||
if ([self disabled] || action == FocusZoneActionNone
|
||||
|| (focusZoneDirection == FocusZoneDirectionVertical
|
||||
&& (action == FocusZoneActionRightArrow || action == FocusZoneActionLeftArrow))
|
||||
|| (focusZoneDirection == FocusZoneDirectionHorizontal
|
||||
&& (action == FocusZoneActionUpArrow || action == FocusZoneActionDownArrow)))
|
||||
{
|
||||
passthrough = YES;
|
||||
}
|
||||
else if (action == FocusZoneActionTab || action == FocusZoneActionShiftTab)
|
||||
else if (!tabAllowed && (action == FocusZoneActionTab || action == FocusZoneActionShiftTab))
|
||||
{
|
||||
viewToFocus = [self nextViewToFocusOutsideZone:action];
|
||||
}
|
||||
else if ((focusZoneDirection == FocusZoneDirectionVertical
|
||||
&& (action == FocusZoneActionRightArrow || action == FocusZoneActionLeftArrow))
|
||||
|| (focusZoneDirection == FocusZoneDirectionHorizontal
|
||||
&& (action == FocusZoneActionUpArrow || action == FocusZoneActionDownArrow))
|
||||
|| (focusZoneDirection == FocusZoneDirectionNone))
|
||||
{
|
||||
passthrough = YES;
|
||||
}
|
||||
else
|
||||
{
|
||||
viewToFocus = [self nextViewToFocusWithFallback:action];
|
||||
FocusZoneAction directionalAction = action == FocusZoneActionTab ? FocusZoneActionDownArrow
|
||||
: (action == FocusZoneActionShiftTab ? FocusZoneActionUpArrow : action);
|
||||
|
||||
BOOL allowCircular = [@"NavigateWrap" isEqual:action == FocusZoneActionTab || action == FocusZoneActionShiftTab
|
||||
? tabKeyNavigation : navigateAtEnd];
|
||||
|
||||
viewToFocus = [self nextViewToFocusWithFallback:directionalAction considerCircular:allowCircular];
|
||||
|
||||
if (viewToFocus == nil && action != directionalAction && [@"Normal" isEqual:tabKeyNavigation]) // tab, shift+tab
|
||||
{
|
||||
// didn't find a view IN the zone -- look outside
|
||||
viewToFocus = [self nextViewToFocusOutsideZone:action];
|
||||
}
|
||||
}
|
||||
|
||||
if (viewToFocus != nil)
|
||||
|
@ -478,14 +576,4 @@ static BOOL ShouldSkipFocusZone(NSView *view)
|
|||
}
|
||||
}
|
||||
|
||||
- (void)setNavigateAtEnd:(NSString *)value
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
- (NSString *)navigateAtEnd
|
||||
{
|
||||
return @"NavigateStopAtEnds";
|
||||
}
|
||||
|
||||
@end
|
||||
|
|
|
@ -34,6 +34,7 @@ RCT_CUSTOM_VIEW_PROPERTY(focusZoneDirection, NSString, RCTFocusZone)
|
|||
}
|
||||
|
||||
RCT_EXPORT_VIEW_PROPERTY(navigateAtEnd, NSString)
|
||||
RCT_EXPORT_VIEW_PROPERTY(tabKeyNavigation, NSString)
|
||||
|
||||
RCT_CUSTOM_VIEW_PROPERTY(defaultTabbableElement, NSNumber, RCTFocusZone)
|
||||
{
|
||||
|
|
|
@ -39,7 +39,6 @@ export type FocusZoneProps = React.PropsWithChildren<{
|
|||
use2DNavigation?: boolean;
|
||||
|
||||
/**
|
||||
* @platform win32
|
||||
* By default, pressing Tab within a FocusZone moves focus out of the FocusZone.
|
||||
* This prop allows you to change that behavior.
|
||||
*
|
||||
|
|
Загрузка…
Ссылка в новой задаче