-
Notifications
You must be signed in to change notification settings - Fork 1.9k
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
Changes from all commits
6f550ca
dd4bb73
48945a3
7de062e
36fd51e
aadadf2
e386004
810392a
65a5083
451f4ae
6391a61
396cfa9
efd7fc0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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(() => []), | ||
}); |
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); | ||
|
||
if (!this.get('isOpen') && e.keyCode === ARROW_DOWN) { | ||
dropdown.actions.open(e); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure, but you may need to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Below here where you There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
} | ||
}, | ||
}, | ||
}); |
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}} |
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}} |
There was a problem hiding this comment.
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 aset
, would it be best to do theset
here? Do you plan on having any other code in thecapture
method?There was a problem hiding this comment.
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
, butcapture
is also called in the template in theonOpen
hook.There was a problem hiding this comment.
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 👍