Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FocusZone (macOS): New prop to ignore OS keyboard shortcut preference #2267

Merged
merged 10 commits into from
Oct 27, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "FocusZone (macOS): New prop to ignore OS keyboard shortcut preference",
"packageName": "@fluentui-react-native/contextual-menu",
"email": "sanajmi@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "FocusZone (macOS): New prop to ignore OS keyboard shortcut preference",
"packageName": "@fluentui-react-native/focus-zone",
"email": "sanajmi@microsoft.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ export const ContextualMenu = compose<ContextualMenuType>({
componentRef: focusZoneRef,
defaultTabbableElement: focusZoneRef,
focusZoneDirection: 'vertical',
forceFocusMacOS: true,
},
});

Expand Down
1 change: 1 addition & 0 deletions packages/components/ContextualMenu/src/Submenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const Submenu = compose<SubmenuType>({
focusZone: {
componentRef: focusZoneRef,
focusZoneDirection: 'vertical',
forceFocusMacOS: true,
},
});

Expand Down
1 change: 1 addition & 0 deletions packages/components/FocusZone/macos/RCTFocusZone.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ typedef NS_ENUM(NSInteger, FocusZoneDirection) {
@interface RCTFocusZone : RCTView

@property(nonatomic) BOOL disabled;
@property(nonatomic) BOOL forceFocus;
@property(nonatomic) FocusZoneDirection focusZoneDirection;
@property(nonatomic) NSString *navigateAtEnd;
@property(nonatomic) NSView *defaultKeyView;
Expand Down
134 changes: 73 additions & 61 deletions packages/components/FocusZone/macos/RCTFocusZone.m
Original file line number Diff line number Diff line change
Expand Up @@ -18,53 +18,26 @@
// enumerated in the same row (or column) as the current focused view
Saadnajmi marked this conversation as resolved.
Show resolved Hide resolved
static const CGFloat FocusZoneBuffer = 3;

@implementation RCTFocusZone

static inline CGFloat GetDistanceBetweenPoints(NSPoint point1, NSPoint point2)
{
NSPoint delta = NSMakePoint(point1.x - point2.x, point1.y - point2.y);
return sqrt(delta.x * delta.x + delta.y * delta.y);
}

static inline CGFloat GetDistanceBetweenRects(NSRect rect1, NSRect rect2)
{
// Get the top left corner of the rect, top right in RTL
bool isRTL = [[RCTI18nUtil sharedInstance] isRTL];

CGFloat rect1Offset = isRTL ? rect1.size.width : 0;
CGFloat rect2Offset = isRTL ? rect2.size.width : 0;

NSPoint rect1Corner = NSMakePoint(rect1.origin.x + rect1Offset , rect1.origin.y);
NSPoint rect2Corner = NSMakePoint(rect2.origin.x + rect2Offset , rect2.origin.y);

return GetDistanceBetweenPoints(rect1Corner, rect2Corner);
}

static inline CGFloat GetMinDistanceBetweenRectVerticesAndPoint(NSRect rect, NSPoint point)
BOOL ShouldFocusOnView(NSView *view, BOOL forceFocus)
chiuam marked this conversation as resolved.
Show resolved Hide resolved
{
return fmin(
fmin(GetDistanceBetweenPoints(point, NSMakePoint(NSMinX(rect), NSMinY(rect))),
GetDistanceBetweenPoints(point, NSMakePoint(NSMaxX(rect), NSMaxY(rect)))),
fmin(GetDistanceBetweenPoints(point, NSMakePoint(NSMaxX(rect), NSMinY(rect))),
GetDistanceBetweenPoints(point, NSMakePoint(NSMinX(rect), NSMaxY(rect))))
);
return forceFocus ? [view acceptsFirstResponder] : [view canBecomeKeyView];
Saadnajmi marked this conversation as resolved.
Show resolved Hide resolved
}

/// Performs a depth first search looking for the first key view in a parent view's view hierarchy.
/// This function does not take into account the geometric position of the view.
static NSView *GetFirstKeyViewWithin(NSView *parentView)
static NSView *GetFirstKeyViewWithin(NSView *parentView, BOOL forceFocus)
{
if ([[parentView subviews] count] < 1)
{
return nil;
}
if ([[parentView subviews] count] < 1)
{
return nil;
}
Saadnajmi marked this conversation as resolved.
Show resolved Hide resolved

for (NSView *view in [parentView subviews]) {
if ([view canBecomeKeyView]) {
if (ShouldFocusOnView(view, forceFocus)) {
return view;
}

NSView *match = GetFirstKeyViewWithin(view);
NSView *match = GetFirstKeyViewWithin(view, forceFocus);
if (match) {
return match;
}
Expand All @@ -75,21 +48,51 @@ static inline CGFloat GetMinDistanceBetweenRectVerticesAndPoint(NSRect rect, NSP
/// Performs a depth first search looking for the last key view in a parent view's view hierarchy.
/// We find the last view by simply reversing the order of the subview array.
/// This function does not take into account the geometric position of the view.
static NSView *GetLastKeyViewWithin(NSView *parentView)
static NSView *GetLastKeyViewWithin(NSView *parentView, BOOL forceFocus)
{
for (NSView *view in [[parentView subviews] reverseObjectEnumerator]) {
if ([view canBecomeKeyView]) {
if (ShouldFocusOnView(view, forceFocus)) {
return view;
}

NSView *match = GetLastKeyViewWithin(view);
NSView *match = GetLastKeyViewWithin(view, forceFocus);
if (match) {
return match;
}
}
return nil;
}

static inline CGFloat GetDistanceBetweenPoints(NSPoint point1, NSPoint point2)
{
NSPoint delta = NSMakePoint(point1.x - point2.x, point1.y - point2.y);
return sqrt(delta.x * delta.x + delta.y * delta.y);
}

static inline CGFloat GetDistanceBetweenRects(NSRect rect1, NSRect rect2)
{
// Get the top left corner of the rect, top right in RTL
bool isRTL = [[RCTI18nUtil sharedInstance] isRTL];

CGFloat rect1Offset = isRTL ? rect1.size.width : 0;
CGFloat rect2Offset = isRTL ? rect2.size.width : 0;

NSPoint rect1Corner = NSMakePoint(rect1.origin.x + rect1Offset , rect1.origin.y);
NSPoint rect2Corner = NSMakePoint(rect2.origin.x + rect2Offset , rect2.origin.y);

return GetDistanceBetweenPoints(rect1Corner, rect2Corner);
}

static inline CGFloat GetMinDistanceBetweenRectVerticesAndPoint(NSRect rect, NSPoint point)
{
return fmin(
fmin(GetDistanceBetweenPoints(point, NSMakePoint(NSMinX(rect), NSMinY(rect))),
GetDistanceBetweenPoints(point, NSMakePoint(NSMaxX(rect), NSMaxY(rect)))),
fmin(GetDistanceBetweenPoints(point, NSMakePoint(NSMaxX(rect), NSMinY(rect))),
GetDistanceBetweenPoints(point, NSMakePoint(NSMinX(rect), NSMaxY(rect))))
);
}

static NSView *GetFirstResponder(NSWindow *window)
{
NSResponder *responder = [window firstResponder];
Expand Down Expand Up @@ -170,32 +173,38 @@ static inline BOOL IsHorizontalNavigationWithinZoneAction(FocusZoneAction action
return focusZoneAncestor;
}

/// Bypass FocusZone if it's empty or has no focusable elements
static BOOL ShouldSkipFocusZone(NSView *view)
@implementation RCTFocusZone

- (NSView *)firstFocusableView
{
if([view isKindOfClass:[RCTFocusZone class]])
{
NSView *keyView = GetFirstKeyViewWithin(view);
// FocusZone is empty or has no focusable elements
if (keyView == nil)
{
return YES;
}
}
return GetFirstKeyViewWithin(self, [self forceFocus]);
}

return NO;
- (NSView *)lastFocusableView
{
return GetLastKeyViewWithin(self, [self forceFocus]);
}

/// Accept firstResponder on FocusZone itself in order to reassign it within the FocusZone.
/// Accept firstResponder on FocusZone itself in order to reassign it within the FocusZone with `becomeFirstResponder`.
/// Reject firstResponder if FocusZone is disabled or should be skipped.
- (BOOL)acceptsFirstResponder
{
// Reject first responder if FocusZone is disabled or should be skipped.
return !_disabled && !ShouldSkipFocusZone(self);
BOOL rejectsResponderStatus = NO;
if (!_disabled) {
// Bypass FocusZone if it's empty or has no focusable elements
NSView *focusableView = [self firstFocusableView];
// FocusZone is empty or has no focusable elements
if (focusableView == nil)
{
rejectsResponderStatus = YES;
}
}
return !rejectsResponderStatus;
}

- (BOOL)becomeFirstResponder
{
NSView *keyView = _defaultKeyView ?: GetFirstKeyViewWithin(self);
NSView *keyView = _defaultKeyView ?: [self firstFocusableView];
return !_disabled && [[self window] makeFirstResponder:keyView];
}

Expand All @@ -210,7 +219,10 @@ - (NSView *)nextViewToFocusForCondition:(IsViewLeadingCandidateForNextFocus)isLe
NSView *candidateView = [queue firstObject];
[queue removeObjectAtIndex:0];

if ([candidateView isNotEqualTo:self] && [candidateView canBecomeKeyView] && isLeadingCandidate(candidateView))
if (
[candidateView isNotEqualTo:self] &&
ShouldFocusOnView(candidateView, [self forceFocus]) &&
isLeadingCandidate(candidateView))
{
nextViewToFocus = candidateView;
}
Expand Down Expand Up @@ -281,7 +293,7 @@ - (NSView *)nextViewToFocusForAction:(FocusZoneAction)action
if (!skip)
{
CGFloat distance = GetDistanceBetweenRects(firstResponderRect, candidateRect);

// If there are other candidate views inside the same ScrollView as the firstResponder,
// prefer those views over other views outside the scrollview, even if they are closer.
if ([firstResponderEnclosingScrollView isEqualTo:[candidateView enclosingScrollView]])
Expand Down Expand Up @@ -356,11 +368,11 @@ - (NSView *)nextViewToFocusWithFallback:(FocusZoneAction)action
{
if (action == FocusZoneActionDownArrow)
{
return GetFirstKeyViewWithin(self);
return [self firstFocusableView];
}
else if (action == FocusZoneActionUpArrow)
{
return GetLastKeyViewWithin(self);
return [self lastFocusableView];
}
}

Expand All @@ -387,7 +399,7 @@ - (NSView *)nextViewToFocusOutsideZone:(FocusZoneAction)action
NSView *nextViewToFocus;

[[self window] recalculateKeyViewLoop];

// Find the first view outside the FocusZone (or any parent FocusZones) to place focus
RCTFocusZone *focusZoneAncestor = GetFocusZoneAncestor(self);

Expand All @@ -406,7 +418,7 @@ - (NSView *)nextViewToFocusOutsideZone:(FocusZoneAction)action
{
nextViewToFocus = [nextViewToFocus previousValidKeyView];
}

// If the previous view is in a FocusZone, focus on its defaultKeyView
// (For FocusZoneActionTab, this is handled by becomeFirstResponder).
RCTFocusZone *focusZoneAncestor = GetFocusZoneAncestor(nextViewToFocus);
Expand Down
2 changes: 2 additions & 0 deletions packages/components/FocusZone/macos/RCTFocusZoneManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ @implementation RCTFocusZoneManager

RCT_EXPORT_MODULE()

RCT_EXPORT_VIEW_PROPERTY(forceFocus, BOOL)

RCT_EXPORT_VIEW_PROPERTY(disabled, BOOL)

RCT_CUSTOM_VIEW_PROPERTY(focusZoneDirection, NSString, RCTFocusZone)
Expand Down
5 changes: 3 additions & 2 deletions packages/components/FocusZone/src/FocusZone.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { findNodeHandle } from 'react-native';
import { findNodeHandle, Platform } from 'react-native';
import { FocusZoneProps, FocusZoneSlotProps, FocusZoneType } from './FocusZone.types';
import { IUseStyling, composable } from '@uifabricshared/foundation-composable';
import { mergeSettings } from '@uifabricshared/foundation-settings';
Expand All @@ -12,7 +12,7 @@ const filterOutComponentRef = (propName) => propName !== 'componentRef';

export const FocusZone = composable<FocusZoneType>({
usePrepareProps: (userProps: FocusZoneProps, useStyling: IUseStyling<FocusZoneType>) => {
const { componentRef, defaultTabbableElement, isCircularNavigation, ...rest } = userProps;
const { componentRef, defaultTabbableElement, isCircularNavigation, forceFocusMacOS, ...rest } = userProps;

const ftzRef = useViewCommandFocus(componentRef);

Expand All @@ -29,6 +29,7 @@ export const FocusZone = composable<FocusZoneType>({
slotProps: mergeSettings<FocusZoneSlotProps>(useStyling(userProps), {
root: {
...rest,
...(Platform.OS === 'macos' ? { forceFocus: forceFocusMacOS } : null),
defaultTabbableElement: targetNativeTag,
ref: ftzRef,
navigateAtEnd: isCircularNavigation ? 'NavigateWrap' : 'NavigateStopAtEnds',
Expand Down
37 changes: 30 additions & 7 deletions packages/components/FocusZone/src/FocusZone.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,32 +18,55 @@ export interface FocusZoneProps {
defaultTabbableElement?: React.RefObject<React.Component>;

/**
** Defines which arrows to react to
** Defines which arrow keys to react to
*/
focusZoneDirection?: FocusZoneDirection;

/**
** If set, the FocusZone will not be tabbable and keyboard navigation will be disabled
* Disabled the FocusZone, with slightly different behavior on macOS and win32.
Saadnajmi marked this conversation as resolved.
Show resolved Hide resolved
* On win32, the FocusZone will not be tabbable and keyboard navigation will be disabled.
* on macOS, the FocusZone will "pass through" events, and the children will respond as if there was no FocusZone.
*/
disabled?: boolean;

/* Circular Navigation prop */
/**
* Circular Navigation prop
* @platform win32
*/
Saadnajmi marked this conversation as resolved.
Show resolved Hide resolved
isCircularNavigation?: boolean;

/**
*Allows for 2D navigation. This navigation strategy takes into account the position of elements
on screen, and navigates in the direction the user selects to the nearest element.
* Allows for 2D navigation. This navigation strategy takes into account the position of elements
* on screen, and navigates in the direction the user selects to the nearest element.
* @platform win32
*/
use2DNavigation?: boolean;

/**
* Moves focus between all focusable views, rather than just key views.
*
* On macOS, not every focusable view is a key view (i.e: we can press Tab to move focus to it).
* Rather, there is a system preference to toggle which views are in the key view loop.
* This prop allows you to focus on all focusable views, rather than just key views.
* For more info, see https://microsoft.github.io/apple-ux-guide/KeyboardFocus.html
* @platform macOS
*/
forceFocusMacOS?: boolean;

/**
* Callback called when “focus” event triggered in FocusZone
*/
onFocus?: (e?: any) => void;
}

export interface NativeProps extends Omit<FocusZoneProps, 'isCircularNavigation'> {
navigateAtEnd?: NavigateAtEnd;
// Props on JS FocusZone that don't exist in the native module
interface NonNativeProps {
isCircularNavigation: FocusZoneProps['isCircularNavigation'];
forceFocusMacOS: FocusZoneProps['forceFocusMacOS'];
}
export interface NativeProps extends Exclude<FocusZoneProps, NonNativeProps> {
navigateAtEnd?: NavigateAtEnd; // win32 only
forceFocus?: boolean; // macOS only
}

export type FocusZoneDirection =
Expand Down