Skip to content

Commit

Permalink
FocusZone (macOS): New prop to ignore OS keyboard shortcut preference (
Browse files Browse the repository at this point in the history
…#2267)

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

* Rename

* Change files

* Clean up types

* Don't extend IViewProps

* Update comment

* Simplify logic

* Update FocusZone.types.ts

* Update documentation
  • Loading branch information
Saadnajmi authored Oct 27, 2022
1 parent c3b4c01 commit d5c85ec
Show file tree
Hide file tree
Showing 9 changed files with 134 additions and 69 deletions.
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"
}
1 change: 1 addition & 0 deletions packages/components/ContextualMenu/src/ContextualMenu.tsx
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
142 changes: 82 additions & 60 deletions packages/components/FocusZone/macos/RCTFocusZone.m
Original file line number Diff line number Diff line change
Expand Up @@ -18,53 +18,31 @@
// enumerated in the same row (or column) as the current focused view
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)
/// Returns whether FocusZone should move focus on the view in question.
/// - Parameters:
/// - view: The view to check if we should move focus to, should be a child / subview of the FocusZone in question.
/// - forceFocus: Check if we should focus on all views that accept first responder status, rather than just key views.
BOOL ShouldFocusOnView(NSView *view, BOOL focusOnAllFirstResponders)
{
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 focusOnAllFirstResponders ? [view acceptsFirstResponder] : [view canBecomeKeyView];
}

/// 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;
}

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 +53,53 @@ 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))))
);
}

/// Returns the first NSView in the given windows NSResponder chain.
/// - Parameter window: The window whose NSResponder chain we should check.
static NSView *GetFirstResponder(NSWindow *window)
{
NSResponder *responder = [window firstResponder];
Expand All @@ -100,6 +110,9 @@ static inline CGFloat GetMinDistanceBetweenRectVerticesAndPoint(NSRect rect, NSP
return [responder isKindOfClass:[NSView class]] ? (NSView *)responder : nil;
}


/// Returns a `FocusZoneAction` for a given NSEvent
/// - Parameter event: The event to interpret into a command. Should be a keyDown event.
static FocusZoneAction GetActionForEvent(NSEvent *event)
{
FocusZoneAction action = FocusZoneActionNone;
Expand Down Expand Up @@ -170,32 +183,39 @@ 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;
}

/// Reassign firstResponder within the FocusZone to either the default key view or first focusable view.
- (BOOL)becomeFirstResponder
{
NSView *keyView = _defaultKeyView ?: GetFirstKeyViewWithin(self);
NSView *keyView = _defaultKeyView ?: [self firstFocusableView];
return !_disabled && [[self window] makeFirstResponder:keyView];
}

Expand All @@ -210,7 +230,9 @@ - (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 +303,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 +378,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 +409,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 +428,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
* Disables the FocusZone, with slightly different behavior on macOS and win32.
* 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
*/
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

0 comments on commit d5c85ec

Please sign in to comment.