Implement Focus Zone circular navigation and tab key behaviors for macOS (#3688)

This commit is contained in:
Navneet Kambo 2024-07-23 09:24:36 -07:00 коммит произвёл GitHub
Родитель 4d1c921736
Коммит ddf90fafdf
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
8 изменённых файлов: 147 добавлений и 33 удалений

Просмотреть файл

@ -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.
*