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

feat(FloatingMenu): conditionally set focus in floating menu on menu open #5489

Merged
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