Skip to content

Commit

Permalink
feat(FloatingMenu): conditionally set focus in floating menu on menu …
Browse files Browse the repository at this point in the history
…open (#5489)

* fix(link): specify properties to transition

* chore(settings): document `selectorFocusable`

* feat(navigation): add selectorFocusable string

* feat: add support for selectorPrimaryFocus

* feat(FloatingMenu): set focus in menu body on menu open

* refactor(OverflowMenu): use FloatingMenu initial focus logic

* docs(FloatingMenu): reference correct prop name

* chore: update snapshots

* fix(FloatingMenu): check if active element is within menu body

* fix(FloatingMenu): primaryFocusNode NPE check

* docs: expose selectorPrimaryFocus knobs

* chore(OverflowMenuItem): deprecate `primaryFocus`

* chore: update snapshots
  • Loading branch information
emyarod authored Jun 1, 2020
1 parent eca64a2 commit 3e1a8f0
Show file tree
Hide file tree
Showing 13 changed files with 112 additions and 55 deletions.
2 changes: 1 addition & 1 deletion packages/components/src/components/link/_link.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
color: $link-01;
text-decoration: none;
outline: none;
transition: $duration--fast-01 motion(standard, productive);
transition: color $duration--fast-01 motion(standard, productive);

&:hover {
color: $hover-primary-text;
Expand Down
6 changes: 5 additions & 1 deletion packages/components/src/globals/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@
* // @todo given that the default value is so long, is it appropriate to put in the JSDoc?
* @property {string} [selectorTabbable]
* A selector selecting tabbable/focusable nodes.
* By default selectorTabbable refereneces links, areas, inputs, buttons, selects, textareas,
* By default selectorTabbable references links, areas, inputs, buttons, selects, textareas,
* iframes, objects, embeds, or elements explicitly using tabindex or contenteditable attributes
* as long as the element is not `disabled` or the `tabindex="-1"`.
* @property {string} [selectorFocusable]
* CSS selector that selects major nodes that are click focusable
* This property is identical to selectorTabbable with the exception of
* the `:not([tabindex='-1'])` pseudo class
*/
const settings = {
prefix: 'bx',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3830,9 +3830,7 @@ Map {
"onMouseUp": Object {
"type": "func",
},
"primaryFocus": Object {
"type": "bool",
},
"primaryFocus": [Function],
"requireTitle": Object {
"type": "bool",
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2397,6 +2397,7 @@ exports[`DataTable should render 1`] = `
"render": [Function],
}
}
selectorPrimaryFocus="[data-overflow-menu-primary-focus]"
tabIndex={0}
title="Settings"
>
Expand Down Expand Up @@ -3373,6 +3374,7 @@ exports[`DataTable sticky header should render 1`] = `
"render": [Function],
}
}
selectorPrimaryFocus="[data-overflow-menu-primary-focus]"
tabIndex={0}
title="Settings"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ exports[`DataTable.TableToolbarMenu should render 1`] = `
"render": [Function],
}
}
selectorPrimaryFocus="[data-overflow-menu-primary-focus]"
tabIndex={0}
title="Add"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default props => (
))}
<TableCell className="bx--table-column-menu">
<OverflowMenu flipped>
<OverflowMenuItem primaryFocus>Action 1</OverflowMenuItem>
<OverflowMenuItem>Action 1</OverflowMenuItem>
<OverflowMenuItem>Action 2</OverflowMenuItem>
<OverflowMenuItem>Action 3</OverflowMenuItem>
</OverflowMenu>
Expand Down
17 changes: 6 additions & 11 deletions packages/react/src/components/OverflowMenu/OverflowMenu-story.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const props = {
iconDescription: text('Icon description (iconDescription)', ''),
flipped: boolean('Flipped (flipped)', false),
light: boolean('Light (light)', false),
selectorPrimaryFocus: text(
'Primary focus element selector (selectorPrimaryFocus)',
''
),
onClick: action('onClick'),
onFocus: action('onFocus'),
onKeyDown: action('onKeyDown'),
Expand All @@ -51,11 +55,7 @@ storiesOf('OverflowMenu', module)
'basic',
withReadme(OverflowREADME, () => (
<OverflowMenu {...props.menu()}>
<OverflowMenuItem
{...props.menuItem()}
itemText="Option 1"
primaryFocus
/>
<OverflowMenuItem {...props.menuItem()} itemText="Option 1" />
<OverflowMenuItem
{...props.menuItem()}
itemText="Option 2 is an example of a really long string and how we recommend handling this"
Expand Down Expand Up @@ -90,7 +90,6 @@ storiesOf('OverflowMenu', module)
href: 'https://www.ibm.com',
}}
itemText="Option 1"
primaryFocus
/>
<OverflowMenuItem
{...{
Expand Down Expand Up @@ -146,11 +145,7 @@ storiesOf('OverflowMenu', module)
style: { width: 'auto' },
renderIcon: () => <div style={{ padding: '0 1rem' }}>Menu</div>,
}}>
<OverflowMenuItem
{...props.menuItem()}
itemText="Option 1"
primaryFocus
/>
<OverflowMenuItem {...props.menuItem()} itemText="Option 1" />
<OverflowMenuItem
{...props.menuItem()}
itemText="Option 2 is an example of a really long string and how we recommend handling this"
Expand Down
35 changes: 10 additions & 25 deletions packages/react/src/components/OverflowMenu/OverflowMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,12 @@ class OverflowMenu extends Component {
* Don't use this to make OverflowMenu background color same as container background color.
*/
light: PropTypes.bool,

/**
* Specify a CSS selector that matches the DOM element that should
* be focused when the OverflowMenu opens
*/
selectorPrimaryFocus: PropTypes.string,
};

static defaultProps = {
Expand All @@ -225,6 +231,7 @@ class OverflowMenu extends Component {
menuOffset: getMenuOffset,
menuOffsetFlip: getMenuOffset,
light: false,
selectorPrimaryFocus: '[data-overflow-menu-primary-focus]',
};

/**
Expand All @@ -246,26 +253,6 @@ class OverflowMenu extends Component {
*/
_triggerRef = React.createRef();

getPrimaryFocusableElement = () => {
const { current: triggerEl } = this._triggerRef;
if (triggerEl) {
const primaryFocusPropEl = triggerEl.querySelector(
'[data-floating-menu-primary-focus]'
);
if (primaryFocusPropEl) {
return primaryFocusPropEl;
}
}
const firstItem = this.overflowMenuItem0;
if (
firstItem &&
firstItem.overflowMenuItem &&
firstItem.overflowMenuItem.current
) {
return firstItem.overflowMenuItem.current;
}
};

componentDidUpdate(_, prevState) {
const { onClose } = this.props;
if (!this.state.open && prevState.isOpen) {
Expand Down Expand Up @@ -393,10 +380,6 @@ class OverflowMenu extends Component {
_handlePlace = menuBody => {
if (menuBody) {
this._menuBody = menuBody;
const primaryFocus =
menuBody.querySelector('[data-floating-menu-primary-focus]') ||
menuBody;
primaryFocus.focus();
const hasFocusin = 'onfocusin' in window;
const focusinEventName = hasFocusin ? 'focusin' : 'focus';
this._hFocusIn = on(
Expand Down Expand Up @@ -446,6 +429,7 @@ class OverflowMenu extends Component {
iconClass,
onClick, // eslint-disable-line
onOpen, // eslint-disable-line
selectorPrimaryFocus = '[data-floating-menu-primary-focus]', // eslint-disable-line
renderIcon: IconElement,
innerRef: ref,
menuOptionsClass,
Expand Down Expand Up @@ -509,7 +493,8 @@ class OverflowMenu extends Component {
menuRef={this._bindMenuBody}
flipped={this.props.flipped}
target={this._getTarget}
onPlace={this._handlePlace}>
onPlace={this._handlePlace}
selectorPrimaryFocus={this.props.selectorPrimaryFocus}>
{React.cloneElement(menuBody, {
'data-floating-menu-direction': direction,
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import classNames from 'classnames';
import warning from 'warning';
import { settings } from 'carbon-components';
import { match, keys } from '../../internal/keyboard';
import deprecate from '../../prop-types/deprecate.js';

const { prefix } = settings;

Expand Down Expand Up @@ -72,7 +73,13 @@ export default class OverflowMenuItem extends React.Component {
/**
* `true` if this menu item should get focus when the menu gets open.
*/
primaryFocus: PropTypes.bool,
primaryFocus: deprecate(
PropTypes.bool,
'The `primaryFocus` prop has been deprecated as it is no longer used. ' +
'Feel free to remove this prop from <OverflowMenuItem>. This prop will ' +
'be removed in the next major release of `carbon-components-react`. ' +
'Opt for `selectorPrimaryFocus` in `<OverflowMenu>` instead'
),

/**
* `true` if this menu item has long text and requires a browser tooltip
Expand Down Expand Up @@ -149,9 +156,6 @@ export default class OverflowMenuItem extends React.Component {
},
wrapperClassName
);
const primaryFocusProp = primaryFocus
? { 'data-floating-menu-primary-focus': true }
: {};
const TagToUse = href ? 'a' : 'button';
const OverflowMenuItemContent = (() => {
if (typeof itemText !== 'string') {
Expand All @@ -167,7 +171,7 @@ export default class OverflowMenuItem extends React.Component {
<li className={overflowMenuItemClasses} role="menuitem">
<TagToUse
{...other}
{...primaryFocusProp}
{...{ 'data-floating-menu-primary-focus': primaryFocus || null }}
href={href}
className={overflowMenuBtnClasses}
disabled={disabled}
Expand Down
16 changes: 16 additions & 0 deletions packages/react/src/components/Tooltip/Tooltip-story.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,30 @@ const props = {
direction: select('Tooltip direction (direction)', directions, 'bottom'),
triggerText: text('Trigger text (triggerText)', 'Tooltip label'),
tabIndex: number('Tab index (tabIndex in <Tooltip>)', 0),
selectorPrimaryFocus: text(
'Primary focus element selector (selectorPrimaryFocus)',
''
),
}),
withoutIcon: () => ({
showIcon: false,
direction: select('Tooltip direction (direction)', directions, 'bottom'),
triggerText: text('Trigger text (triggerText)', 'Tooltip label'),
tabIndex: number('Tab index (tabIndex in <Tooltip>)', 0),
selectorPrimaryFocus: text(
'Primary focus element selector (selectorPrimaryFocus)',
''
),
}),
customIcon: () => ({
showIcon: true,
direction: select('Tooltip direction (direction)', directions, 'bottom'),
triggerText: text('Trigger text (triggerText)', 'Tooltip label'),
tabIndex: number('Tab index (tabIndex in <Tooltip>)', 0),
selectorPrimaryFocus: text(
'Primary focus element selector (selectorPrimaryFocus)',
''
),
renderIcon: React.forwardRef((props, ref) => (
<div ref={ref}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
Expand All @@ -52,6 +64,10 @@ const props = {
direction: select('Tooltip direction (direction)', directions, 'bottom'),
iconDescription: 'Helpful Information',
tabIndex: number('Tab index (tabIndex in <Tooltip>)', 0),
selectorPrimaryFocus: text(
'Primary focus element selector (selectorPrimaryFocus)',
''
),
renderIcon: OverflowMenuVertical16,
}),
};
Expand Down
9 changes: 9 additions & 0 deletions packages/react/src/components/Tooltip/Tooltip.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ class Tooltip extends Component {
*/
direction: PropTypes.oneOf(['bottom', 'top', 'left', 'right']),

/**
* Specify a CSS selector that matches the DOM element that should
* be focused when the Tooltip opens
*/
selectorPrimaryFocus: PropTypes.string,

/**
* The adjustment of the tooltip position.
*/
Expand Down Expand Up @@ -201,6 +207,7 @@ class Tooltip extends Component {
showIcon: true,
triggerText: null,
menuOffset: getMenuOffset,
selectorPrimaryFocus: '[data-tooltip-primary-focus]',
};

/**
Expand Down Expand Up @@ -381,6 +388,7 @@ class Tooltip extends Component {
menuOffset,
tabIndex = 0,
innerRef: ref,
selectorPrimaryFocus, // eslint-disable-line
...other
} = this.props;

Expand Down Expand Up @@ -447,6 +455,7 @@ class Tooltip extends Component {
</ClickListener>
{open && (
<FloatingMenu
selectorPrimaryFocus={this.props.selectorPrimaryFocus}
target={this._getTarget}
triggerRef={this._triggerRef}
menuDirection={direction}
Expand Down
47 changes: 40 additions & 7 deletions packages/react/src/internal/FloatingMenu.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import ReactDOM from 'react-dom';
import window from 'window-or-global';
import { settings } from 'carbon-components';
import OptimizedResize from './OptimizedResize';
import { selectorFocusable, selectorTabbable } from './keyboard/navigation';

const { prefix } = settings;

Expand Down Expand Up @@ -175,6 +176,12 @@ class FloatingMenu extends React.Component {
PropTypes.func,
]),

/**
* Specify a CSS selector that matches the DOM element that should
* be focused when the Modal opens
*/
selectorPrimaryFocus: PropTypes.string,

/**
* The additional styles to put to the floating menu.
*/
Expand Down Expand Up @@ -293,17 +300,43 @@ class FloatingMenu extends React.Component {
this._updateMenuSize();
});
}
/**
* Set focus on floating menu content after menu placement.
* @param {Element} menuBody The DOM element of the menu body.
* @private
*/
_focusMenuContent = menuBody => {
const primaryFocusNode = menuBody.querySelector(
this.props.selectorPrimaryFocus || null
);
const tabbableNode = menuBody.querySelector(selectorTabbable);
const focusableNode = menuBody.querySelector(selectorFocusable);
const focusTarget =
primaryFocusNode || // User defined focusable node
tabbableNode || // First sequentially focusable node
focusableNode || // First programmatic focusable node
menuBody;
focusTarget.focus();
if (focusTarget === menuBody && __DEV__) {
warning(
focusableNode === null,
'Floating Menus must have at least a programmatically focusable child. ' +
'This can be accomplished by adding tabIndex="-1" to the content element.'
);
}
};

componentDidUpdate(prevProps) {
this._updateMenuSize(prevProps);
const { onPlace } = this.props;
if (
this._placeInProgress &&
this.state.floatingPosition &&
typeof onPlace === 'function'
) {
onPlace(this._menuBody);
this._placeInProgress = false;
if (this._placeInProgress && this.state.floatingPosition) {
if (this._menuBody && !this._menuBody.contains(document.activeElement)) {
this._focusMenuContent(this._menuBody);
}
if (typeof onPlace === 'function') {
onPlace(this._menuBody);
this._placeInProgress = false;
}
}
}

Expand Down
Loading

0 comments on commit 3e1a8f0

Please sign in to comment.