Skip to content

Commit 679f3c0

Browse files
Merge pull request #431 from caseywilliams/select-option-groups
2 parents ad8d8a8 + 16ab845 commit 679f3c0

File tree

12 files changed

+867
-114
lines changed

12 files changed

+867
-114
lines changed

CHANGELOG.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
# react-component 5.28.0 (2021-05-06)
1+
# react-components 5.29.0 (2021-06-01)
2+
3+
- [Select] Add support for option groups to the Select component and fix keyboard handling of disabled options (by [@caseywilliams](https://github.com/caseywilliams)) in [#431](https://github.com/puppetlabs/design-system/pull/431)
4+
5+
# react-components 5.28.0 (2021-05-06)
26

37
- [Alerts] Add Alerts component (by [@vine77](https://github.com/vine77) in [#416](https://github.com/puppetlabs/design-system/pull/416))
48
- [Breadcrumb] Add `disabled` prop to Breadcrumb.Section (by [@mardotio](https://github.com/mardotio)) in [#414](https://github.com/puppetlabs/design-system/pull/414)

packages/data-grid/src/__test__/__snapshots__/quickFitler.test.jsx.snap

+6
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ exports[`Snapshot test Check component matches previous HTML snapshot 1`] = `
253253
onMouseEnter={[Function]}
254254
selected={false}
255255
svg={null}
256+
type="option"
256257
>
257258
<li
258259
aria-selected={false}
@@ -305,6 +306,7 @@ exports[`Snapshot test Check component matches previous HTML snapshot 1`] = `
305306
onMouseEnter={[Function]}
306307
selected={false}
307308
svg={null}
309+
type="option"
308310
>
309311
<li
310312
aria-selected={false}
@@ -357,6 +359,7 @@ exports[`Snapshot test Check component matches previous HTML snapshot 1`] = `
357359
onMouseEnter={[Function]}
358360
selected={false}
359361
svg={null}
362+
type="option"
360363
>
361364
<li
362365
aria-selected={false}
@@ -602,6 +605,7 @@ exports[`Snapshot test Check component matches previous HTML snapshot 1`] = `
602605
onMouseEnter={[Function]}
603606
selected={false}
604607
svg={null}
608+
type="option"
605609
>
606610
<li
607611
aria-selected={false}
@@ -654,6 +658,7 @@ exports[`Snapshot test Check component matches previous HTML snapshot 1`] = `
654658
onMouseEnter={[Function]}
655659
selected={false}
656660
svg={null}
661+
type="option"
657662
>
658663
<li
659664
aria-selected={false}
@@ -706,6 +711,7 @@ exports[`Snapshot test Check component matches previous HTML snapshot 1`] = `
706711
onMouseEnter={[Function]}
707712
selected={false}
708713
svg={null}
714+
type="option"
709715
>
710716
<li
711717
aria-selected={false}

packages/react-components/package-lock.json

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/react-components/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@puppet/react-components",
3-
"version": "5.28.0",
3+
"version": "5.29.0",
44
"author": "Puppet, Inc.",
55
"license": "Apache-2.0",
66
"main": "build/library.js",

packages/react-components/source/react/helpers/customPropTypes.js

+14
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import PropTypes from 'prop-types';
2+
import Icon from '../library/icon';
23

34
/**
45
* Design system available element elevations
@@ -81,3 +82,16 @@ export const deprecated = message => typeChecker => {
8182
return typeChecker(props, key, componentName, location, propFullName);
8283
};
8384
};
85+
86+
export const optionMenuItemShape = {
87+
/** Value of the option */
88+
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
89+
/** The label to show */
90+
label: PropTypes.node.isRequired,
91+
/** Optional icon associated with this option */
92+
icon: PropTypes.oneOf(Icon.AVAILABLE_ICONS),
93+
/** Optional custom icon associated with this option */
94+
svg: PropTypes.element,
95+
/** Whether this option is disabled */
96+
disabled: PropTypes.bool,
97+
};

packages/react-components/source/react/internal/option-menu-list/OptionMenuList.js

+125-49
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
33
import classNames from 'classnames';
44
import scrollIntoView from 'scroll-into-view-if-needed';
55
import { isNil, focus, cancelEvent } from '../../helpers/statics';
6+
import { optionMenuItemShape } from '../../helpers/customPropTypes';
67

78
import {
89
UP_KEY_CODE,
@@ -15,21 +16,20 @@ import {
1516
} from '../../constants';
1617

1718
import OptionMenuListItem from './OptionMenuListItem';
18-
import Icon from '../../library/icon';
1919

2020
const propTypes = {
2121
id: PropTypes.string.isRequired,
2222
multiple: PropTypes.bool,
2323
autocomplete: PropTypes.bool,
2424
showCancel: PropTypes.bool,
2525
options: PropTypes.arrayOf(
26-
PropTypes.shape({
27-
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number])
28-
.isRequired,
29-
label: PropTypes.node.isRequired,
30-
icon: PropTypes.oneOf(Icon.AVAILABLE_ICONS),
31-
disabled: PropTypes.bool,
32-
}),
26+
PropTypes.oneOfType([
27+
PropTypes.shape(optionMenuItemShape),
28+
PropTypes.shape({
29+
...optionMenuItemShape,
30+
value: PropTypes.arrayOf(PropTypes.shape(optionMenuItemShape)),
31+
}),
32+
]),
3333
),
3434
selected: PropTypes.oneOfType([
3535
PropTypes.string,
@@ -72,13 +72,21 @@ const defaultProps = {
7272

7373
const getOptionId = (id, value) => `${id}-${value}`;
7474

75+
const getFocusableOptions = options =>
76+
options.map(opt => (Array.isArray(opt.value) ? opt.value : opt)).flat();
77+
7578
const getFocusedId = (focusedIndex, id, options) =>
76-
isNil(focusedIndex) || focusedIndex >= options.length
79+
typeof focusedIndex !== 'number' || focusedIndex >= options.length
7780
? undefined
78-
: getOptionId(id, options[focusedIndex].value);
81+
: getOptionId(
82+
id,
83+
getFocusableOptions(options)[Math.max(focusedIndex, 0)].value,
84+
);
7985

8086
const getSelectionSet = selection =>
81-
new Set(Array.isArray(selection) ? selection : [selection]);
87+
new Set(
88+
(Array.isArray(selection) ? selection : [selection]).filter(el => !!el),
89+
);
8290

8391
class OptionMenuList extends Component {
8492
constructor(props) {
@@ -132,7 +140,11 @@ class OptionMenuList extends Component {
132140
}
133141

134142
onMouseEnterItem(focusedIndex) {
135-
this.focusItem(focusedIndex);
143+
if (typeof focusedIndex === 'number') {
144+
this.focusItem(focusedIndex);
145+
} else {
146+
this.setState({ focusedIndex: null });
147+
}
136148
}
137149

138150
onCancel(e) {
@@ -159,12 +171,15 @@ class OptionMenuList extends Component {
159171
if (isNil(focusedIndex)) {
160172
this.focusFirst();
161173
} else {
162-
this.focusItem(Math.min(options.length - 1, focusedIndex + 1));
174+
this.focusItem(
175+
Math.min(getFocusableOptions(options).length - 1, focusedIndex + 1),
176+
);
163177
}
164178
}
165179

166180
onKeyDown(e) {
167-
const { onEscape, onClickItem } = this.props;
181+
const { onEscape, onClickItem, options } = this.props;
182+
const { focusedIndex } = this.state;
168183

169184
switch (e.keyCode) {
170185
case UP_KEY_CODE: {
@@ -189,8 +204,11 @@ class OptionMenuList extends Component {
189204
}
190205
case SPACE_KEY_CODE:
191206
case ENTER_KEY_CODE: {
192-
this.selectFocusedItem();
193-
onClickItem();
207+
const focused = getFocusableOptions(options)[focusedIndex];
208+
if (focused && !focused.disabled) {
209+
this.selectFocusedItem();
210+
onClickItem();
211+
}
194212
cancelEvent(e);
195213
break;
196214
}
@@ -245,7 +263,7 @@ class OptionMenuList extends Component {
245263
focusLast() {
246264
const { options } = this.props;
247265

248-
this.focusItem(options.length - 1);
266+
this.focusItem(getFocusableOptions(options).length - 1);
249267
}
250268

251269
focusItem(focusedIndex) {
@@ -290,25 +308,14 @@ class OptionMenuList extends Component {
290308
const { options } = this.props;
291309

292310
if (!isNil(focusedIndex)) {
293-
const { value } = options[focusedIndex];
311+
const { value } = getFocusableOptions(options)[focusedIndex];
294312

295313
this.select(value);
296314
}
297315
}
298316

299317
/* eslint-disable jsx-a11y/click-events-have-key-events */
300318
render() {
301-
const {
302-
onClickItem,
303-
onMouseEnterItem,
304-
onCancel,
305-
onKeyDown,
306-
onKeyDownInAction,
307-
onFocus,
308-
onMenuBlur,
309-
onActionBlur,
310-
} = this;
311-
const { focusedIndex } = this.state;
312319
const {
313320
id,
314321
options,
@@ -323,7 +330,6 @@ class OptionMenuList extends Component {
323330
className,
324331
style,
325332
onBlur,
326-
focusedIndex: focussed,
327333
onFocusItem,
328334
footer,
329335
onClickItem: onClick,
@@ -334,8 +340,95 @@ class OptionMenuList extends Component {
334340
return null;
335341
}
336342

343+
const {
344+
onClickItem,
345+
onMouseEnterItem,
346+
onCancel,
347+
onKeyDown,
348+
onKeyDownInAction,
349+
onFocus,
350+
onMenuBlur,
351+
onActionBlur,
352+
} = this;
353+
337354
const selectionSet = getSelectionSet(selected);
338-
const focusedId = getFocusedId(focusedIndex, id, options);
355+
356+
delete rest.focusedIndex;
357+
const { focusedIndex } = this.state;
358+
const focusedId = getFocusedId(
359+
focusedIndex,
360+
id,
361+
getFocusableOptions(options),
362+
);
363+
364+
const renderListItems = (items, offset = 0) => {
365+
const list = [];
366+
367+
items.forEach(item => {
368+
if (Array.isArray(item.value)) {
369+
const groupId = `group-${item.value
370+
.map(child => child.value)
371+
.join('-')}`;
372+
const labelId = `${groupId}-label`;
373+
374+
list.push(
375+
<ul
376+
role="group"
377+
aria-labelledby={labelId}
378+
className="rc-menu-list-group"
379+
id={groupId}
380+
key={groupId}
381+
>
382+
{item.label && (
383+
<OptionMenuListItem
384+
type="heading"
385+
disabled={item.disabled}
386+
id={labelId}
387+
key={labelId}
388+
onMouseEnter={() => onMouseEnterItem(null)}
389+
>
390+
{item.label}
391+
</OptionMenuListItem>
392+
)}
393+
{renderListItems(
394+
item.value.map(child =>
395+
Object.assign(child, {
396+
disabled: item.disabled || child.disabled,
397+
}),
398+
),
399+
list.length + offset,
400+
)}
401+
</ul>,
402+
);
403+
// eslint-disable-next-line no-param-reassign
404+
offset += item.value.length - 1;
405+
} else {
406+
const index = list.length + offset;
407+
list.push(
408+
<OptionMenuListItem
409+
id={getOptionId(id, item.value)}
410+
key={item.value}
411+
focused={index === focusedIndex}
412+
selected={selectionSet.has(item.value)}
413+
icon={item.icon}
414+
svg={item.svg}
415+
disabled={item.disabled}
416+
onClick={() =>
417+
item.disabled ? undefined : onClickItem(item.value)
418+
}
419+
onMouseEnter={() => onMouseEnterItem(index)}
420+
ref={option => {
421+
this.optionRefs[index] = option;
422+
}}
423+
>
424+
{item.label}
425+
</OptionMenuListItem>,
426+
);
427+
}
428+
});
429+
430+
return list;
431+
};
339432

340433
const list = (
341434
<ul
@@ -353,24 +446,7 @@ class OptionMenuList extends Component {
353446
}}
354447
{...rest}
355448
>
356-
{options.map(({ value, label, icon, svg, disabled }, index) => (
357-
<OptionMenuListItem
358-
id={getOptionId(id, value)}
359-
key={value}
360-
focused={index === focusedIndex}
361-
selected={selectionSet.has(value)}
362-
icon={icon}
363-
svg={svg}
364-
disabled={disabled}
365-
onClick={disabled ? undefined : () => onClickItem(value)}
366-
onMouseEnter={() => onMouseEnterItem(index)}
367-
ref={option => {
368-
this.optionRefs[index] = option;
369-
}}
370-
>
371-
{label}
372-
</OptionMenuListItem>
373-
))}
449+
{renderListItems(options)}
374450
</ul>
375451
);
376452

0 commit comments

Comments
 (0)