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

UI: Multi-select dropdown component #5200

Merged
merged 13 commits into from
Jan 22, 2019
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
45 changes: 45 additions & 0 deletions ui/app/components/freestyle/sg-multi-select-dropdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Component from '@ember/component';
import { computed } from '@ember/object';

export default Component.extend({
options1: computed(() => [
{ key: 'option-1', label: 'Option One' },
{ key: 'option-2', label: 'Option Two' },
{ key: 'option-3', label: 'Option Three' },
{ key: 'option-4', label: 'Option Four' },
{ key: 'option-5', label: 'Option Five' },
]),

selection1: computed(() => ['option-2', 'option-4', 'option-5']),

optionsMany: computed(() =>
Array(100)
.fill(null)
.map((_, i) => ({ label: `Option ${i}`, key: `option-${i}` }))
),
selectionMany: computed(() => []),

optionsDatacenter: computed(() => [
{ key: 'pdx-1', label: 'pdx-1' },
{ key: 'jfk-1', label: 'jfk-1' },
{ key: 'jfk-2', label: 'jfk-2' },
{ key: 'muc-1', label: 'muc-1' },
]),
selectionDatacenter: computed(() => ['jfk-1', 'jfk-2']),

optionsType: computed(() => [
{ key: 'batch', label: 'Batch' },
{ key: 'service', label: 'Service' },
{ key: 'system', label: 'System' },
{ key: 'periodic', label: 'Periodic' },
{ key: 'parameterized', label: 'Parameterized' },
]),
selectionType: computed(() => ['system', 'service']),

optionsStatus: computed(() => [
{ key: 'pending', label: 'Pending' },
{ key: 'running', label: 'Running' },
{ key: 'dead', label: 'Dead' },
]),
selectionStatus: computed(() => []),
});
98 changes: 98 additions & 0 deletions ui/app/components/multi-select-dropdown.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import Component from '@ember/component';
import { computed } from '@ember/object';
import { run } from '@ember/runloop';

const TAB = 9;
const ESC = 27;
const SPACE = 32;
const ARROW_UP = 38;
const ARROW_DOWN = 40;

export default Component.extend({
classNames: ['dropdown'],

options: computed(() => []),
selection: computed(() => []),

onSelect() {},

isOpen: false,
dropdown: null,

capture(dropdown) {
// It's not a good idea to grab a dropdown reference like this, but it's necessary
// in order to invoke dropdown.actions.close in traverseList as well as
// dropdown.actions.reposition when the label or selection length changes.
this.set('dropdown', dropdown);
},

didReceiveAttrs() {
const dropdown = this.get('dropdown');
if (this.get('isOpen') && dropdown) {
run.scheduleOnce('afterRender', () => {
dropdown.actions.reposition();
});
}
},

actions: {
toggle({ key }) {
const newSelection = this.get('selection').slice();
if (newSelection.includes(key)) {
newSelection.removeObject(key);
} else {
newSelection.addObject(key);
}
this.get('onSelect')(newSelection);
},

openOnArrowDown(dropdown, e) {
this.capture(dropdown);
Copy link
Contributor

@johncowen johncowen Jan 17, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the capture method only does a set, would it be best to do the set here? Do you plan on having any other code in the capture method?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no other code in capture, but capture is also called in the template in the onOpen hook.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah gotcha, missed that, makes sense then 👍


if (!this.get('isOpen') && e.keyCode === ARROW_DOWN) {
dropdown.actions.open(e);
Copy link
Contributor

@johncowen johncowen Jan 17, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure, but you may need to bind this to dropdown so this within actions.open refers to the component rather than the actions object - guessing here btw. If you do need to do that, don't miss the close one below also.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It works as is and this is how it is used in the docs.

image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, if thats how they document it then fair enough

e.preventDefault();
} else if (this.get('isOpen') && (e.keyCode === TAB || e.keyCode === ARROW_DOWN)) {
const optionsId = this.element.querySelector('.dropdown-trigger').getAttribute('aria-owns');
const firstElement = document.querySelector(`#${optionsId} .dropdown-option`);

if (firstElement) {
firstElement.focus();
e.preventDefault();
}
}
},

traverseList(option, e) {
if (e.keyCode === ESC) {
// Close the dropdown
const dropdown = this.get('dropdown');
if (dropdown) {
dropdown.actions.close(e);
// Return focus to the trigger so tab works as expected
const trigger = this.element.querySelector('.dropdown-trigger');
if (trigger) trigger.focus();
Copy link
Contributor

@johncowen johncowen Jan 17, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Below here where you preventDefault it's wrapped within the conditional - this is actually different, but thought I'd check in case the preventDefault here should be inside the conditional also?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm. It probably should, just for the correctness, but in practice I'm not concerned about the ESC key. The reason to be careful about preventDefault in the UP and DOWN case is preventDefault prevents page scrolling with arrow keys, so I wanted to make sure if you mash the down key it will traverse the full list, then carry on scrolling the page.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool no prob, I see why its different, thanks!

e.preventDefault();
this.set('dropdown', null);
}
} else if (e.keyCode === ARROW_UP) {
// previous item
const prev = e.target.previousElementSibling;
if (prev) {
prev.focus();
e.preventDefault();
}
} else if (e.keyCode === ARROW_DOWN) {
// next item
const next = e.target.nextElementSibling;
if (next) {
next.focus();
e.preventDefault();
}
} else if (e.keyCode === SPACE) {
this.send('toggle', option);
e.preventDefault();
}
},
},
});
119 changes: 114 additions & 5 deletions ui/app/styles/components/dropdown.scss
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
.ember-power-select-trigger {
.ember-power-select-trigger,
.dropdown-trigger {
position: relative;
padding: 0.3em 16px 0.3em 0.3em;
border-radius: $radius;
box-shadow: $button-box-shadow-standard;
background: $white-bis;
border: 1px solid $grey-light;
outline: none;
cursor: pointer;

&:focus {
box-shadow: $button-box-shadow-standard, inset 0 0 0 2px $grey-lighter;
}

&.is-outlined {
border-color: rgba($white, 0.5);
Expand All @@ -20,17 +29,117 @@
}
}

.ember-power-select-selected-item {
.button-bar {
display: inline-flex;
flex-direction: row;
box-shadow: $button-box-shadow-standard;

.dropdown {
display: flex;
position: relative;

& + .dropdown {
margin-left: -1px;
}
}

.ember-power-select-trigger,
.dropdown-trigger {
border-radius: 0;
box-shadow: none;

&:focus {
box-shadow: inset 0 0 0 2px $grey-lighter;
}
}

.dropdown:first-child {
.ember-power-select-trigger,
.dropdown-trigger {
border-top-left-radius: $radius;
border-bottom-left-radius: $radius;
}
}

.dropdown:last-child {
.ember-power-select-trigger,
.dropdown-trigger {
border-top-right-radius: $radius;
border-bottom-right-radius: $radius;
}
}
}

.dropdown-trigger-label {
margin-left: 8px;
margin-right: 8px;
}

.ember-power-select-selected-item,
.dropdown-item {
text-overflow: ellipsis;
white-space: nowrap;
}

.ember-power-select-prefix {
.ember-power-select-prefix,
.dropdown-prefix {
color: $grey;
}

.ember-power-select-option {
.ember-power-select-prefix {
.ember-power-select-option,
.dropdown-option {
.ember-power-select-prefix,
.dropdown-prefix {
display: none;
}
}

.dropdown-options,
.ember-power-select-options {
margin: 0;
padding: 0;
}

// Bulma override
.menu-list li ul.ember-power-select-options {
margin: 0;
padding: 0;
}

.dropdown-options {
border: 1px solid $grey-light;
margin-top: -1px;
max-height: 400px;
overflow: auto;

& > ul {
width: 100%;
}

.dropdown-option {
margin: 0;
padding: 0;
white-space: nowrap;
line-height: 1.75;

label {
display: block;
padding: 3px 8px;
cursor: pointer;
}

& + .dropdown-option {
border-top: 1px solid $grey-lighter;
}

&:hover,
&:focus {
background: $white-bis;
outline: none;
border-left: 2px solid $blue;
label {
padding-left: 6px;
}
}
}
}
3 changes: 2 additions & 1 deletion ui/app/styles/core/tag.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
line-height: 1;
height: 1.5em;

&.is-pending {
&.is-pending,
&.is-light {
background: $grey-blue;
color: findColorInvert(darken($grey-blue, 10%));
}
Expand Down
58 changes: 58 additions & 0 deletions ui/app/templates/components/freestyle/sg-multi-select-dropdown.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
{{#freestyle-usage "multi-select-dropdown" title="Multi-select dropdown"}}
{{multi-select-dropdown
label="Example Dropdown"
options=options1
selection=selection1
onSelect=(action (mut selection1))}}
{{/freestyle-usage}}
{{#freestyle-annotation}}
A wrapper around basic-dropdown for creating a list of checkboxes and tracking the state thereof.
{{/freestyle-annotation}}

{{#freestyle-usage "multi-select-right-aligned" title="Multi-select dropdown right-aligned"}}
<div style="display:flex; justify-content:flex-end">
{{multi-select-dropdown
label="Example right-aligned Dropdown"
options=options1
selection=selection1
onSelect=(action (mut selection1))}}
</div>
{{/freestyle-usage}}

{{#freestyle-usage "multi-select-dropdown-may" title="Multi-select dropdown with many options"}}
{{multi-select-dropdown
label="Lots of options in here"
options=optionsMany
selection=selectionMany
onSelect=(action (mut selectionMany))}}
{{/freestyle-usage}}
{{#freestyle-annotation}}
A strength of the multi-select-dropdown is its simple presentation. It is quick to select options and it is quick to remove options.
However, this strength becomes a weakness when there are too many options. Since the selection isn't pinned in any way, removing a selection
can become an adventure of scrolling up and down. Also since the selection isn't pinned, this component can't support search, since search would
entirely mask the selection.
{{/freestyle-annotation}}

{{#freestyle-usage "multi-select-dropdown-bar" title="Multi-select dropdown bar"}}
<div class="button-bar">
{{multi-select-dropdown
label="Datacenter"
options=optionsDatacenter
selection=selectionDatacenter
onSelect=(action (mut selectionDatacenter))}}
{{multi-select-dropdown
label="Type"
options=optionsType
selection=selectionType
onSelect=(action (mut selectionType))}}
{{multi-select-dropdown
label="Status"
options=optionsStatus
selection=selectionStatus
onSelect=(action (mut selectionStatus))}}
</div>
{{/freestyle-usage}}
{{#freestyle-annotation}}
Since this is a core component for faceted search, it makes sense to construct an arrangement of multi-select dropdowns.
Do this by wrapping all the options in a <code>.button-bar</code> container.
{{/freestyle-annotation}}
34 changes: 34 additions & 0 deletions ui/app/templates/components/multi-select-dropdown.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{{#basic-dropdown
horizontalPosition="left"
onOpen=(action (queue
(action (mut isOpen) true)
(action capture)
))
onClose=(action (mut isOpen) false)
as |dd|}}
{{#dd.trigger data-test-dropdown-trigger class="dropdown-trigger" onKeyDown=(action "openOnArrowDown")}}
<span class="dropdown-trigger-label">
{{label}}
{{#if selection.length}}
<span data-test-dropdown-count class="tag is-light">{{selection.length}}</span>
{{/if}}
</span>
<span class="dropdown-trigger-icon ember-power-select-status-icon"></span>
{{/dd.trigger}}
{{#dd.content class="dropdown-options"}}
<ul role="listbox" data-test-dropdown-options>
{{#each options key="key" as |option|}}
<li data-test-dropdown-option class="dropdown-option" tabindex="1" onkeydown={{action "traverseList" option}}>
<label>
<input
type="checkbox"
tabindex="-1"
checked={{contains option.key selection}}
onchange={{action "toggle" option}} />
{{option.label}}
</label>
</li>
{{/each}}
</ul>
{{/dd.content}}
{{/basic-dropdown}}
Loading