Skip to content

Commit

Permalink
prevent keyboard-focusable-scrollers from stealing focus
Browse files Browse the repository at this point in the history
  • Loading branch information
amk221 committed Nov 25, 2024
1 parent 31aae47 commit aa4d8cd
Show file tree
Hide file tree
Showing 10 changed files with 122 additions and 62 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 16.4.1

- Don't allow focus to move to listbox, when in a combbox
(avoids keyboard-focusable-scroller behaviour)
- Use touch events instead of focus out on touch devices
to determine when to close.
- Clarify virtual focus tests

## 16.4.0

- Remove cycling options, to get closer to native behaviour
Expand Down
36 changes: 29 additions & 7 deletions addon/components/select-box/index.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export default class SelectBox extends Component {
}

get optionsTabIndex() {
return this.isListBox ? '0' : null;
return this.isListBox ? '0' : '-1';
}

get triggerTabIndex() {
Expand Down Expand Up @@ -228,6 +228,9 @@ export default class SelectBox extends Component {
@action
handleDestroyElement() {
this.element = null;

document.removeEventListener('mouseup', this.handleMouseUp);
document.removeEventListener('touchstart', this.handleTouchStart);
}

@action
Expand Down Expand Up @@ -285,7 +288,13 @@ export default class SelectBox extends Component {
handleMouseDown(event) {
this.lastMouseDownElement = event.target;

document.addEventListener('mouseup', this.handleMouseUp, { once: true });
document.addEventListener('mouseup', this.handleMouseUp, {
once: true
});

document.addEventListener('touchstart', this.handleTouchStart, {
once: true
});
}

@action
Expand All @@ -312,12 +321,18 @@ export default class SelectBox extends Component {
this._forgetActiveOption();
}

@action
handleTouchStart(event) {
if (this.element.contains(event.target)) {
return;
}

this._close();
}

@action
handleFocusOut(event) {
if (
document.activeElement === this.interactiveElement ||
this.element.contains(event.relatedTarget)
) {
if (this.element.contains(event.relatedTarget)) {
return;
}

Expand Down Expand Up @@ -353,6 +368,13 @@ export default class SelectBox extends Component {
this._handleInputChar(event);
}

@action
handleMouseDownOptions(event) {
if (this.isComboBox) {
event.preventDefault();
}
}

@action
handleMouseDownTrigger(event) {
if (this.isDisabled || event.button !== 0) {
Expand Down Expand Up @@ -781,6 +803,7 @@ export default class SelectBox extends Component {
onInsert=this.handleInsertOptions
onDestroy=this.handleDestroyOptions
onKeyDown=this.handleKeyDownOptions
onMouseDown=this.handleMouseDownOptions
)
Option=(component
SelectBoxOption
Expand All @@ -791,7 +814,6 @@ export default class SelectBox extends Component {
onMouseUp=this.handleMouseUpOption
onMouseDown=this.handleMouseDownOption
onKeyDown=this.handleKeyDownOption
onFocusIn=this.handleFocusInOption
)
)
}}
Expand Down
2 changes: 0 additions & 2 deletions addon/components/select-box/option.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,10 @@ export default class SelectBoxOption extends Component {
aria-selected="{{this.isSelected}}"
class={{concat "select-box__option" (if @class (concat " " @class))}}
role="option"
tabindex={{@tabindex}}
{{on "mouseenter" (fn @onMouseEnter this)}}
{{on "mousedown" @onMouseDown}}
{{on "keydown" @onKeyDown}}
{{on "mouseup" (fn @onMouseUp this)}}
{{on "focusin" (fn @onFocusIn this)}}
{{lifecycle
onInsert=this.handleInsertElement
onDestroy=this.handleDestroyElement
Expand Down
1 change: 1 addition & 0 deletions addon/components/select-box/options.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import lifecycle from '@zestia/ember-select-box/modifiers/lifecycle';
role="listbox"
tabindex={{@tabindex}}
{{on "keydown" @onKeyDown}}
{{on "mousedown" @onMouseDown}}
{{lifecycle onInsert=@onInsert onDestroy=@onDestroy}}
...attributes
>
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/components/option/render-test.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ module('select-box/option', function (hooks) {
</SelectBox>
</template>);

assert.dom('.select-box__option').hasAttribute('tabindex', '3');
assert.dom('.select-box__option').doesNotHaveAttribute('tabindex');
});

test('disabled', async function (assert) {
Expand Down
28 changes: 24 additions & 4 deletions tests/integration/components/options/render-test.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,13 @@ module('select-box/options', function (hooks) {
</SelectBox>
</template>);

assert.dom('.select-box__options').hasAttribute('tabindex', '0');
assert
.dom('.select-box__options')
.hasAttribute(
'tabindex',
'0',
'the main interactive element is the listbox'
);
});

test('tabindex (combobox with input)', async function (assert) {
Expand All @@ -64,20 +70,34 @@ module('select-box/options', function (hooks) {
</SelectBox>
</template>);

assert.dom('.select-box__options').doesNotHaveAttribute('tabindex');
assert.dom('.select-box__options').hasAttribute(
'tabindex',
'-1',
`the main interactive element is the input (combobox)
focus should not move to the listbox, which is
aria controlled by the input.
this prevents keyboard-focusable-scrollers from stealing focus,
since options often overflows`
);
});

test('tabindex (combobox with trigger)', async function (assert) {
assert.expect(1);

await render(<template>
<SelectBox as |sb|>
<sb.Input />
<sb.Trigger />
<sb.Options />
</SelectBox>
</template>);

assert.dom('.select-box__options').doesNotHaveAttribute('tabindex');
assert.dom('.select-box__options').hasAttribute(
'tabindex',
'-1',
`the main interactive element is the trigger (combobox)
focus should not move to the listbox, which is
aria controlled by the trigger`
);
});

test('id', async function (assert) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ module('select-box (clicking trigger)', function (hooks) {
test('clicking trigger toggles select box on mousedown', async function (assert) {
assert.expect(7);

// Mouse down makes the select box feel more responsive
// than on click, which would open on mouseup.
// It's also what would happen if you clicked on a
// native select box.

let event;

const handleMouseDownTrigger = (_event) => (event = _event);
Expand Down
30 changes: 25 additions & 5 deletions tests/integration/components/select-box/closing-test.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -178,21 +178,40 @@ module('select-box (closing)', function (hooks) {
});

test('mousing up outside must have moused down first', async function (assert) {
assert.expect(1);
assert.expect(2);

await render(<template>
<button type="button"></button>

<SelectBox @open={{true}} @onlClose={{handleClose}} as |sb|>
<SelectBox @open={{true}} @onClose={{handleClose}} as |sb|>
<sb.Trigger />
</SelectBox>

<div class="outside"></div>
</template>);

await click('button');
await click('.outside');

assert.dom('.select-box').hasAttribute('data-open', 'true');
assert.verifySteps([]);
});

test('closing due to touching outside', async function (assert) {
assert.expect(3);

await render(<template>
<SelectBox @open={{true}} @onClose={{handleClose}} as |sb|>
<sb.Trigger />
</SelectBox>

<div class="outside"></div>
</template>);

await triggerEvent('.select-box', 'mousedown');
await triggerEvent('.outside', 'touchstart');

assert.dom('.select-box').hasAttribute('data-open', 'false');
assert.verifySteps(['close']);
});

test('closing due to pressing escape', async function (assert) {
assert.expect(3);

Expand All @@ -219,6 +238,7 @@ module('select-box (closing)', function (hooks) {
<SelectBox @onClose={{handleClose}} as |sb|>
<sb.Trigger />
</SelectBox>

<div class="outside"></div>
</template>);

Expand Down
64 changes: 25 additions & 39 deletions tests/integration/components/select-box/focus-test.gjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ module('select-box (focus)', function (hooks) {

assert
.dom('.select-box__input')
.isFocused('the input is assumed to be the main interactive element');
.isFocused('the input is the main interactive element');
});

test('focus after clicking an option (trigger combobox)', async function (assert) {
Expand All @@ -73,7 +73,7 @@ module('select-box (focus)', function (hooks) {

assert
.dom('.select-box__trigger')
.isFocused('the trigger is assumed to be the main interactive element');
.isFocused('the trigger is the main interactive element');
});

test('focus after clicking an option (input & trigger combobox)', async function (assert) {
Expand All @@ -93,7 +93,7 @@ module('select-box (focus)', function (hooks) {

assert
.dom('.select-box__input')
.isFocused('the input is assumed to be the main interactive element');
.isFocused('the input is the main interactive element');
});

test('combobox with input', async function (assert) {
Expand All @@ -118,7 +118,8 @@ module('select-box (focus)', function (hooks) {

assert.dom('.select-box__input').isFocused(`
clicking an option does not steal focus from the input,
otherwise the select box would close
otherwise the select box would close (due to focus out
of the main interactive element)
`);

assert.true(event.defaultPrevented);
Expand Down Expand Up @@ -419,11 +420,21 @@ module('select-box (focus)', function (hooks) {
);

assert.dom('.select-box__trigger').isFocused(`
focus must be maintained on the interactive element,
otherwise the select box will not be receptive to user actions
focus must be maintained on the interactive element, regardless
of where is clicked inside the select box, otherwise the
select box will not be receptive to user actions, which is the
same as how any other native component would behave/
`);

assert.false(event.defaultPrevented);
// Note
// we use tabindex="-1" on the listbox to prevent
// keyboard-focusable-scrollers from stealing focus, but
// this has a down side of actually allowing the listbox
// to be focusable on click, so we must prevent that.
// https://issues.chromium.org/issues/376718258
// https://bugzilla.mozilla.org/show_bug.cgi?id=1930662

assert.true(event.defaultPrevented);
});

test('focus moving to somewhere else inside the combo box', async function (assert) {
Expand All @@ -449,7 +460,9 @@ module('select-box (focus)', function (hooks) {
'true',
`focus is free to move about inside an open select box
it will not be receptive to user actions, but the interactive
elements inside it will, and that's ok.`
elements inside it will, and that's ok. unusual, but at least
allows flexibility for the developer if designing a highly
custom select box`
);

assert.dom('.inside').isFocused();
Expand Down Expand Up @@ -484,7 +497,7 @@ module('select-box (focus)', function (hooks) {
await render(<template>
<SelectBox as |sb|>
<sb.Options>
<sb.Option tabindex="0" />
<sb.Option @value="A" tabindex="0" />
</sb.Options>
</SelectBox>
</template>);
Expand All @@ -493,8 +506,9 @@ module('select-box (focus)', function (hooks) {

assert.dom('.select-box__option').hasAttribute(
'aria-current',
'true',
`focusing an option activates it. usually this is not required
'false',
`focusing an option does not activate it.
options are not directly focusable
because focus is managed virtually using aria-activedescendant`
);
});
Expand Down Expand Up @@ -552,32 +566,4 @@ module('select-box (focus)', function (hooks) {
to the element using the keyboard`
);
});

test('changing tabs', async function (assert) {
assert.expect(4);

await render(<template>
<SelectBox as |sb|>
<sb.Trigger />
<sb.Options />
</SelectBox>
</template>);

await click('.select-box__trigger');

assert.dom('.select-box__trigger').isFocused();
assert.dom('.select-box').hasAttribute('data-open', 'true');

await triggerEvent('.select-box__trigger', 'focusout');

assert.dom('.select-box__trigger').isFocused();
assert.dom('.select-box').hasAttribute(
'data-open',
'true',
`focusout fires when changing tabs (before document.hidden becomes true)
which causes the select box to close. but when returning to that tab,
focus will still be on the interactive element, therefore really it
should not close when changing tabs.`
);
});
});
Loading

0 comments on commit aa4d8cd

Please sign in to comment.