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

Make FocusTrapZone useful for focusing the first item in a virtual list #14606

Merged
merged 8 commits into from
Oct 29, 2020
Merged
Show file tree
Hide file tree
Changes from all 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,8 @@
{
"type": "patch",
"comment": "Make FocusTrapZone properly focus the first element of a FocusZone",
"packageName": "@uifabric/utilities",
"email": "[email protected]",
"dependentChangeType": "patch",
"date": "2020-09-18T21:26:51.071Z"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"type": "minor",
"comment": "Add 'firstFocusableTarget' prop to FocusTrapZone",
"packageName": "office-ui-fabric-react",
"email": "[email protected]",
"dependentChangeType": "patch",
"date": "2020-09-18T21:26:30.843Z"
}
Original file line number Diff line number Diff line change
Expand Up @@ -4880,7 +4880,9 @@ export interface IFocusTrapZoneProps extends React.HTMLAttributes<HTMLDivElement
disableFirstFocus?: boolean;
elementToFocusOnDismiss?: HTMLElement;
enableAriaHiddenSiblings?: boolean;
// @deprecated
firstFocusableSelector?: string | (() => string);
firstFocusableTarget?: string | ((element: HTMLElement) => HTMLElement | null);
focusPreviouslyFocusedInnerElement?: boolean;
forceFocusInsideTrap?: boolean;
ignoreExternalFocusing?: boolean;
Expand Down Expand Up @@ -5696,6 +5698,8 @@ export interface IListState<T = any> {
measureVersion?: number;
// (undocumented)
pages?: IPage<T>[];
// (undocumented)
pagesVersion?: {};
}

// @public (undocumented)
Expand Down Expand Up @@ -8570,7 +8574,7 @@ export class List<T = any> extends React.Component<IListProps<T>, IListState<T>>
// (undocumented)
componentDidMount(): void;
// (undocumented)
componentDidUpdate(): void;
componentDidUpdate(previousProps: IListProps, previousState: IListState<T>): void;
// (undocumented)
componentWillUnmount(): void;
// (undocumented)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,54 @@ describe('FocusTrapZone', () => {

expect(document.activeElement).toBe(buttonA);
});

it('Focuses on firstFocusableTarget selector on mount', async () => {
expect.assertions(1);

const { buttonC } = setupTest({ firstFocusableTarget: '.c' });

expect(document.activeElement).toBe(buttonC);
});

it('Focuses on firstFocusableTarget callback on mount', async () => {
expect.assertions(1);

const { buttonC } = setupTest({ firstFocusableTarget: (element: HTMLElement) => element.querySelector('.c') });

expect(document.activeElement).toBe(buttonC);
});

it('Does not focus on firstFocusableTarget selector on mount while disabled', async () => {
expect.assertions(1);

const activeElement = document.activeElement;

setupTest({ firstFocusableTarget: '.c', disabled: true });

expect(document.activeElement).toBe(activeElement);
});

it('Does not focus on firstFocusableTarget callback on mount while disabled', async () => {
expect.assertions(1);

const activeElement = document.activeElement;

setupTest({ firstFocusableTarget: (element: HTMLElement) => element.querySelector('.c'), disabled: true });

expect(document.activeElement).toBe(activeElement);
});

it('Falls back to first focusable element with invalid firstFocusableTarget selector', async () => {
const { buttonA } = setupTest({ firstFocusableTarget: '.invalidSelector' });

expect(document.activeElement).toBe(buttonA);
});

it('Falls back to first focusable element with invalid firstFocusableTarget callback', async () => {
const { buttonA } = setupTest({ firstFocusableTarget: () => null });

expect(document.activeElement).toBe(buttonA);
});
});

describe('Focusing the FTZ', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ export class FocusTrapZone extends React.Component<IFocusTrapZoneProps, {}> impl
}

public focus() {
const { focusPreviouslyFocusedInnerElement, firstFocusableSelector } = this.props;
// eslint-disable-next-line deprecation/deprecation
const { focusPreviouslyFocusedInnerElement, firstFocusableSelector, firstFocusableTarget } = this.props;

if (
focusPreviouslyFocusedInnerElement &&
Expand All @@ -158,7 +159,11 @@ export class FocusTrapZone extends React.Component<IFocusTrapZoneProps, {}> impl
let _firstFocusableChild: HTMLElement | null = null;

if (this._root.current) {
if (focusSelector) {
if (typeof firstFocusableTarget === 'string') {
ThomasMichon marked this conversation as resolved.
Show resolved Hide resolved
_firstFocusableChild = this._root.current.querySelector(firstFocusableTarget);
} else if (firstFocusableTarget) {
_firstFocusableChild = firstFocusableTarget(this._root.current);
} else if (focusSelector) {
_firstFocusableChild = this._root.current.querySelector('.' + focusSelector);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,17 @@ export interface IFocusTrapZoneProps extends React.HTMLAttributes<HTMLDivElement
/**
* Class name (not actual selector) for first focusable item. Do not append a dot.
* Only applies if `focusPreviouslyFocusedInnerElement` is false.
* @deprecated Use `firstFocusableTarget`, since it is more generic. `firstFocusableTarget` takes precendence if
* supplied.
*/
firstFocusableSelector?: string | (() => string);

/**
* Either a full query selector for the first focusable element, or a function to select the focusable element
* within the area directly.
*/
firstFocusableTarget?: string | ((element: HTMLElement) => HTMLElement | null);

/**
* Do not put focus onto the first element inside the focus trap zone.
* @defaultvalue false
Expand Down
49 changes: 26 additions & 23 deletions packages/office-ui-fabric-react/src/components/List/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export interface IListState<T = any> {
measureVersion?: number;
isScrolling?: boolean;
getDerivedStateFromProps(nextProps: IListProps<T>, previousState: IListState<T>): IListState<T>;

pagesVersion?: {};
}

interface IPageCacheItem<T> {
Expand Down Expand Up @@ -342,38 +344,38 @@ export class List<T = any> extends React.Component<IListProps<T>, IListState<T>>
}
}

public componentDidUpdate(): void {
// Multiple updates may have been queued, so the callback will reflect all of them.
// Re-fetch the current props and states to avoid using a stale props or state captured in the closure.
public componentDidUpdate(previousProps: IListProps, previousState: IListState<T>): void {
const finalProps = this.props;
const finalState = this.state;

// If we weren't provided with the page height, measure the pages
if (!finalProps.getPageHeight) {
// If measured version is invalid since we've updated the DOM
const heightsChanged = this._updatePageMeasurements(finalState.pages!);

// On first render, we should re-measure so that we don't get a visual glitch.
if (heightsChanged) {
this._materializedRect = null;
if (!this._hasCompletedFirstRender) {
this._hasCompletedFirstRender = true;
this.setState(this._updatePages(finalProps, finalState));
if (this.state.pagesVersion !== previousState.pagesVersion) {
// If we weren't provided with the page height, measure the pages
if (!finalProps.getPageHeight) {
// If measured version is invalid since we've updated the DOM
const heightsChanged = this._updatePageMeasurements(finalState.pages!);

// On first render, we should re-measure so that we don't get a visual glitch.
if (heightsChanged) {
this._materializedRect = null;
if (!this._hasCompletedFirstRender) {
this._hasCompletedFirstRender = true;
this.setState(this._updatePages(finalProps, finalState));
} else {
this._onAsyncScroll();
}
} else {
this._onAsyncScroll();
// Enqueue an idle bump.
this._onAsyncIdle();
}
} else {
// Enqueue an idle bump.
// Enqueue an idle bump
this._onAsyncIdle();
}
} else {
// Enqueue an idle bump
this._onAsyncIdle();
}

// Notify the caller that rendering the new pages has completed
if (finalProps.onPagesUpdated) {
finalProps.onPagesUpdated(finalState.pages as IPage<T>[]);
// Notify the caller that rendering the new pages has completed
if (finalProps.onPagesUpdated) {
finalProps.onPagesUpdated(finalState.pages as IPage<T>[]);
}
}
}

Expand Down Expand Up @@ -719,6 +721,7 @@ export class List<T = any> extends React.Component<IListProps<T>, IListState<T>>
return {
...previousState,
...newListState,
pagesVersion: {},
};
}

Expand Down
15 changes: 14 additions & 1 deletion packages/utilities/src/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,10 +467,23 @@ export function focusAsync(element: HTMLElement | { focus: () => void } | undefi
if (win) {
// element.focus() is a no-op if the element is no longer in the DOM, meaning this is always safe
win.requestAnimationFrame(() => {
targetToFocusOnNextRepaint && targetToFocusOnNextRepaint.focus();
const focusableElement = targetToFocusOnNextRepaint as HTMLElement | null;

// We are done focusing for this frame, so reset the queued focus element
targetToFocusOnNextRepaint = undefined;

if (focusableElement) {
if (focusableElement.getAttribute && focusableElement.getAttribute(IS_FOCUSABLE_ATTRIBUTE) === 'true') {
ThomasMichon marked this conversation as resolved.
Show resolved Hide resolved
// Normally, a FocusZone would be responsible for setting the tabindex values on all its descendants.
// However, even this animation frame callback can pre-empt the rendering of a FocusZone's child elements,
// so it may be necessary to set the tabindex directly here.
if (!focusableElement.getAttribute('tabindex')) {
focusableElement.setAttribute('tabindex', '0');
}
}

focusableElement.focus();
}
});
}
}
Expand Down