Skip to content

Commit

Permalink
Merge pull request #81 from phorest/bug/listbox-prevent-document-scro…
Browse files Browse the repository at this point in the history
…ll-if-active

🐛 `<Listbox>`: 4 bug fixes for scrolling & focus
  • Loading branch information
alexlafroscia authored Nov 11, 2021
2 parents f8f6de5 + c0ebe7a commit c3335bc
Show file tree
Hide file tree
Showing 6 changed files with 80 additions and 6 deletions.
3 changes: 3 additions & 0 deletions addon/components/listbox.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
unsetActiveOption=this.unsetActiveOption
setSelectedOption=this.setSelectedOption
handleKeyPress=this.handleKeyPress
handleKeyDown=this.handleKeyDown
handleKeyUp=this.handleKeyUp
openListbox=this.openListbox
closeListbox=this.closeListbox
Expand All @@ -28,8 +29,10 @@
guid=this.guid
isOpen=this.isOpen
registerButtonElement=this.registerButtonElement
unregisterButtonElement=this.unregisterButtonElement
handleButtonClick=this.handleButtonClick
handleKeyPress=this.handleKeyPress
handleKeyDown=this.handleKeyDown
handleKeyUp=this.handleKeyUp
isDisabled=this.isDisabled
openListbox=this.openListbox
Expand Down
48 changes: 43 additions & 5 deletions addon/components/listbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ const ACTIVATE_NONE = 0;
const ACTIVATE_FIRST = 1;
const ACTIVATE_LAST = 2;

const PREVENTED_KEYDOWN_EVENTS = new Set([
'ArrowUp',
'ArrowDown',
'ArrowLeft',
'ArrowRight',
'PageUp',
'PageDown',
'Home',
'End',
]);

export default class ListboxComponent extends Component {
@tracked activeOptionIndex;
activateBehaviour = ACTIVATE_NONE;
Expand Down Expand Up @@ -74,6 +85,13 @@ export default class ListboxComponent extends Component {
return true;
}

@action
handleKeyDown(event) {
if (PREVENTED_KEYDOWN_EVENTS.has(event.key)) {
event.preventDefault();
}
}

@action
handleKeyUp(event) {
if (event.key === 'ArrowDown') {
Expand Down Expand Up @@ -142,6 +160,11 @@ export default class ListboxComponent extends Component {
this.buttonElement = buttonElement;
}

@action
unregisterButtonElement() {
this.buttonElement = undefined;
}

@action
registerLabelElement(labelElement) {
this.labelElement = labelElement;
Expand All @@ -161,6 +184,8 @@ export default class ListboxComponent extends Component {
if (this.args.value === optionComponent.args.value) {
this.selectedOptionIndex = this.activeOptionIndex =
this.optionElements.length - 1;

this.scrollIntoView(optionElement);
}
}

Expand Down Expand Up @@ -223,6 +248,18 @@ export default class ListboxComponent extends Component {
}
}

scrollIntoView(optionElement) {
// Cannot use optionElement.scrollIntoView() here because that function
// also scrolls the *window* by some amount. Here, we don't want to
// jerk the window, we just want to make the the option element visible
// inside its container.

optionElement.parentElement.scroll(
0,
optionElement.offsetTop - optionElement.parentElement.offsetTop
);
}

@action
unregisterOptionsElement() {
this.optionsElement = undefined;
Expand Down Expand Up @@ -283,13 +320,14 @@ export default class ListboxComponent extends Component {
this.search += key.toLowerCase();

for (let i = 0; i < this.optionElements.length; i++) {
let optionElement = this.optionElements[i];

if (
!this.optionElements[i].hasAttribute('disabled') &&
this.optionElements[i].textContent
.trim()
.toLowerCase()
.startsWith(this.search)
!optionElement.hasAttribute('disabled') &&
optionElement.textContent.trim().toLowerCase().startsWith(this.search)
) {
this.scrollIntoView(optionElement);

this.activeOptionIndex = i;
break;
}
Expand Down
3 changes: 3 additions & 0 deletions addon/components/listbox/-button.hbs
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
{{#let (element (or @as 'button')) as |Tag|}}
<Tag
role={{if @role @role 'button'}}
type={{if @type @type 'button'}}
id='{{@guid}}-button'
aria-haspopup='listbox'
aria-controls={{@guid}}
aria-labelledby='{{@guid}}-label {{@guid}}-button'
{{on 'click' @handleButtonClick}}
{{on 'keydown' @handleKeyDown}}
{{on 'keyup' @handleKeyUp}}
{{on 'keypress' @handleKeyPress}}
aria-expanded={{unless @isDisabled (if @isOpen 'true' 'false')}}
disabled={{@isDisabled}}
{{did-insert @registerButtonElement}}
{{will-destroy @unregisterButtonElement}}
...attributes
>
{{yield}}
Expand Down
1 change: 1 addition & 0 deletions addon/components/listbox/-options.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
{{did-insert @registerOptionsElement}}
{{will-destroy @unregisterOptionsElement}}
{{on 'keypress' @handleKeyPress}}
{{on 'keydown' @handleKeyDown}}
{{on 'keyup' @handleKeyUp}}
{{headlessui-focus-trap
focusTrapOptions=(hash
Expand Down
2 changes: 1 addition & 1 deletion tests/dummy/app/components/listboxes/basic.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
</span>
</listbox.Button>
<listbox.Options
class='z-30 max-w-md absolute w-full py-1 mt-1 overflow-auto text-base bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm'
class='z-30 max-w-md w-full py-1 mt-1 overflow-auto text-base bg-white rounded-md shadow-lg max-h-60 ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm'
as |options|
>
{{#each this.people as |person|}}
Expand Down
29 changes: 29 additions & 0 deletions tests/integration/components/listbox-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3382,4 +3382,33 @@ module('Integration | Component | <Listbox>', function (hooks) {
assertNoActiveListboxOption();
});
});

test('should be possible to open a listbox without submitting the form', async function (assert) {
let callCount = 0;

this.set('onSubmit', () => {
callCount++;
});

await render(hbs`
<form {{on 'submit' this.onSubmit}}>
<Listbox as |listbox|>
<listbox.Button>Trigger</listbox.Button>
<listbox.Options as |options|>
<options.Option>option</options.Option>
</listbox.Options>
</Listbox>
</form>
`);
assertListboxButton({
state: ListboxState.InvisibleUnmounted,
});
assertListbox({
state: ListboxState.InvisibleUnmounted,
});
await click(getListboxButton());
assertListbox({ state: ListboxState.Visible });

assert.equal(callCount, 0, 'onSubmit not called');
});
});

0 comments on commit c3335bc

Please sign in to comment.