Skip to content

Commit

Permalink
lib: Port Cockpit selects to native HTML
Browse files Browse the repository at this point in the history
These elements are properly accessible and keyboard-navigatable.

Define new .ct-select CSS class to style them exactly like our old ones.

Elimininate the `SelectHeader` component. An `<optgroup>` must contain
the grouped options, not just be an empty element in between. There is
no remaining additional functionality, so use `<optgroup>` in the VM
creation dialog directly.

Closes #11155
  • Loading branch information
garrett authored and martinpitt committed Mar 6, 2019
1 parent 80b36ac commit 3423064
Show file tree
Hide file tree
Showing 24 changed files with 218 additions and 310 deletions.
1 change: 1 addition & 0 deletions pkg/docker/docker.css
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ div.spinner {
}

/* workaround: make text red for select if contained in has-error, not in patternfly */
.has-error > .ct-select,
.has-error > .dropdown > button > span {
color: #A94442;
}
Expand Down
160 changes: 31 additions & 129 deletions pkg/lib/cockpit-components-select.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
*/

import React from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import cockpit from "cockpit";

Expand All @@ -29,141 +28,48 @@ const _ = cockpit.gettext;
const textForUndefined = _("undefined");

/* React pattern component for a dropdown/select control
* Entries should be child components of type SelectEntry (html <a>)
* Entries should be child components of type SelectEntry
*
* User of this component should listen onChange and set selected prop of it
*
* Expected properties:
* - selected (optional) explicit data to select, default: first entry
* - onChange (optional) callback (parameter data) when the selection has changed
* - onChange (required) callback (parameter data) when the selection has changed
* - id (optional) html id of the top level node
* - enabled (optional) whether the component is enabled or not; defaults to true
* - extraClass (optional) CSS class name(s) to be added to the main <div> of the component
* - extraClass (optional) CSS class name(s) to be added to the main <select> of the component
*/
export class StatelessSelect extends React.Component {
constructor() {
super();
this.clickHandler = this.clickHandler.bind(this);

this.state = {
open: false,
documentClickHandler: undefined,
};
}

componentDidMount() {
const handler = this.handleDocumentClick.bind(this, ReactDOM.findDOMNode(this));
this.setState({ documentClickHandler: handler });
document.addEventListener('click', handler, false);
}

componentWillUnmount() {
document.removeEventListener('click', this.state.documentClickHandler, false);
}

handleDocumentClick(node, ev) {
// clicking outside the select control should ensure it's closed
if (!node.contains(ev.target))
this.setState({ open: false });
}

clickHandler(ev) {
// only consider clicks with the primary button
if (ev && ev.button !== 0)
return;

if (ev.target.tagName === 'A') {
const liElement = ev.target.offsetParent;
if (liElement.className.indexOf("disabled") >= 0)
return;
let elementData;
if ('data-data' in liElement.attributes)
elementData = liElement.attributes['data-data'].value;

this.setState({ open: false });
// if the item didn't change, don't do anything
if (elementData === this.props.selected)
return;
if (this.props.onChange)
this.props.onChange(elementData);
} else {
this.setState({ open: !this.state.open });
}
}

render() {
const getItemData = (item) => (item && item.props && ('data' in item.props) ? item.props.data : undefined);
const getItemValue = (item) => (item && item.props && (item.props.children !== undefined) ? item.props.children : textForUndefined);

const entries = React.Children.toArray(this.props.children).filter(item => item && item.props && ('data' in item.props));

let selectedEntries = entries.filter(item => this.props.selected === getItemData(item));

let selectedEntry;
if (selectedEntries.length > 0)
selectedEntry = selectedEntries[0];
else if (entries.length > 0)
selectedEntry = entries[0]; // default to first item if selected item not found

const currentValue = getItemValue(selectedEntry);

let classes = "btn-group bootstrap-select dropdown";
if (this.state.open)
classes += " open";
if (this.props.extraClass) {
classes += " " + this.props.extraClass;
}

let buttonClasses = "btn btn-default dropdown-toggle";
if (this.props.enabled === false)
buttonClasses += " disabled";

return (
<div className={classes} onClick={this.clickHandler} id={this.props.id}>
<button className={buttonClasses} type="button">
<span className="pull-left">{currentValue}</span>
<span className="caret" />
</button>
<ul className="dropdown-menu">
{this.props.children}
</ul>
</div>
);
}
}

StatelessSelect.propTypes = {
selected: PropTypes.any,
onChange: PropTypes.func,
id: PropTypes.string,
enabled: PropTypes.bool,
extraClass: PropTypes.string,
};
export const StatelessSelect = ({ selected, onChange, id, enabled, extraClass, children }) => (
<select className={ "ct-select " + (extraClass || "") }
onChange={ ev => onChange(ev.target.value) }
id={id} value={selected} disabled={enabled === false}>
{children}
</select>
);

export class Select extends React.Component {
constructor(props) {
super();
this.onChange = this.onChange.bind(this);

this.state = {
currentData: props.initial,
this.state = { value: props.initial,
};
}

onChange(data) {
this.setState({ currentData: data });
onChange(value) {
this.setState({ value });
if (typeof this.props.onChange === 'function')
this.props.onChange(data);
this.props.onChange(value);
}

componentWillReceiveProps(nextProps) {
this.setState({ currentData: nextProps.initial });
this.setState({ value: nextProps.initial });
}

render() {
return (
<StatelessSelect onChange={this.onChange}
selected={this.state.currentData}
selected={this.state.value}
id={this.props.id}
enabled={this.props.enabled}
extraClass={this.props.extraClass}>
Expand Down Expand Up @@ -192,29 +98,25 @@ export class SelectEntry extends React.Component {
render() {
const value = (this.props.children !== undefined) ? this.props.children : textForUndefined;
return (
<li key={value} className={this.props.disabled ? "disabled" : ""}
data-value={value} data-data={this.props.data}>
<a>{value}</a>
</li>
<option key={value} disabled={this.props.disabled}
data-value={value} value={this.props.data}>
{value}
</option>
);
}
}

/* Divider
* Example: <SelectDivider/>
*/
export const SelectDivider = () => <li role="separator" className="divider" />;

/* Header
* Example: <SelectHeader>Some header</SelectHeader>
*/
export const SelectHeader = ({ children }) => {
const value = (children !== undefined) ? children : textForUndefined;
return (
<li className="dropdown-header">{value}</li>
);
};

SelectEntry.propTypes = {
data: PropTypes.any.isRequired,
};

/* Divider
* Example: <SelectDivider/>
*/
/* HACK: dividers do not exist in HTML selects — people either use blank
* space (which we probably want to do) or a disabled text, like these dashes */
export const SelectDivider = () => (
<option role="separator" className="divider" disabled>
──────────
</option>
);
2 changes: 2 additions & 0 deletions pkg/lib/form-layout.less
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
> textarea,
> select,
> .bootstrap-select,
> .ct-select,
> .dropdown,
> .combobox-container,
> fieldset,
Expand Down Expand Up @@ -174,6 +175,7 @@
}

// Allow dropdowns to be less wide
&:not(.ct-form-layout-relax) > .ct-select,
&:not(.ct-form-layout-relax) > .dropdown {
width: auto !important;
}
Expand Down
50 changes: 50 additions & 0 deletions pkg/lib/page.css
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,53 @@ input[type=number] {
.dialog-ct-visible {
display: block;
}

.ct-select {
--dropdown-image: url("data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2237%22 height=%2224%22 viewBox=%220 0 9.487 6%22%3E%3Cpath fill=%22%234d5258%22 d=%22M5.623 2.831l-.09-.09c-.02-.02-.063-.02-.083 0l-.706.707-.707-.708c-.02-.02-.063-.018-.082.001l-.091.09c-.02.02-.02.063 0 .082l.838.838c.02.02.063.02.082 0l.84-.837c.02-.02.019-.063-.001-.083z%22/%3E%3C/svg%3E%0A");
--dropdown-background: linear-gradient(#fafafa, #ededed);
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
max-width: 100%;
padding: 1px 28px 1px 5px;
border: 1px solid #bbb;
background: var(--dropdown-image) no-repeat 100% 50%, var(--dropdown-background);
box-shadow: 0 2px 3px rgba(3, 3, 3, 0.1);
border-radius: 1px;
color: #4d5258;
cursor: pointer;
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
font-weight: 600;
min-height: 26px;
}

@-moz-document url-prefix() {
/* Accomodate Firefox styling selects with slightly different padding. */
.ct-select {
padding-left: 1px;
}
}

.ct-select:hover,
.ct-select:active {
--dropdown-background: #f1f1f1;
}

.ct-select:hover {
border-color: #7dc3e8;
}

.ct-select:focus {
border-color: #0088ce; /* pf-blue-400 */
/* Outer blue glow */
box-shadow: inset 0 1px 1px rgba(3, 3, 3, 0.075), 0 0 8px rgba(0, 136, 206, 0.6);
color: #4d5258;
}

.ct-select:active {
box-shadow: inset 0 2px 8px rgba(3, 3, 3, 0.2);
}

.ct-select > option {
background-color: #fff;
}
9 changes: 4 additions & 5 deletions pkg/machines/components/create-vm-dialog/createVmDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,11 @@ class CreateVM extends React.Component {
if (family === DIVIDER_FAMILY) {
return;
}
vendorSelectEntries.push((<Select.SelectHeader key={family}>{family}</Select.SelectHeader>));

vendors.forEach((vendor) => {
vendorSelectEntries.push((
<Select.SelectEntry data={vendor} key={vendor}>{vendor}</Select.SelectEntry>));
});
vendorSelectEntries.push(
<optgroup key={family} label={family}>
{ vendors.map(vendor => <Select.SelectEntry data={vendor} key={vendor}>{vendor}</Select.SelectEntry>) }
</optgroup>);
});

const osEntries = (
Expand Down
2 changes: 1 addition & 1 deletion pkg/machines/components/diskAdd.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ const SelectExistingVolume = ({ idPrefix, dialogValues, onValueChanged, vmStorag
} else {
content = (
<Select.SelectEntry data="empty" key="empty-list">
<i>{_("The pool is empty")}</i>
{_("The pool is empty")}
</Select.SelectEntry>
);
initiallySelected = "empty";
Expand Down
1 change: 1 addition & 0 deletions pkg/machines/machines.less
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ table.vcpu-detail-modal-right input {
height: 25px;
}

table.vcpu-detail-modal-right .ct-select,
table.vcpu-detail-modal-right .btn-group.bootstrap-select.dropdown {
width: 110px;
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/networkmanager/networking.css
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@
height: 120px;
}

#network-interface-graph-toolbar .ct-select,
#networking-graph-toolbar .ct-select,
#network-interface-graph-toolbar .dropdown,
#networking-graph-toolbar .dropdown {
display: inline-block;
Expand Down
9 changes: 9 additions & 0 deletions pkg/packagekit/updates.less
Original file line number Diff line number Diff line change
Expand Up @@ -337,14 +337,23 @@ table.header-buttons {
.dropdown-toggle {
width: auto;
}
.ct-select,
.dropdown {
width: auto !important;
}
.ct-select {
margin: 0.5ch;
}
.dropdown {
padding: 0.5ch;
}
}
.auto-conf-text {
padding: 0 1ch;
}
.auto-conf-group:first-child > .ct-select {
margin-left: 0;
}
.auto-conf-group:first-child > .dropdown {
padding-left: 0;
}
Expand Down
1 change: 1 addition & 0 deletions pkg/shell/shell.less
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ html.index-page body {
float: right;
}

#dashboard-toolbar > div.ct-select,
#dashboard-toolbar > div.dropdown {
display: inline-block;
}
Expand Down
1 change: 1 addition & 0 deletions pkg/storaged/storage.css
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ td.job-action {
margin-top: 20px;
}

#storage-graph-toolbar .ct-select,
#storage-graph-toolbar .dropdown {
display: inline-block;
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/systemd/host.css
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ body {
font-size: smaller;
}

#server-graph-toolbar .ct-select,
#server-graph-toolbar .dropdown {
display: inline-block;
}
Expand Down Expand Up @@ -248,6 +249,7 @@ body {
padding: 1px 10px;
}

#shutdown-dialog .ct-select,
#shutdown-dialog .dropdown {
min-width: 150px;
}
Expand Down
1 change: 1 addition & 0 deletions pkg/systemd/services.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ table.systemd-unit-relationship-table td:first-child {
justify-content: flex-end;
}

.filter-group .ct-select,
.filter-group .dropdown {
margin-left: 4px;
}
Expand Down
Loading

0 comments on commit 3423064

Please sign in to comment.