diff --git a/CHANGELOG.md b/CHANGELOG.md index 82c0b746..2b192c1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 17.0.0 + +- Add dropdown primitive + ## 16.4.0 - Don't allow focus to move to listbox, when in a combbox diff --git a/README.md b/README.md index b7e7a228..c7a324af 100644 --- a/README.md +++ b/README.md @@ -52,14 +52,18 @@ This addon intentionally... ```handlebars - - {{sb.value}} - - - - Foo - - + + + {{sb.value}} + + + + + Foo + + + + ``` @@ -140,18 +144,6 @@ Optional. If `true`, `@value` is expected to be an array. If an option's value i Optional. Fired whenever a selection is made. This function receives the values most recently selected, and the previously selected values. The return value is then used as the final selection. This is primarily used to customise select boxes where `@multiple` is `true` - because the behaviour for a selection is undefined and totally depends on your use-case. -#### `@open` - -Optional. Whether or not the select box should be in an open state initially. - -#### `@onOpen` - -Optional. Fired when the select box is opened - -#### `@onClose` - -Optional. Fired when the select box is closed - #### `@onActivate` Optional. Fired when an option is moused over or focused via the keyboard controls @@ -166,18 +158,6 @@ Mimics the user making a selection, and so `@onChange` may fire. Updates the select box with a new value(s). `@onChange` will not fire. -#### `open` - -Opens the select box. - -#### `toggle` - -Opens or closes the select box - -#### `close` - -Closes the select box - #### `element` The element of the select box @@ -190,10 +170,6 @@ The selected value(s) of the select box True if the select box is waiting for a search to finish -#### `isOpen` - -Whether the select box is open - #### `query` The query used to produce the latest search results. (This may be different to the current value in the text input). @@ -241,17 +217,55 @@ Whether or not the option is currently disabled Whether or not the option is currently selected -## `Group` +## Dropdown ### Arguments -#### `@label` +#### `@open` -Required. The group label (similar to the native `optgroup`) +Optional. Whether or not the select box's dropdown should be in an open state initially. + +#### `@onOpen` + +Optional. Fired when the select box's dropdown is opened + +#### `@onClose` + +Optional. Fired when the select box's dropdown is closed + +### API + +#### `isOpen` + +Whether the select box's dropdown is open + +#### `open` + +Opens the select box's dropdown + +#### `toggle` + +Opens or closes the select box's dropdown + +#### `close` + +Closes the select box's dropdown + +#### `element` + +The element of the select box's dropdown ## `Options` -A container element to house each option. If no `Trigger` or `Input` is rendered, then this will be a Listbox. +A listbox container element to house each option + +## `Group` + +### Arguments + +#### `@label` + +Required. The group label (similar to the native `optgroup`) ## `Input` diff --git a/addon/components/dropdown/content.gjs b/addon/components/dropdown/content.gjs new file mode 100644 index 00000000..28339085 --- /dev/null +++ b/addon/components/dropdown/content.gjs @@ -0,0 +1,12 @@ +import lifecycle from '@zestia/ember-select-box/modifiers/lifecycle'; +import { on } from '@ember/modifier'; + + diff --git a/addon/components/dropdown/index.gjs b/addon/components/dropdown/index.gjs new file mode 100644 index 00000000..e1fb8741 --- /dev/null +++ b/addon/components/dropdown/index.gjs @@ -0,0 +1,267 @@ +/* eslint-disable ember/no-runloop, ember/no-tracked-properties-from-args */ + +import { action } from '@ember/object'; +import { cached, tracked } from '@glimmer/tracking'; +import { concat, hash } from '@ember/helper'; +import { on } from '@ember/modifier'; +import Component from '@glimmer/component'; +import lifecycle from '@zestia/ember-select-box/modifiers/lifecycle'; +import DropdownTrigger from '@zestia/ember-select-box/components/dropdown/trigger'; +import DropdownContent from '@zestia/ember-select-box/components/dropdown/content'; +import { scheduleOnce } from '@ember/runloop'; +import { modifier } from 'ember-modifier'; +const { assign } = Object; + +const FOCUS_LEAVE = Symbol('FOCUS_LEAVE'); +const CLICK_OUTSIDE = Symbol('CLICK_OUTSIDE'); +const ESCAPE = Symbol('ESCAPE'); + +export default class Dropdown extends Component { + @tracked triggerElement; + @tracked contentElement; + @tracked element; + @tracked _isOpen = this.args.open; + + lastMouseDownElement; + + Trigger; + Content; + + registerComponents = (components) => { + assign(this, components); + }; + + get isOpen() { + return !!this._isOpen; + } + + get isClosed() { + return !this.isOpen; + } + + get canOpen() { + return this.isClosed; + } + + get canClose() { + return this.isOpen; + } + + documentListeners = modifier(() => { + document.addEventListener('mousedown', this.handleMouseDown); + document.addEventListener('mouseup', this.handleMouseUp); + + return () => { + document.removeEventListener('mousedown', this.handleMouseDown); + document.removeEventListener('mouseup', this.handleMouseUp); + }; + }); + + @action + handleInsertElement(element) { + this.element = element; + this.args.onReady?.(this.api); + } + + @action + handleDestroyElement() { + this.element = null; + } + + @action + handleInsertTrigger(element) { + this.triggerElement = element; + } + + @action + handleDestroyTrigger() { + this.triggerElement = null; + } + + @action + handleInsertContent(element) { + this.contentElement = element; + } + + @action + handleDestroyContent() { + this.contentElement = null; + } + + @action + handleMouseDownTrigger(event) { + if (event.button !== 0) { + return; + } + + this.toggle(); + } + + @action + handleMouseDown(event) { + this.lastMouseDownElement = event.target; + } + + @action + handleMouseUp(event) { + this.lastMouseDownElement = null; + + if (this._isInside(event.target)) { + return; + } + + this._handleClickOutside(); + } + + @action + handleFocusOut(event) { + const element = event.relatedTarget || this.lastMouseDownElement; + + if (this._isInside(element)) { + return; + } + + this._handleFocusLeave(); + } + + @action + handleKeyDownTrigger(event) { + if (event.key === 'Enter' || event.key === ' ') { + this._handleEnterOrSpace(event); + } + } + + @action + handleKeyDown(event) { + if (event.key === 'Escape') { + this._handleEscape(event); + } + } + + @action + open() { + if (!this.canOpen) { + return; + } + + this._isOpen = true; + + scheduleOnce('afterRender', this, '_handleOpened'); + } + + @action + close(reason) { + if (!this.canClose) { + return; + } + + this._isOpen = false; + + scheduleOnce('afterRender', this, '_handleClosed', reason); + } + + @action + toggle() { + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + } + + _handleFocusLeave() { + this.close(FOCUS_LEAVE); + } + + _handleClickOutside() { + this.close(CLICK_OUTSIDE); + } + + _handleEscape(event) { + if (!this.canClose) { + return; + } + + event.stopPropagation(); + + this.close(ESCAPE); + } + + _handleEnterOrSpace(event) { + this.toggle(); + } + + _isInside(element) { + return ( + element !== this.element && + (this.element.contains(element) || this.contentElement?.contains(element)) + ); + } + + _handleOpened() { + this.args.onOpenClosure?.(); + this.args.onOpen?.(); + } + + _handleClosed(reason) { + this.args.onCloseClosure?.(reason); + this.args.onClose?.(reason); + } + + @cached + get _api() { + return { + Trigger: this.Trigger, + Content: this.Content, + element: this.element, + isOpen: this.isOpen, + open: this.open, + close: this.close, + toggle: this.toggle + }; + } + + api = new Proxy(this, { + get(target, key) { + return target._api[key]; + }, + set() {} + }); + + +} diff --git a/addon/components/dropdown/trigger.gjs b/addon/components/dropdown/trigger.gjs new file mode 100644 index 00000000..1c3007d9 --- /dev/null +++ b/addon/components/dropdown/trigger.gjs @@ -0,0 +1,34 @@ +import { on } from '@ember/modifier'; +import lifecycle from '@zestia/ember-select-box/modifiers/lifecycle'; +import { concat } from '@ember/helper'; +import { action } from '@ember/object'; +import Component from '@glimmer/component'; + +export default class DropdownTrigger extends Component { + @action + handleInsert(element) { + this.args.onInsert?.(element); + this.args.onInsertClosure?.(element); + } + + +} diff --git a/addon/components/select-box/index.gjs b/addon/components/select-box/index.gjs index f5e02d81..d24fcf8f 100644 --- a/addon/components/select-box/index.gjs +++ b/addon/components/select-box/index.gjs @@ -1,7 +1,6 @@ /* eslint-disable ember/no-runloop */ import { action } from '@ember/object'; -import { assert } from '@ember/debug'; import { cached } from '@glimmer/tracking'; import { filter } from '@zestia/ember-select-box/utils'; import { hash } from '@ember/helper'; @@ -17,30 +16,30 @@ import { task } from 'ember-concurrency'; import { tracked } from 'tracked-built-ins'; import Component from '@glimmer/component'; import lifecycle from '@zestia/ember-select-box/modifiers/lifecycle'; +import Dropdown from '@zestia/ember-select-box/components/dropdown/index'; import SelectBoxGroup from '@zestia/ember-select-box/components/select-box/group'; import SelectBoxInput from '@zestia/ember-select-box/components/select-box/input'; import SelectBoxOption from '@zestia/ember-select-box/components/select-box/option'; import SelectBoxOptions from '@zestia/ember-select-box/components/select-box/options'; -import SelectBoxTrigger from '@zestia/ember-select-box/components/select-box/trigger'; const { assign } = Object; export default class SelectBox extends Component { @tracked _activeOption; @tracked _options = tracked([]); + @tracked dropdown; @tracked element; - @tracked inputElements = tracked([]); - @tracked isOpen = this.args.open ?? null; - @tracked optionsElements = tracked([]); + @tracked inputElement; + @tracked optionsElement; @tracked query = null; - @tracked triggerElements = tracked([]); + @tracked triggerElement; @localCopy('args.value') _value; @localCopy('args.options') results; chars = ''; charTimer; - lastMouseDownElement; + Dropdown; Group; Input; Option; @@ -51,12 +50,6 @@ export default class SelectBox extends Component { assign(this, components); }; - constructor() { - super(...arguments); - this.args.onReady?.(this.api); - scheduleOnce('afterRender', this, '_handleRender'); - } - get value() { return this.isMultiple ? makeArray(this._value) : this._value; } @@ -74,43 +67,23 @@ export default class SelectBox extends Component { } get isComboBox() { - return this.hasInput || this.hasTrigger; + return this.hasOptions && (this.hasInput || this.hasTrigger); } get isListBox() { - return !this.isComboBox; - } - - get isClosed() { - return !this.isOpen; - } - - get isOpenAttr() { - return this.isComboBox - ? this.hasTrigger - ? !!this.isOpen - : this.isOpen - : null; - } - - get canOpen() { - return this.isComboBox && this.isClosed; - } - - get canClose() { - return this.isComboBox && this.isOpen; + return this.hasOptions && !(this.hasInput || this.hasTrigger); } get canAutoOpen() { - return this.hasTrigger && this.isClosed; + return this.hasTrigger && !this.dropdown.isOpen; } get canAutoClose() { - return this.isSingle && this.isOpen; + return this.isSingle && this.hasTrigger && this.dropdown.isOpen; } get canAutoSelect() { - return this.isSingle && this.isComboBox && this.isClosed; + return this.isSingle && this.hasTrigger && !this.dropdown.isOpen; } get isBusy() { @@ -121,12 +94,16 @@ export default class SelectBox extends Component { return this.chars.trim() !== ''; } + get hasOptions() { + return !!this.optionsElement; + } + get hasTrigger() { - return this.triggerElements.length === 1; + return !!this.triggerElement; } get hasInput() { - return this.inputElements.length === 1; + return !!this.inputElement; } get hasSearch() { @@ -176,31 +153,20 @@ export default class SelectBox extends Component { return this.options[this.activeOptionIndex + 1]; } - @cached - get interactiveElements() { - return [this.inputElement, this.triggerElement, this.optionsElement].filter( - Boolean - ); + get optionElements() { + return [...this.element.querySelectorAll('.select-box__option')]; } get interactiveElement() { - return this.interactiveElements[0]; - } - - get optionsElement() { - return this.optionsElements[0]; - } - - get triggerElement() { - return this.triggerElements[0]; - } + if (this.hasInput) { + return this.inputElement; + } - get inputElement() { - return this.inputElements[0]; - } + if (this.hasTrigger) { + return this.triggerElement; + } - get optionElements() { - return [...this.element.querySelectorAll('.select-box__option')]; + return this.optionsElement; } get hasFocus() { @@ -223,14 +189,12 @@ export default class SelectBox extends Component { @action handleInsertElement(element) { this.element = element; + this.args.onReady?.(this.api); } @action handleDestroyElement() { this.element = null; - - document.removeEventListener('mouseup', this.handleMouseUp); - document.removeEventListener('touchstart', this.handleTouchStart); } @action @@ -250,66 +214,47 @@ export default class SelectBox extends Component { @action handleInsertOptions(element) { - this.optionsElements.push(element); + this.optionsElement = element; } @action handleDestroyOptions() { - this.optionsElements = tracked([]); + this.optionsElement = null; } @action handleInsertTrigger(element) { - this.triggerElements.push(element); - this.isOpen = !!this.args.open; + this.triggerElement = element; } @action handleDestroyTrigger() { - this.triggerElements = tracked([]); + this.triggerElement = null; } @action handleInsertInput(element) { - this.inputElements.push(element); + this.inputElement = element; } @action handleDestroyInput() { - this.inputElements = tracked([]); + this.inputElement = null; } @action - handleInput() { - this._search(this.inputElement.value); + registerDropdown(dropdown) { + this.dropdown = dropdown; } @action - handleMouseDown(event) { - this.lastMouseDownElement = event.target; - - document.addEventListener('mouseup', this.handleMouseUp, { - once: true - }); - - document.addEventListener('touchstart', this.handleTouchStart, { - once: true - }); + handleDestroyDropdown() { + this.dropdown = null; } @action - handleMouseUp(event) { - if (!this.element || !this.lastMouseDownElement) { - return; - } - - this.lastMouseDownElement = null; - - if (this.element.contains(event.target)) { - return; - } - - this._handleClickAbort(); + handleInput() { + this._search(this.inputElement.value); } @action @@ -322,29 +267,18 @@ export default class SelectBox extends Component { } @action - handleTouchStart(event) { - if (this.element.contains(event.target)) { - return; - } - - this._handleTapOutside(); + handleMouseDown(event) { + event.preventDefault(); + this._ensureFocus(); } @action - handleFocusOut(event) { - const interactiveEl = event.relatedTarget; - const nonInteractiveEl = this.lastMouseDownElement; - - if (this.element.contains(interactiveEl)) { - return; - } - - if (this.element.contains(nonInteractiveEl)) { - this._ensureFocus(); + handleFocusOut() { + if (this.dropdown) { return; } - scheduleOnce('afterRender', this, '_handleFocusLeave', event); + this._forgetActiveOption(); } @action @@ -368,35 +302,11 @@ 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) { - return; - } - - event.preventDefault(); - - this._toggle(); - } - @action handleMouseEnterOption(option) { this._activateOption(option); } - @action - handleMouseDownOption(event) { - event.preventDefault(); - this._ensureFocus(); - } - @action handleMouseUpOption(option, event) { if (event.button !== 0) { @@ -418,18 +328,18 @@ export default class SelectBox extends Component { } @action - close() { - this._close(); + handleOpenDropdown() { + this.activeOption?.scrollIntoView(); + this._ensureFocus(); } @action - open() { - this._open(); - } + handleCloseDropdown(reason) { + this._forgetActiveOption(); - @action - toggle() { - this._toggle(); + if (reason?.description !== 'FOCUS_LEAVE') { + this._ensureFocus(); + } } @action @@ -455,9 +365,6 @@ export default class SelectBox extends Component { case 'ArrowDown': this._handleArrowDown(event); break; - case 'Escape': - this._handleEscape(event); - break; case 'Enter': this._handleEnter(event); break; @@ -471,7 +378,7 @@ export default class SelectBox extends Component { event.preventDefault(); if (this.canAutoOpen) { - this._open(); + this.dropdown.open(); return; } @@ -482,7 +389,7 @@ export default class SelectBox extends Component { event.preventDefault(); if (this.canAutoOpen) { - this._open(); + this.dropdown.open(); return; } @@ -494,7 +401,7 @@ export default class SelectBox extends Component { event.preventDefault(); } - this._handleEnterAndSpace(); + this._handleEnterOrSpace(); } _handleSpace(event) { @@ -508,54 +415,23 @@ export default class SelectBox extends Component { return; } - this._handleEnterAndSpace(); + this._handleEnterOrSpace(); } - _handleEnterAndSpace() { + _handleEnterOrSpace(event) { if (this.canAutoOpen) { - this._open(); + this.dropdown.open(); return; } this._selectActiveOption(); } - _handleEscape(event) { - if (this.canClose) { - event.stopPropagation(); - } - - this._close(Symbol('ESCAPE')); - } - - _handleFocusLeave() { - this._close(Symbol('FOCUS_LEAVE')); - } - - _handleClickAbort() { - this._close(Symbol('CLICK_ABORT')); - } - - _handleTapOutside() { - this._handleFocusLeave(); - } - _handleSelected() { const close = this.args.onSelect?.(this.api) ?? this.canAutoClose; if (close) { - this._close(Symbol('SELECTED')); - } - } - - _handleOpened() { - this.activeOption?.scrollIntoView(); - this._ensureFocus(); - } - - _handleClosed({ description: reason } = {}) { - if (reason !== 'FOCUS_LEAVE') { - this._ensureFocus(); + this.dropdown.close(); } } @@ -563,13 +439,6 @@ export default class SelectBox extends Component { this.activeOption?.scrollIntoView(); } - _handleRender() { - assert('must have an interactive element', this.interactiveElement); - assert('can only have 1 listbox', this.optionsElements.length <= 1); - assert('can only have 1 input', this.inputElements.length <= 1); - assert('can only have 1 trigger', this.triggerElements.length <= 1); - } - _handleInputChar(event) { const { key: char } = event; @@ -601,37 +470,6 @@ export default class SelectBox extends Component { } } - _open() { - if (!this.canOpen) { - return; - } - - this.isOpen = true; - this.args.onOpen?.(this.api); - - scheduleOnce('afterRender', this, '_handleOpened'); - } - - _close(reason) { - if (!this.canClose) { - return; - } - - this.isOpen = false; - this._forgetActiveOption(); - this.args.onClose?.(this.api); - - scheduleOnce('afterRender', this, '_handleClosed', reason); - } - - _toggle() { - if (this.isOpen) { - this._close(); - } else { - this._open(); - } - } - _forgetActiveOption() { this._activeOption = null; } @@ -740,24 +578,22 @@ export default class SelectBox extends Component { get _api() { return { // Components + Dropdown: this.Dropdown, Group: this.Group, Input: this.Input, Option: this.Option, Options: this.Options, Trigger: this.Trigger, // Properties - close: this.close, element: this.element, isBusy: this.isBusy, - isOpen: this.isOpen, options: this.results, query: this.query, value: this.value, + dropdown: this.dropdown, // Actions - open: this.open, search: this.search, select: this.select, - toggle: this.toggle, update: this.update }; } @@ -771,28 +607,14 @@ export default class SelectBox extends Component { diff --git a/tests/dummy/app/components/example4.gjs b/tests/dummy/app/components/example4.gjs index 21ef9c00..678b24ed 100644 --- a/tests/dummy/app/components/example4.gjs +++ b/tests/dummy/app/components/example4.gjs @@ -18,24 +18,28 @@ export default class extends Component { ...attributes as |sb| > - - {{yield sb.value to="trigger"}} - - - {{#each @options as |value|}} - - - {{yield value to="option"}} - - {{/each}} - + + + {{yield sb.value to="trigger"}} + + + + {{#each @options as |value|}} + + + {{yield value to="option"}} + + {{/each}} + + + } diff --git a/tests/dummy/app/components/example6.gjs b/tests/dummy/app/components/example6.gjs index edfddce0..236ece66 100644 --- a/tests/dummy/app/components/example6.gjs +++ b/tests/dummy/app/components/example6.gjs @@ -1,29 +1,22 @@ import SelectBox from '@zestia/ember-select-box/components/select-box'; -import Component from '@glimmer/component'; -import { action } from '@ember/object'; +import { fn } from '@ember/helper'; -export default class extends Component { - @action - handleOpen(sb) { - sb.search(''); - } - - diff --git a/tests/dummy/app/components/example7.gjs b/tests/dummy/app/components/example7.gjs index 0f941531..fac00eab 100644 --- a/tests/dummy/app/components/example7.gjs +++ b/tests/dummy/app/components/example7.gjs @@ -3,15 +3,11 @@ import { on } from '@ember/modifier'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; +import { fn } from '@ember/helper'; export default class extends Component { @tracked inputValue = ''; - @action - handleOpen(sb) { - sb.search(sb.query); - } - @action handleSelect(sb) { this.inputValue = ''; @@ -24,42 +20,45 @@ export default class extends Component { @value={{@value}} @onChange={{@onChange}} @onSelect={{this.handleSelect}} - @onOpen={{this.handleOpen}} @onSearch={{@onSearch}} ...attributes as |sb| > -
- + +
+ - - {{if sb.isOpen "↑" "↓"}} - -
+ + {{if dd.isOpen "↑" "↓"}} + +
- {{#if sb.isOpen}} - - {{#if sb.isBusy}} - - {{yield to="busy"}} - - {{else if sb.options}} - {{#each sb.options as |value|}} - - {{yield value to="option"}} - - {{/each}} - {{else}} - - {{yield sb.query to="noOptions"}} - - {{/if}} - - {{/if}} + {{#if dd.isOpen}} + + + {{#if sb.isBusy}} + + {{yield to="busy"}} + + {{else if sb.options}} + {{#each sb.options as |value|}} + + {{yield value to="option"}} + + {{/each}} + {{else}} + + {{yield sb.query to="noOptions"}} + + {{/if}} + + + {{/if}} + } diff --git a/tests/dummy/app/router.js b/tests/dummy/app/router.js index d4735ab9..fc8eab4a 100644 --- a/tests/dummy/app/router.js +++ b/tests/dummy/app/router.js @@ -16,6 +16,6 @@ Router.map(function () { this.route('example5'); this.route('example6'); this.route('example7'); + this.route('dropdown'); this.route('performance'); - this.route('custom-trigger'); }); diff --git a/tests/dummy/app/styles/app.scss b/tests/dummy/app/styles/app.scss index 28c77d49..5d9048d3 100644 --- a/tests/dummy/app/styles/app.scss +++ b/tests/dummy/app/styles/app.scss @@ -7,4 +7,4 @@ @use 'example5'; @use 'example6'; @use 'example7'; -@use 'custom-trigger'; +@use 'dropdown'; diff --git a/tests/dummy/app/styles/custom-trigger.scss b/tests/dummy/app/styles/custom-trigger.scss deleted file mode 100644 index 79d26f82..00000000 --- a/tests/dummy/app/styles/custom-trigger.scss +++ /dev/null @@ -1,30 +0,0 @@ -@use 'example'; - -.custom-trigger { - &[data-open='false'] .select-box__options { - @include example.screen-reader-only; - } - - .select-box__trigger { - display: contents; - } - - .select-box__options { - @include example.box; - @include example.options; - - height: 240px; - } - - .select-box__option { - @include example.option; - } - - .select-box__option[aria-current='true'] { - @include example.option-current; - } - - .select-box__option[aria-selected='true'] { - @include example.option-selected; - } -} diff --git a/tests/dummy/app/styles/dropdown.scss b/tests/dummy/app/styles/dropdown.scss new file mode 100644 index 00000000..c947f66f --- /dev/null +++ b/tests/dummy/app/styles/dropdown.scss @@ -0,0 +1,29 @@ +@use 'example'; + +.dropdown.example { + .dropdown__trigger { + appearance: none; + all: unset; + cursor: pointer; + padding: var(--space-m); + outline: none; + user-select: none; + display: inline-block; + border: 2px solid var(--light-grey); + } + + .dropdown__trigger:focus { + @include example.box-focused; + } + + .dropdown__content { + border: 2px solid var(--light-grey); + margin-top: var(--space-m); + padding: var(--space-xl); + width: 300px; + } + + .dropdown__content:focus-within { + @include example.box-focused; + } +} diff --git a/tests/dummy/app/styles/example.scss b/tests/dummy/app/styles/example.scss index 6dccdbe5..10f69246 100644 --- a/tests/dummy/app/styles/example.scss +++ b/tests/dummy/app/styles/example.scss @@ -12,8 +12,8 @@ border: 2px solid var(--light-grey); display: inline-flex; flex-direction: column; - margin: var(--space-sm); - padding: var(--space-sm); + margin: var(--space-s); + padding: var(--space-s); } @mixin box-focused { @@ -22,7 +22,7 @@ @mixin option { cursor: pointer; - padding: var(--space-lg); + padding: var(--space-l); } @mixin options { @@ -45,14 +45,14 @@ } @mixin group-label { - padding: var(--space-sm); - margin: var(--space-sm); + padding: var(--space-s); + margin: var(--space-s); font-weight: bold; } @mixin trigger { cursor: pointer; - padding: var(--space-md); + padding: var(--space-m); outline: none; user-select: none; border: 2px solid transparent; @@ -68,8 +68,8 @@ box-sizing: border-box; min-width: 200px; border: 2px solid var(--light-grey); - padding: var(--space-sm); - margin: var(--space-sm); + padding: var(--space-s); + margin: var(--space-s); outline: none; display: block; } diff --git a/tests/dummy/app/styles/example3.scss b/tests/dummy/app/styles/example3.scss index 72f5b16b..13decbdc 100644 --- a/tests/dummy/app/styles/example3.scss +++ b/tests/dummy/app/styles/example3.scss @@ -7,7 +7,7 @@ @include example.box-focused; } - &[data-open='false'] .select-box__options { + .select-box__dropdown[data-open='false'] .select-box__options { @include example.screen-reader-only; } diff --git a/tests/dummy/app/styles/example4.scss b/tests/dummy/app/styles/example4.scss index 8ef53f33..bf7171d3 100644 --- a/tests/dummy/app/styles/example4.scss +++ b/tests/dummy/app/styles/example4.scss @@ -7,7 +7,7 @@ @include example.box-focused; } - &[data-open='false'] .select-box__options { + .select-box__dropdown[data-open='false'] .select-box__options { @include example.screen-reader-only; } diff --git a/tests/dummy/app/styles/example6.scss b/tests/dummy/app/styles/example6.scss index c281a29a..716bbab5 100644 --- a/tests/dummy/app/styles/example6.scss +++ b/tests/dummy/app/styles/example6.scss @@ -7,6 +7,11 @@ @include example.box-focused; } + .dropdown__content { + display: flex; + flex-direction: column; + } + .select-box__trigger { @include example.trigger; @@ -37,8 +42,3 @@ @include example.option-selected; } } - -.example6__dropdown { - display: flex; - flex-direction: column; -} diff --git a/tests/dummy/app/styles/variables.scss b/tests/dummy/app/styles/variables.scss index c7c22a21..e69f8a9a 100644 --- a/tests/dummy/app/styles/variables.scss +++ b/tests/dummy/app/styles/variables.scss @@ -6,7 +6,8 @@ --red: red; --blue: blue; --white: white; - --space-lg: 6px; - --space-md: 4px; - --space-sm: 2px; + --space-xl: 10px; + --space-l: 6px; + --space-m: 4px; + --space-s: 2px; } diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs index 8478ddb2..755034e9 100644 --- a/tests/dummy/app/templates/application.hbs +++ b/tests/dummy/app/templates/application.hbs @@ -30,6 +30,10 @@ Combobox with input outside + | + + Dropdown +

diff --git a/tests/dummy/app/templates/custom-trigger.hbs b/tests/dummy/app/templates/custom-trigger.hbs deleted file mode 100644 index f022f634..00000000 --- a/tests/dummy/app/templates/custom-trigger.hbs +++ /dev/null @@ -1,26 +0,0 @@ -

- Selected: - {{this.selected.name}} -

- - - - - {{if sb.value sb.value.name "None"}} - - - {{#if sb.isOpen}} - - {{#each this.data.pies as |value|}} - - {{value.name}} - - {{/each}} - - {{/if}} - \ No newline at end of file diff --git a/tests/dummy/app/templates/dropdown.hbs b/tests/dummy/app/templates/dropdown.hbs new file mode 100644 index 00000000..3ae6b6ef --- /dev/null +++ b/tests/dummy/app/templates/dropdown.hbs @@ -0,0 +1,20 @@ +

+ This addon utilises a dropdown component to create comboboxes that can open + and close like a native single select. +

+ + + + Click here + + {{#if dd.isOpen}} + +
+ Non interactive element +
+ + Interactive element + +
+ {{/if}} +
\ No newline at end of file diff --git a/tests/dummy/app/templates/performance.hbs b/tests/dummy/app/templates/performance.hbs index 32af83eb..edb72320 100644 --- a/tests/dummy/app/templates/performance.hbs +++ b/tests/dummy/app/templates/performance.hbs @@ -9,16 +9,20 @@ @onChange={{this.handleSelect}} as |sb| > - - {{if sb.value sb.value.name "None"}} - - {{#if sb.isOpen}} - - {{#each this.data as |value|}} - - {{value.name}} - - {{/each}} - - {{/if}} + + + {{if sb.value sb.value.name "None"}} + + {{#if dd.isOpen}} + + + {{#each this.data as |value|}} + + {{value.name}} + + {{/each}} + + + {{/if}} + \ No newline at end of file diff --git a/tests/integration/components/dropdown/content/render-test.gjs b/tests/integration/components/dropdown/content/render-test.gjs new file mode 100644 index 00000000..c2bd6fd2 --- /dev/null +++ b/tests/integration/components/dropdown/content/render-test.gjs @@ -0,0 +1,46 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, find } from '@ember/test-helpers'; +import Dropdown from '@zestia/ember-select-box/components/dropdown'; + +module('dropdown/content', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown__content').hasTagName('div'); + }); + + test('it renders', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown__content').hasText('Hello World'); + }); + + test('splattributes', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown__content').hasClass('foo'); + }); +}); diff --git a/tests/integration/components/dropdown/index/api-test.gjs b/tests/integration/components/dropdown/index/api-test.gjs new file mode 100644 index 00000000..c3405f03 --- /dev/null +++ b/tests/integration/components/dropdown/index/api-test.gjs @@ -0,0 +1,90 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { find, render, rerender, click } from '@ember/test-helpers'; +import Dropdown from '@zestia/ember-select-box/components/dropdown'; + +module('dropdownx (api)', function (hooks) { + setupRenderingTest(hooks); + + test('api', async function (assert) { + assert.expect(8); + + let api; + let api2; + + const handleReady = (dd) => (api = dd); + const capture = (dd) => (api2 = dd); + + await render(); + + assert.strictEqual(api, api2); + + // Components + assert.strictEqual(typeof api.Trigger, 'object'); + assert.strictEqual(typeof api.Content, 'object'); + + // Properties + assert.deepEqual(api.element, find('.dropdown')); + assert.strictEqual(api.isOpen, false); + + // Actions + assert.strictEqual(typeof api.open, 'function'); + assert.strictEqual(typeof api.close, 'function'); + assert.strictEqual(typeof api.toggle, 'function'); + }); + + test('isOpen', async function (assert) { + assert.expect(2); + + let api; + + const handleReady = (dd) => (api = dd); + + await render(); + + assert.false(api.isOpen); + + await click('.dropdown__trigger'); + + assert.true(api.isOpen); + }); + + test('toggle', async function (assert) { + assert.expect(3); + + let api; + + const handleReady = (dd) => (api = dd); + + await render(); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + + api.toggle(); + + await rerender(); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + api.toggle(); + + await rerender(); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + }); +}); diff --git a/tests/integration/components/dropdown/index/click-trigger-test.gjs b/tests/integration/components/dropdown/index/click-trigger-test.gjs new file mode 100644 index 00000000..3c3713b7 --- /dev/null +++ b/tests/integration/components/dropdown/index/click-trigger-test.gjs @@ -0,0 +1,52 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click } from '@ember/test-helpers'; +import Dropdown from '@zestia/ember-select-box/components/dropdown'; + +module('dropdownx (clicking trigger)', function (hooks) { + setupRenderingTest(hooks); + + test('clicking trigger', async function (assert) { + assert.expect(7); + + // Whether or not the Content renders is up to the developer. + // This allows it to be hidden with CSS instead if preferred. + + await render(); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + assert.dom('.dropdown__trigger').hasAttribute('aria-expanded', 'false'); + assert.dom('.dropdown__content').exists(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + assert.dom('.dropdown__trigger').hasAttribute('aria-expanded', 'true'); + assert.dom('.dropdown__content').exists(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + }); + + test('right clicking trigger', async function (assert) { + assert.expect(2); + + await render(); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + + await click('.dropdown__trigger', { button: 2 }); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + }); +}); diff --git a/tests/integration/components/dropdown/index/closing-test.gjs b/tests/integration/components/dropdown/index/closing-test.gjs new file mode 100644 index 00000000..e04fd0c0 --- /dev/null +++ b/tests/integration/components/dropdown/index/closing-test.gjs @@ -0,0 +1,278 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { + render, + click, + rerender, + triggerEvent, + triggerKeyEvent +} from '@ember/test-helpers'; +import Dropdown from '@zestia/ember-select-box/components/dropdown'; +import { on } from '@ember/modifier'; +import { tracked } from '@glimmer/tracking'; +import autoFocus from '@zestia/ember-auto-focus/modifiers/auto-focus'; + +module('dropdownx (closing)', function (hooks) { + setupRenderingTest(hooks); + + let handleClose; + + hooks.beforeEach(function (assert) { + handleClose = (reason) => assert.step(`close ${reason.description}`); + }); + + test('closing with api', async function (assert) { + assert.expect(3); + + let api; + + const handleReady = (dd) => (api = dd); + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + api.close(); + + await rerender(); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + assert.dom('.dropdown__trigger').isFocused(); + }); + + test('pressing escape', async function (assert) { + assert.expect(3); + + await render(); + + await click('.dropdown__trigger'); + + await triggerKeyEvent('.dropdown', 'keydown', 'Escape'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + assert.verifySteps(['close ESCAPE']); + }); + + test('clicking outside', async function (assert) { + assert.expect(5); + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await click('.outside'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + assert.dom('.dropdown__trigger').isNotFocused(); + assert.verifySteps(['close FOCUS_LEAVE']); + }); + + test('clicking dropdown container', async function (assert) { + assert.expect(5); + + // This tests that the dropdown container is treated + // as empty space, and so click it is the same as + // clicking outside the dropdown content element. + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await click('.dropdown'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + assert.dom('.dropdown__trigger').isNotFocused(); + assert.verifySteps(['close FOCUS_LEAVE']); + }); + + test('clicking a non interactive element inside the dropdown content', async function (assert) { + assert.expect(6); + + // Selecting text won't cause the dropdown to close. + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await click('.inside'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + assert.dom('.dropdown__trigger').isNotFocused(); + assert.verifySteps([]); + + await click('.outside'); + + assert.verifySteps(['close CLICK_OUTSIDE']); + }); + + test('clicking an interactive element inside the dropdown container', async function (assert) { + assert.expect(4); + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await click('.inside'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + assert.dom('.inside').isFocused(); + assert.verifySteps([]); + }); + + test('clicking an interactive element inside the dropdown content', async function (assert) { + assert.expect(5); + + let event; + + const handleMouseDown = (_event) => (event = _event); + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await click('.inside'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + assert.dom('.dropdown__trigger').isNotFocused(); + assert.false(event.defaultPrevented); + assert.verifySteps([]); + }); + + test('closing with api', async function (assert) { + assert.expect(5); + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + // Intentionally twice + await click('.close'); + await click('.close'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + assert.dom('.dropdown__trigger').isNotFocused(); + assert.verifySteps(['close undefined']); + }); + + test('mousing down on the trigger but mousing up outside', async function (assert) { + assert.expect(3); + + // aka click-abort + + await render(); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + + await triggerEvent('.dropdown__trigger', 'mousedown'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await triggerEvent('.outside', 'mouseup'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + }); + + test('closing does not steal focus', async function (assert) { + assert.expect(1); + + const state = new (class { + @tracked showInput; + })(); + + const handleClick = () => { + state.showInput = true; + }; + + await render(); + + await click('.dropdown__trigger'); + await click('.inside'); + + assert.dom('.outside').isFocused(); + }); +}); diff --git a/tests/integration/components/dropdown/index/focus-test.gjs b/tests/integration/components/dropdown/index/focus-test.gjs new file mode 100644 index 00000000..2d1c1d69 --- /dev/null +++ b/tests/integration/components/dropdown/index/focus-test.gjs @@ -0,0 +1,160 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, focus, click, blur } from '@ember/test-helpers'; +import Dropdown from '@zestia/ember-select-box/components/dropdown'; +import { on } from '@ember/modifier'; + +module('dropdownx (focus)', function (hooks) { + setupRenderingTest(hooks); + + test('focus leaving the dropdown trigger', async function (assert) { + assert.expect(2); + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await blur('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + }); + + test('focus leaving the dropdown trigger when manually opened', async function (assert) { + assert.expect(2); + + await render(); + + await focus('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await focus('.outside'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + }); + + test('focus leaving an interactive element inside the dropdown', async function (assert) { + assert.expect(2); + + await render(); + + await click('.dropdown__trigger'); + await focus('.inside'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await focus('.outside'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + }); + + test('focus leaving an interactive element inside the content', async function (assert) { + assert.expect(2); + + await render(); + + await click('.dropdown__trigger'); + await focus('.inside'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await focus('.outside'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + }); + + test('focus moving inside the dropdown', async function (assert) { + assert.expect(3); + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await focus('.inside'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + assert.dom('.inside').isFocused(); + }); + + test('focus moving inside the content', async function (assert) { + assert.expect(3); + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await focus('.inside'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + assert.dom('.inside').isFocused(); + }); + + test('keyboard-focusable-scrollers', async function (assert) { + assert.expect(2); + + // we use do not employ any methods to prevent + // keyboard-focusable-scrollers from taking affect. + + let event; + + const handleMouseDown = (_event) => (event = _event); + + await render(); + + await click('.dropdown__trigger'); + await click('.dropdown__content'); + + assert.dom('.dropdown__content').doesNotHaveAttribute('tabindex'); + assert.false(event.defaultPrevented); + }); +}); diff --git a/tests/integration/components/dropdown/index/in-element-test.gjs b/tests/integration/components/dropdown/index/in-element-test.gjs new file mode 100644 index 00000000..5c360204 --- /dev/null +++ b/tests/integration/components/dropdown/index/in-element-test.gjs @@ -0,0 +1,49 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { click, render, find } from '@ember/test-helpers'; +import Dropdown from '@zestia/ember-select-box/components/dropdown'; + +module('dropdownx (in-element)', function (hooks) { + setupRenderingTest(hooks); + + const destination = () => find('.destination'); + + test('a common scenario of rendering a dropdown in an external element works', async function (assert) { + assert.expect(6); + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + assert.dom('.dropdown .dropdown__content').doesNotExist(); + assert.dom('.destination .dropdown__content').exists(); + + await click('.test'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await click('.outside'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + assert.dom('.outside').isFocused(); + }); +}); diff --git a/tests/integration/components/dropdown/index/key-enter-test.gjs b/tests/integration/components/dropdown/index/key-enter-test.gjs new file mode 100644 index 00000000..2316f089 --- /dev/null +++ b/tests/integration/components/dropdown/index/key-enter-test.gjs @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, triggerKeyEvent } from '@ember/test-helpers'; +import Dropdown from '@zestia/ember-select-box/components/dropdown'; + +module('dropdownx (enter)', function (hooks) { + setupRenderingTest(hooks); + + test('enter on trigger', async function (assert) { + assert.expect(2); + + await render(); + + await triggerKeyEvent('.dropdown__trigger', 'keydown', 'Enter'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await triggerKeyEvent('.dropdown__trigger', 'keydown', 'Enter'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + }); +}); diff --git a/tests/integration/components/dropdown/index/key-escape-test.gjs b/tests/integration/components/dropdown/index/key-escape-test.gjs new file mode 100644 index 00000000..a897bab3 --- /dev/null +++ b/tests/integration/components/dropdown/index/key-escape-test.gjs @@ -0,0 +1,128 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, click, focus, triggerKeyEvent } from '@ember/test-helpers'; +import { on } from '@ember/modifier'; +import Dropdown from '@zestia/ember-select-box/components/dropdown'; + +module('dropdownx (escape)', function (hooks) { + setupRenderingTest(hooks); + + let handleClose; + + hooks.beforeEach(function (assert) { + handleClose = () => assert.step('close'); + }); + + test('escape on trigger', async function (assert) { + assert.expect(4); + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await triggerKeyEvent('.dropdown__trigger', 'keydown', 'Escape'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + assert.verifySteps(['close']); + }); + + test('escape on dropdown', async function (assert) { + assert.expect(4); + + // This ensures our listeners are on the dropdown itself, + // and not just on the trigger. + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await focus('.inside'); + await triggerKeyEvent('.dropdown', 'keydown', 'Escape'); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + assert.verifySteps(['close']); + }); + + test('escape inside something else escapable (closed)', async function (assert) { + assert.expect(1); + + let event; + + const handleKeyDownParent = (_event) => (event = _event); + + await render(); + + await triggerKeyEvent('.dropdown', 'keydown', 'Escape'); + + assert.true( + event instanceof Event, + 'event not stopped, escape allowed to bubble up' + ); + }); + + test('escape inside something else escapable (open)', async function (assert) { + assert.expect(5); + + let event; + + const handleKeyDownParent = (_event) => (event = _event); + + await render(); + + await click('.parent .dropdown__trigger'); + await click('.child .dropdown__trigger'); + await triggerKeyEvent('.child', 'keydown', 'Escape'); + + assert.dom('.parent').hasAttribute('data-open', 'true'); + assert.dom('.child').hasAttribute('data-open', 'false'); + + assert.notOk( + event, + `event propagation is stopped, since escape has caused the dropdown + to close. we don't want escape to also close the parent element + that the select box is contained within` + ); + + assert.verifySteps(['close']); + }); +}); diff --git a/tests/integration/components/dropdown/index/key-space-test.gjs b/tests/integration/components/dropdown/index/key-space-test.gjs new file mode 100644 index 00000000..0074ed7c --- /dev/null +++ b/tests/integration/components/dropdown/index/key-space-test.gjs @@ -0,0 +1,26 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, triggerKeyEvent } from '@ember/test-helpers'; +import Dropdown from '@zestia/ember-select-box/components/dropdown'; + +module('dropdownx', function (hooks) { + setupRenderingTest(hooks); + + test('space on trigger (space)', async function (assert) { + assert.expect(2); + + await render(); + + await triggerKeyEvent('.dropdown__trigger', 'keydown', ' '); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + + await triggerKeyEvent('.dropdown__trigger', 'keydown', ' '); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + }); +}); diff --git a/tests/integration/components/dropdown/index/opening-test.gjs b/tests/integration/components/dropdown/index/opening-test.gjs new file mode 100644 index 00000000..2e2d489a --- /dev/null +++ b/tests/integration/components/dropdown/index/opening-test.gjs @@ -0,0 +1,72 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, rerender } from '@ember/test-helpers'; +import { tracked } from '@glimmer/tracking'; +import Dropdown from '@zestia/ember-select-box/components/dropdown'; + +module('dropdownx (opening)', function (hooks) { + setupRenderingTest(hooks); + + test('open', async function (assert) { + assert.expect(2); + + let api; + + const handleReady = (dd) => (api = dd); + + await render(); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + + api.open(); + + await rerender(); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + }); + + test('can set initial open state', async function (assert) { + assert.expect(3); + + await render(); + + assert.dom('.dropdown').hasAttribute('data-open', 'true'); + assert.dom('.dropdown__trigger').hasAttribute('aria-expanded', 'true'); + assert.dom('.dropdown__trigger').isNotFocused(); + }); + + test('cannot open dropdown manually with argument', async function (assert) { + assert.expect(2); + + // We can easily support this using localCopy. + // But, changing `@open` will not pass through the expected + // code path, like it would with a user interaction, and so + // onOpen will not fire if we do that. + + const state = new (class { + @tracked isOpen; + })(); + + await render(); + + state.isOpen = true; + + await rerender(); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + assert.dom('.dropdown__trigger').hasAttribute('aria-expanded', 'false'); + }); +}); diff --git a/tests/integration/components/dropdown/index/render-test.gjs b/tests/integration/components/dropdown/index/render-test.gjs new file mode 100644 index 00000000..9537f4a9 --- /dev/null +++ b/tests/integration/components/dropdown/index/render-test.gjs @@ -0,0 +1,40 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, find } from '@ember/test-helpers'; +import Dropdown from '@zestia/ember-select-box/components/dropdown'; + +module('dropdownx', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown').hasTagName('div'); + }); + + test('open', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown').hasAttribute('data-open', 'false'); + }); + + test('splattributes', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown').hasClass('foo'); + }); + + test('whitespace', async function (assert) { + assert.expect(1); + + await render(); + + assert.strictEqual(find('.dropdown').innerHTML, ''); + }); +}); diff --git a/tests/integration/components/dropdown/trigger/render-test.gjs b/tests/integration/components/dropdown/trigger/render-test.gjs new file mode 100644 index 00000000..443f0aca --- /dev/null +++ b/tests/integration/components/dropdown/trigger/render-test.gjs @@ -0,0 +1,152 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { render, find } from '@ember/test-helpers'; +import Dropdown from '@zestia/ember-select-box/components/dropdown'; + +module('dropdown/trigger', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown__trigger').hasTagName('div'); + }); + + test('splattributes', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown__trigger').hasClass('foo'); + }); + + test('role', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown__trigger').doesNotHaveAttribute('role'); + }); + + test('role (closure component)', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown__trigger').hasAttribute('role', 'button'); + }); + + test('tabindex', async function (assert) { + assert.expect(1); + + // Requires tabindex so that Safari will populate relatedTarget + // and handle focus properly. + + await render(); + + assert.dom('.dropdown__trigger').hasAttribute('tabindex', '0'); + }); + + test('tabindex (closure component)', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown__trigger').hasAttribute('tabindex', '-1'); + }); + + test('class (closure component)', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown__trigger').hasClass('foo'); + }); + + test('whitespace', async function (assert) { + assert.expect(1); + + await render(); + + assert.strictEqual(find('.dropdown__trigger').innerHTML, ''); + }); + + test('aria defaults', async function (assert) { + assert.expect(6); + + await render(); + + assert + .dom('.dropdown__trigger') + .hasAttribute('aria-expanded', 'false') + .hasAttribute('aria-haspopup', 'true') + .doesNotHaveAttribute('aria-busy') + .doesNotHaveAttribute('aria-controls') + .doesNotHaveAttribute('aria-disabled') + .doesNotHaveAttribute('aria-activedescendant'); + }); + + test('aria (closure component)', async function (assert) { + assert.expect(6); + + await render(); + + assert + .dom('.dropdown__trigger') + .hasAttribute('aria-haspopup', 'true') + .hasAttribute('aria-expanded', 'true') + .hasAttribute('aria-busy', 'true') + .hasAttribute('aria-controls', 'foo') + .hasAttribute('aria-disabled', 'true') + .hasAttribute('aria-activedescendant', 'bar'); + }); +}); diff --git a/tests/integration/components/select-box/closing-test.gjs b/tests/integration/components/select-box/closing-test.gjs deleted file mode 100644 index 66df39c2..00000000 --- a/tests/integration/components/select-box/closing-test.gjs +++ /dev/null @@ -1,301 +0,0 @@ -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'dummy/tests/helpers'; -import { - render, - click, - blur, - focus, - triggerEvent, - triggerKeyEvent -} from '@ember/test-helpers'; -import { tracked } from '@glimmer/tracking'; -import { on } from '@ember/modifier'; -import autoFocus from '@zestia/ember-auto-focus/modifiers/auto-focus'; -import SelectBox from '@zestia/ember-select-box/components/select-box'; - -module('select-box (closing)', function (hooks) { - setupRenderingTest(hooks); - - let api; - let handleClose; - - const handleReady = (sb) => (api = sb); - - hooks.beforeEach(function (assert) { - handleClose = () => assert.step('close'); - }); - - test('closing with api', async function (assert) { - assert.expect(6); - - await render(); - - assert.true(api.isOpen); - - await click('button'); - await click('button'); - - assert.verifySteps(['close']); - - assert.dom('.select-box').hasAttribute('data-open', 'false'); - assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); - }); - - test('closes when trigger loses focus', async function (assert) { - assert.expect(3); - - await render(); - - await focus('.select-box__trigger'); - await blur('.select-box__trigger'); - - assert.dom('.select-box').hasAttribute('data-open', 'false'); - assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); - }); - - test('closes when input loses focus', async function (assert) { - assert.expect(3); - - await render(); - - await focus('.select-box__input'); - await blur('.select-box__input'); - - assert.dom('.select-box').hasAttribute('data-open', 'false'); - assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); - }); - - test('closing listbox', async function (assert) { - assert.expect(1); - - await render(); - - await click('button'); - - assert.verifySteps([], 'listboxes cannot be closed'); - }); - - test('closing does not steal focus', async function (assert) { - assert.expect(2); - - const state = new (class { - @tracked value; - })(); - - const handleChange = (value) => (state.value = value); - - await render(); - - await click('.select-box__trigger'); - await click('.select-box__option'); - - assert.dom('.outside').hasValue('foo').isFocused(); - }); - - test('closing due to loss of focus', async function (assert) { - assert.expect(3); - - await render(); - - await click('.select-box__trigger'); - await blur('.select-box__trigger'); - - assert.dom('.select-box').hasAttribute('data-open', 'false'); - assert.verifySteps(['close']); - }); - - test('closing due to mousing up outside', async function (assert) { - assert.expect(3); - - await render(); - - await triggerEvent('.select-box__trigger', 'mousedown'); - await triggerEvent('.outside', 'mouseup'); - - assert.dom('.select-box').hasAttribute('data-open', 'false'); - assert.verifySteps(['close']); - }); - - test('mousing up outside must have moused down first', async function (assert) { - assert.expect(2); - - await render(); - - 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(); - - 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); - - await render(); - - await click('.select-box__trigger'); - await triggerKeyEvent('.select-box__trigger', 'keydown', 'Escape'); - - assert.dom('.select-box').hasAttribute('data-open', 'false'); - assert.verifySteps(['close']); - }); - - test('close only fires once', async function (assert) { - assert.expect(2); - - await render(); - - await click('.select-box__trigger'); - await blur('.select-box__trigger'); - await triggerEvent('.outside', 'mouseup'); - - assert.verifySteps( - ['close'], - `a select box may close on focus leave and mousing up outside, - if focus is lost _because_ of the mousing up outside, then - the onClose action only fires once, not twice` - ); - }); - - test('programmatically closing', async function (assert) { - assert.expect(3); - - const handleSelect = () => false; - - await render(); - - await click('.select-box__trigger'); - - assert.verifySteps( - [], - `the return value of onSelect controls whether or not the - select box will close after making the selection` - ); - - await click('button'); - - assert.verifySteps(['close']); - }); - - test('clicking to programmatically close', async function (assert) { - assert.expect(1); - - await render(); - - await click('.select-box__trigger'); - await click('button'); - - assert.ok(true, 'does not cause infinite revalidation bug'); - }); -}); diff --git a/tests/integration/components/group/render-test.gjs b/tests/integration/components/select-box/group/render-test.gjs similarity index 100% rename from tests/integration/components/group/render-test.gjs rename to tests/integration/components/select-box/group/render-test.gjs diff --git a/tests/integration/components/select-box/api-test.gjs b/tests/integration/components/select-box/index/api-test.gjs similarity index 65% rename from tests/integration/components/select-box/api-test.gjs rename to tests/integration/components/select-box/index/api-test.gjs index 476332cd..058f5a5e 100644 --- a/tests/integration/components/select-box/api-test.gjs +++ b/tests/integration/components/select-box/index/api-test.gjs @@ -1,6 +1,13 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { find, render, fillIn, settled, click } from '@ember/test-helpers'; +import { + find, + render, + rerender, + fillIn, + settled, + click +} from '@ember/test-helpers'; import { tracked } from '@glimmer/tracking'; import { on } from '@ember/modifier'; import { fn } from '@ember/helper'; @@ -10,7 +17,7 @@ module('select-box (api)', function (hooks) { setupRenderingTest(hooks); test('api', async function (assert) { - assert.expect(18); + assert.expect(16); let api; let api2; @@ -20,10 +27,14 @@ module('select-box (api)', function (hooks) { await render(); @@ -34,26 +45,42 @@ module('select-box (api)', function (hooks) { assert.strictEqual(typeof api.Input, 'object'); assert.strictEqual(typeof api.Option, 'object'); assert.strictEqual(typeof api.Options, 'object'); + assert.strictEqual(typeof api.Dropdown, 'object'); assert.strictEqual(typeof api.Trigger, 'object'); // Properties assert.deepEqual(api.element, find('.select-box')); assert.strictEqual(api.isBusy, null); - assert.false(api.isOpen); assert.strictEqual(api.options, undefined); assert.strictEqual(api.query, null); assert.strictEqual(api.value, undefined); + assert.strictEqual(typeof api.dropdown, 'object'); // Actions - assert.strictEqual(typeof api.close, 'function'); - assert.strictEqual(typeof api.open, 'function'); assert.strictEqual(typeof api.search, 'function'); - assert.strictEqual(typeof api.toggle, 'function'); assert.strictEqual(typeof api.update, 'function'); assert.strictEqual(typeof api.select, 'function'); }); - test('provides access to two useful elements', async function (assert) { + test('api without dropdown', async function (assert) { + assert.expect(2); + + let api; + + const handleReady = (sb) => (api = sb); + + await render(); + + assert.strictEqual(typeof api.Dropdown, 'object'); + assert.strictEqual( + api.Trigger, + undefined, + `can't render a select box trigger without first + having rendered a dropdown.` + ); + }); + + test('provides access to the main element', async function (assert) { assert.expect(1); let api; @@ -69,7 +96,7 @@ module('select-box (api)', function (hooks) { assert.strictEqual(api.element, find('.select-box')); }); - test('isBusy (searchable)', async function (assert) { + test('isBusy (aka searchable)', async function (assert) { assert.expect(3); let api; @@ -81,9 +108,8 @@ module('select-box (api)', function (hooks) { await render(); @@ -125,15 +151,17 @@ module('select-box (api)', function (hooks) { await render(); - assert.false(api.isOpen); + assert.false(api.dropdown.isOpen); await click('.select-box__trigger'); - assert.true(api.isOpen); + assert.true(api.dropdown.isOpen); }); test('value', async function (assert) { @@ -184,66 +212,6 @@ module('select-box (api)', function (hooks) { assert.verifySteps(['foo']); }); - test('open', async function (assert) { - assert.expect(3); - - await render(); - - await click('button'); - - assert.dom('.select-box').hasAttribute('data-open', 'true'); - assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'true'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'true'); - }); - - test('close', async function (assert) { - assert.expect(3); - - await render(); - - await click('button'); - - assert.dom('.select-box').hasAttribute('data-open', 'false'); - assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); - }); - - test('toggle', async function (assert) { - assert.expect(6); - - await render(); - - await click('button'); - - assert.dom('.select-box').hasAttribute('data-open', 'true'); - assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'true'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'true'); - - await click('button'); - - assert.dom('.select-box').hasAttribute('data-open', 'false'); - assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); - }); - test('select', async function (assert) { assert.expect(8); @@ -303,30 +271,32 @@ module('select-box (api)', function (hooks) { test('select (not focused)', async function (assert) { assert.expect(4); - const state = new (class { - @tracked api; - })(); + let api; - const handleReady = (sb) => (state.api = sb); + const handleReady = (sb) => (api = sb); await render(); - await click('button'); + api.select('2'); + + await rerender(); assert .dom('.select-box__option:nth-child(2)') - .hasAttribute('aria-selected', 'true', 'precondition'); + .hasAttribute('aria-selected', 'true'); assert.dom('.select-box__input').isNotFocused(); assert.dom('.select-box__trigger').isNotFocused(); @@ -334,30 +304,36 @@ module('select-box (api)', function (hooks) { }); test('select (closes)', async function (assert) { - assert.expect(1); + assert.expect(2); - const state = new (class { - @tracked api; - })(); + let api; - const handleReady = (sb) => (state.api = sb); + const handleReady = (sb) => (api = sb); await render(); - await click('button'); + await click('.dropdown__trigger'); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); + + api.select('2'); + + await rerender(); assert - .dom('.select-box') + .dom('.select-box__dropdown') .hasAttribute( 'data-open', 'false', @@ -368,25 +344,21 @@ module('select-box (api)', function (hooks) { test('select (disabled option)', async function (assert) { assert.expect(2); - const state = new (class { - @tracked api; - })(); + let api; - const handleReady = (sb) => (state.api = sb); + const handleReady = (sb) => (api = sb); await render(); - await click('button'); + api.select('foo'); + + await rerender(); assert .dom('.select-box__option:nth-child(1)') @@ -426,7 +398,6 @@ module('select-box (api)', function (hooks) { @onBuildSelection={{handleBuildSelection}} as |sb| > - @@ -434,8 +405,10 @@ module('select-box (api)', function (hooks) { ); - await click('button'); - await click('button'); + api.update('2'); + api.update('2'); + + await rerender(); assert.strictEqual(state.value, '1', 'does not mutate value'); assert.strictEqual(api.value, '2', 'api is correct'); @@ -469,31 +442,4 @@ module('select-box (api)', function (hooks) { await click('button'); }); - - test('open boolean with a trigger defaults to closed', async function (assert) { - assert.expect(2); - - await render(); - - assert.dom('.select-box').hasAttribute('data-open', 'false'); - assert.dom('.select-box__options').hasAttribute('data-open', 'false'); - }); - - test('open boolean without a trigger is undefined', async function (assert) { - assert.expect(2); - - await render(); - - assert.dom('.select-box').doesNotHaveAttribute('data-open'); - assert.dom('.select-box__options').doesNotHaveAttribute('data-open'); - }); }); diff --git a/tests/integration/components/select-box/change-options-test.gjs b/tests/integration/components/select-box/index/change-options-test.gjs similarity index 98% rename from tests/integration/components/select-box/change-options-test.gjs rename to tests/integration/components/select-box/index/change-options-test.gjs index 0411a1c0..3ef0ed53 100644 --- a/tests/integration/components/select-box/change-options-test.gjs +++ b/tests/integration/components/select-box/index/change-options-test.gjs @@ -99,18 +99,21 @@ module('select-box (changing options)', function (hooks) { assert.expect(7); const options = [ + // a 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', + // b 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', + // c 'c1', 'c2', 'c3', @@ -150,12 +153,12 @@ module('select-box (changing options)', function (hooks) { assert.dom('.select-box__option').exists({ count: 18 }); assert.dom('.select-box__option[aria-current="true"]').hasText('b3'); - // 16px each times 6 preceeding options + // 16px each, times 6 preceding options assert.strictEqual(find('.select-box__options').scrollTop, 96); await fillIn('.select-box__input', 'b'); - // 16px each times 2 preceeding options + // 16px each, times 2 preceding options assert.strictEqual( find('.select-box__options').scrollTop, 32, diff --git a/tests/integration/components/select-box/click-input-test.gjs b/tests/integration/components/select-box/index/click-input-test.gjs similarity index 80% rename from tests/integration/components/select-box/click-input-test.gjs rename to tests/integration/components/select-box/index/click-input-test.gjs index 47d36227..eebc0287 100644 --- a/tests/integration/components/select-box/click-input-test.gjs +++ b/tests/integration/components/select-box/index/click-input-test.gjs @@ -11,14 +11,16 @@ module('select-box (clicking input)', function (hooks) { await render(); await click('.select-box__input'); - assert.dom('.select-box').hasAttribute('data-open', 'false'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); }); diff --git a/tests/integration/components/select-box/click-option-test.gjs b/tests/integration/components/select-box/index/click-option-test.gjs similarity index 69% rename from tests/integration/components/select-box/click-option-test.gjs rename to tests/integration/components/select-box/index/click-option-test.gjs index 1c551e60..7c2d3c32 100644 --- a/tests/integration/components/select-box/click-option-test.gjs +++ b/tests/integration/components/select-box/index/click-option-test.gjs @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, click, triggerEvent } from '@ember/test-helpers'; +import { render, click } from '@ember/test-helpers'; import { on } from '@ember/modifier'; import { tracked } from '@glimmer/tracking'; import SelectBox from '@zestia/ember-select-box/components/select-box'; @@ -45,7 +45,7 @@ module('select-box (clicking option)', function (hooks) { }); test('clicking on a child', async function (assert) { - assert.expect(4); + assert.expect(5); let event; @@ -54,37 +54,45 @@ module('select-box (clicking option)', function (hooks) { await render(); await click('.select-box__trigger'); - assert.dom('.select-box').hasAttribute('data-open', 'true', 'precondition'); + assert + .dom('.select-box__dropdown') + .hasAttribute('data-open', 'true', 'precondition'); - await triggerEvent('.select-box__option', 'mousedown'); await click('a'); assert.true( event.defaultPrevented, - 'focus will not leave the trigger or input' + `focus will not leave the trigger or input. but, + the link will still be visited. prevent default on the link click, if + you wish to have options that can be cmd clicked to open in a new tab` ); - assert.dom('.select-box').hasAttribute( + assert.dom('.select-box__dropdown').hasAttribute( 'data-open', 'false', - ` - the select box closes because an option was selected. even though the target - was a child of the option. this allows for greater flexibility building UI's - that require custom markup - ` + `the select box closes because an option was selected. even though the target + was a child of the option.` ); + assert.dom('.select-box__trigger').hasText('#'); + assert.dom('.select-box__trigger').isFocused('does not lose focus'); }); @@ -93,21 +101,27 @@ module('select-box (clicking option)', function (hooks) { await render(); await click('.select-box__trigger'); - assert.dom('.select-box').hasAttribute('data-open', 'true', 'precondition'); + assert + .dom('.select-box__dropdown') + .hasAttribute('data-open', 'true', 'precondition'); await click('.select-box__option'); assert - .dom('.select-box') + .dom('.select-box__dropdown') .hasAttribute( 'data-open', 'false', @@ -120,20 +134,26 @@ module('select-box (clicking option)', function (hooks) { await render(); await click('.select-box__trigger'); - assert.dom('.select-box').hasAttribute('data-open', 'true', 'precondition'); + assert + .dom('.select-box__dropdown') + .hasAttribute('data-open', 'true', 'precondition'); await click('.select-box__option'); - assert.dom('.select-box').hasAttribute( + assert.dom('.select-box__dropdown').hasAttribute( 'data-open', 'true', `assume that more options are to be selected. do not assume its ok to close @@ -177,12 +197,16 @@ module('select-box (clicking option)', function (hooks) { await render(); diff --git a/tests/integration/components/select-box/click-trigger-test.gjs b/tests/integration/components/select-box/index/click-trigger-test.gjs similarity index 60% rename from tests/integration/components/select-box/click-trigger-test.gjs rename to tests/integration/components/select-box/index/click-trigger-test.gjs index 63586579..5f92ebb3 100644 --- a/tests/integration/components/select-box/click-trigger-test.gjs +++ b/tests/integration/components/select-box/index/click-trigger-test.gjs @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, triggerEvent } from '@ember/test-helpers'; +import { render, click, triggerEvent } from '@ember/test-helpers'; import { on } from '@ember/modifier'; import SelectBox from '@zestia/ember-select-box/components/select-box'; @@ -11,7 +11,7 @@ module('select-box (clicking trigger)', function (hooks) { assert.expect(7); // Mouse down makes the select box feel more responsive - // than on click, which would open on mouseup. + // than on click, which would open on mouse up. // It's also what would happen if you clicked on a // native select box. @@ -22,24 +22,32 @@ module('select-box (clicking trigger)', function (hooks) { await render(); - assert.dom('.select-box').hasAttribute('data-open', 'false'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); await triggerEvent('.select-box__trigger', 'mousedown'); - assert.dom('.select-box').hasAttribute('data-open', 'true'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'true'); await triggerEvent('.select-box__trigger', 'mousedown'); - assert.dom('.select-box').hasAttribute('data-open', 'false'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); - assert.true(event.defaultPrevented); + assert.true( + event.defaultPrevented, + `whilst mousing down on the trigger would ordinarily focus it, + here we prevent that, so that we can manually control where + focus moves to next. e.g. either remaining on the trigger, + or advancing the a comobox's input` + ); }); test('right clicking trigger does not open select box', async function (assert) { @@ -47,13 +55,15 @@ module('select-box (clicking trigger)', function (hooks) { await render(); - await triggerEvent('.select-box__trigger', 'mousedown', { button: 2 }); + await click('.select-box__trigger', { button: 2 }); - assert.dom('.select-box').hasAttribute('data-open', 'false'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); }); }); diff --git a/tests/integration/components/select-box/index/closing-test.gjs b/tests/integration/components/select-box/index/closing-test.gjs new file mode 100644 index 00000000..fb212350 --- /dev/null +++ b/tests/integration/components/select-box/index/closing-test.gjs @@ -0,0 +1,313 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'dummy/tests/helpers'; +import { + render, + click, + blur, + focus, + triggerEvent, + triggerKeyEvent +} from '@ember/test-helpers'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import autoFocus from '@zestia/ember-auto-focus/modifiers/auto-focus'; +import SelectBox from '@zestia/ember-select-box/components/select-box'; + +module('select-box (closing)', function (hooks) { + setupRenderingTest(hooks); + + let handleClose; + + hooks.beforeEach(function (assert) { + handleClose = () => assert.step('close'); + }); + + test('closing with api', async function (assert) { + assert.expect(8); + + await render(); + + await click('.dropdown__trigger'); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); + assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'true'); + assert.dom('.select-box__input').hasAttribute('aria-expanded', 'true'); + + // Intentionally twice + await click('.close'); + await click('.close'); + + assert.verifySteps(['close']); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); + assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); + }); + + test('closes when trigger loses focus', async function (assert) { + assert.expect(6); + + await render(); + + await click('.select-box__trigger'); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); + assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'true'); + assert.dom('.select-box__input').hasAttribute('aria-expanded', 'true'); + + await focus('.select-box__trigger'); + await blur('.select-box__trigger'); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); + assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); + }); + + test('closes when input loses focus', async function (assert) { + assert.expect(4); + + await render(); + + await click('.select-box__trigger'); + + assert.dom('.select-box__input').isFocused(); + + await blur('.select-box__input'); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); + assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); + }); + + test('closing does not steal focus', async function (assert) { + assert.expect(2); + + const state = new (class { + @tracked value; + })(); + + const handleChange = (value) => (state.value = value); + + await render(); + + await click('.select-box__trigger'); + await click('.select-box__option'); + + assert.dom('.outside').hasValue('foo').isFocused(); + }); + + test('closing due to mousing up outside', async function (assert) { + assert.expect(3); + + // This is a 'click abort'. When the user mouses down on the + // select box, drags their cursor over an option, but then + // decides no, and drags their cursor outside the select box + // and releases. + + await render(); + + await triggerEvent('.select-box__trigger', 'mousedown'); + await triggerEvent('.outside', 'mouseup'); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + assert.verifySteps(['close']); + }); + + test('mousing up outside will close a manually opened dropdown', async function (assert) { + assert.expect(4); + + await render(); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); + + await click('.outside'); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + assert.verifySteps(['close']); + }); + + test('closing due to pressing escape', async function (assert) { + assert.expect(3); + + await render(); + + await click('.select-box__trigger'); + await triggerKeyEvent('.select-box__trigger', 'keydown', 'Escape'); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + assert.verifySteps(['close']); + }); + + test('programmatically closing', async function (assert) { + assert.expect(3); + + const handleSelect = () => false; + + await render(); + + await click('.select-box__trigger'); + + assert.verifySteps( + [], + `the return value of onSelect controls whether or not the + select box will close after making the selection` + ); + + await click('button'); + + assert.verifySteps(['close']); + }); + + test('clicking to programmatically close', async function (assert) { + assert.expect(3); + + await render(); + + await click('.select-box__trigger'); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); + + await click('.close'); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + + assert.ok(true, 'does not cause infinite revalidation bug'); + }); + + test('closing forgets previous active option', async function (assert) { + assert.expect(2); + + await render(); + + await click('.select-box__trigger'); + await triggerEvent('.select-box__option:nth-child(2)', 'mouseenter'); + + assert + .dom('.select-box__option:nth-child(2)') + .hasAttribute('aria-current', 'true'); + + await click('.select-box__trigger'); + + assert.dom('.select-box__option[aria-current="true"]').doesNotExist(); + }); +}); diff --git a/tests/integration/components/select-box/conditional-modifier-test.gjs b/tests/integration/components/select-box/index/conditional-modifier-test.gjs similarity index 79% rename from tests/integration/components/select-box/conditional-modifier-test.gjs rename to tests/integration/components/select-box/index/conditional-modifier-test.gjs index d7d88f40..3d5ec3cf 100644 --- a/tests/integration/components/select-box/conditional-modifier-test.gjs +++ b/tests/integration/components/select-box/index/conditional-modifier-test.gjs @@ -12,8 +12,7 @@ module('select-box', function (hooks) { // https://github.com/ember-modifier/ember-modifier/issues/851 const position = modifier( - (dropdown, [container]) => (dropdown.dataset.positioned = 'true'), - { eager: false } + (dropdown, [container]) => (dropdown.dataset.positioned = 'true') ); skip('it does not blow up', async function (assert) { @@ -29,10 +28,14 @@ module('select-box', function (hooks) { await render(); diff --git a/tests/integration/components/select-box/disabling-test.gjs b/tests/integration/components/select-box/index/disabling-test.gjs similarity index 79% rename from tests/integration/components/select-box/disabling-test.gjs rename to tests/integration/components/select-box/index/disabling-test.gjs index 87665a8c..76c93729 100644 --- a/tests/integration/components/select-box/disabling-test.gjs +++ b/tests/integration/components/select-box/index/disabling-test.gjs @@ -32,12 +32,16 @@ module('select-box (disabling)', function (hooks) { await render(); @@ -54,7 +58,6 @@ module('select-box (disabling)', function (hooks) { await render(); - await triggerEvent('.select-box__option', 'mousedown'); + await click('.select-box__option'); assert .dom('.select-box__options') @@ -48,7 +48,7 @@ module('select-box (focus)', function (hooks) { assert.dom('.select-box__input').isNotFocused('does not steal focus'); - await triggerEvent('.select-box__option', 'mousedown'); + await click('.select-box__option'); assert .dom('.select-box__input') @@ -60,16 +60,20 @@ module('select-box (focus)', function (hooks) { await render(); assert.dom('.select-box__trigger').isNotFocused('does not steal focus'); - await triggerEvent('.select-box__option', 'mousedown'); + await click('.select-box__option'); assert .dom('.select-box__trigger') @@ -81,11 +85,15 @@ module('select-box (focus)', function (hooks) { await render(); @@ -151,10 +159,14 @@ module('select-box (focus)', function (hooks) { await render(); @@ -195,14 +207,16 @@ module('select-box (focus)', function (hooks) { await render(); await focus('.select-box__trigger'); - assert.dom('.select-box').hasAttribute('data-open', 'false'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); }); @@ -212,13 +226,17 @@ module('select-box (focus)', function (hooks) { await render(); @@ -232,18 +250,22 @@ module('select-box (focus)', function (hooks) { assert.dom('.select-box__trigger').isFocused('focus restored to trigger'); }); - test('a focused input going due to focus leaving', async function (assert) { + test('a focused input going away', async function (assert) { assert.expect(3); await render(); @@ -578,4 +579,68 @@ module('select-box (focus)', function (hooks) { to the element using the keyboard` ); }); + + test('focus out listbox forgets active option', async function (assert) { + assert.expect(2); + + // We only show the active option when the select box has focus, + // because it is receptive to user input and therefore can + // be selected. When not focused, there is no need for it. + // This is the equivalent of when a select box with a dropdown + // (a combobox) is closed. + + await render(); + + await focus('.select-box__options'); + await triggerEvent('.select-box__option:nth-child(2)', 'mouseenter'); + await triggerEvent('.select-box__options', 'mouseleave'); + + assert + .dom('.select-box__option:nth-child(2)') + .hasAttribute('aria-current', 'true'); + + await blur('.select-box__options'); + + assert.dom('.select-box__option[aria-current="true"]').doesNotExist(); + }); + + test('keyboard-focusable-scrollers fix', async function (assert) { + assert.expect(1); + + // 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 + + let event; + + const handleMouseDown = (_event) => (event = _event); + + await render(); + + await click('.select-box__trigger'); + await click('.select-box__options'); + + assert.true(event.defaultPrevented); + }); }); diff --git a/tests/integration/components/select-box/in-element-test.gjs b/tests/integration/components/select-box/index/in-element-test.gjs similarity index 55% rename from tests/integration/components/select-box/in-element-test.gjs rename to tests/integration/components/select-box/index/in-element-test.gjs index 429ef5ee..ff49cb12 100644 --- a/tests/integration/components/select-box/in-element-test.gjs +++ b/tests/integration/components/select-box/index/in-element-test.gjs @@ -13,17 +13,21 @@ module('select-box (in-element)', function (hooks) { await render(); - await triggerEvent('.select-box__trigger', 'mousedown'); + await click('.select-box__trigger'); - assert.dom('.select-box').hasAttribute('data-open', 'true'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); assert.dom('.select-box .select-box__options').doesNotExist(); assert.dom('.destination .select-box__options').exists(); - // Implied that the mouse will leave the select box, - // because we used in-element so the options are _outside_. - await triggerEvent('.select-box', 'mouseleave'); - await triggerEvent('.select-box__option:nth-child(2)', 'mouseup'); + await click('.select-box__option:nth-child(2)'); - assert.dom('.select-box').hasAttribute('data-open', 'false'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); assert.dom('.select-box__trigger').hasText('bar'); await click('.outside'); diff --git a/tests/integration/components/select-box/jump-to-option-test.gjs b/tests/integration/components/select-box/index/jump-to-option-test.gjs similarity index 86% rename from tests/integration/components/select-box/jump-to-option-test.gjs rename to tests/integration/components/select-box/index/jump-to-option-test.gjs index f6990bb5..60d4525c 100644 --- a/tests/integration/components/select-box/jump-to-option-test.gjs +++ b/tests/integration/components/select-box/index/jump-to-option-test.gjs @@ -47,12 +47,16 @@ module('select-box (jump to option)', function (hooks) { await render(); @@ -71,12 +75,16 @@ module('select-box (jump to option)', function (hooks) { await render(); @@ -95,11 +103,15 @@ module('select-box (jump to option)', function (hooks) { await render(); @@ -118,11 +130,15 @@ module('select-box (jump to option)', function (hooks) { await render(); @@ -253,12 +269,16 @@ module('select-box (jump to option)', function (hooks) { await render(); @@ -367,12 +387,16 @@ module('select-box (jump to option)', function (hooks) { await render(); @@ -392,12 +416,16 @@ module('select-box (jump to option)', function (hooks) { await render(); diff --git a/tests/integration/components/select-box/key-arrow-down-test.gjs b/tests/integration/components/select-box/index/key-arrow-down-test.gjs similarity index 76% rename from tests/integration/components/select-box/key-arrow-down-test.gjs rename to tests/integration/components/select-box/index/key-arrow-down-test.gjs index c3a8f12c..2e61cd2f 100644 --- a/tests/integration/components/select-box/key-arrow-down-test.gjs +++ b/tests/integration/components/select-box/index/key-arrow-down-test.gjs @@ -60,12 +60,16 @@ module('select-box (down arrow key)', function (hooks) { await render(); @@ -181,7 +185,7 @@ module('select-box (down arrow key)', function (hooks) { await focus('.select-box__options'); await triggerKeyEvent('.select-box__options', 'keydown', 'ArrowDown'); - assert.true(event.defaultPrevented); + assert.true(event.defaultPrevented, 'prevents window scrolling'); }); test('down scrolls the active option into view', async function (assert) { @@ -190,19 +194,23 @@ module('select-box (down arrow key)', function (hooks) { await render(); @@ -213,30 +221,11 @@ module('select-box (down arrow key)', function (hooks) { assert.strictEqual(startTop, 16); assert.strictEqual(expectedTop, 32); - assert.strictEqual(find('.select-box__options').scrollTop, startTop); + assert.strictEqual(find('.dropdown__content').scrollTop, startTop); await triggerKeyEvent('.select-box__trigger', 'keydown', 'ArrowDown'); - assert.strictEqual(find('.select-box__options').scrollTop, expectedTop); - }); - - test('down on options will not open listbox', async function (assert) { - assert.expect(1); - - await render(); - - await focus('.select-box__options'); - await triggerKeyEvent('.select-box__options', 'keydown', 'ArrowDown'); - - assert.dom('.select-box').doesNotHaveAttribute('data-open'); + assert.strictEqual(find('.dropdown__content').scrollTop, expectedTop); }); test('down on trigger will open combobox', async function (assert) { @@ -244,12 +233,16 @@ module('select-box (down arrow key)', function (hooks) { await render(); @@ -257,29 +250,31 @@ module('select-box (down arrow key)', function (hooks) { await triggerKeyEvent('.select-box__trigger', 'keydown', 'ArrowDown'); assert - .dom('.select-box') - .hasAttribute('data-open', 'true', 'opens the select box'); + .dom('.select-box__dropdown') + .hasAttribute('data-open', 'true', 'opens the select box dropdown'); assert .dom('.select-box__trigger') .hasAttribute('aria-expanded', 'true', 'opens the combobox box'); - assert - .dom('.select-box__option:nth-child(1)') - .hasAttribute('aria-current', 'true', 'activates the first option'); + assert.dom('.select-box__option[aria-current="true"]').doesNotExist(); }); test('down on trigger will open combobox (with selected value)', async function (assert) { assert.expect(1); await render(); @@ -291,32 +286,33 @@ module('select-box (down arrow key)', function (hooks) { .hasAttribute('aria-current', 'true', 'the selected option is active'); }); - test('down on input will not open combobox (behaviour undefined)', async function (assert) { - assert.expect(3); + test('down on input will open combobox', async function (assert) { + assert.expect(2); + + // We can assume a Trigger is for opening/closing. + // But if there's a dropdown and no trigger, then its + // probably a custom select box, and we make no assumptions + // The developer should probably just render both an input + // and a trigger. await render(); await focus('.select-box__input'); await triggerKeyEvent('.select-box__input', 'keydown', 'ArrowDown'); - assert.dom('.select-box').doesNotHaveAttribute('data-open', 'false'); - - assert - .dom('.select-box__input') - .doesNotHaveAttribute('aria-expanded', 'false'); - - assert - .dom('.select-box__option:nth-child(1)') - .hasAttribute('aria-current', 'true', 'still navigates options'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); }); test('up on options of a listbox after making a selection', async function (assert) { @@ -387,14 +383,20 @@ module('select-box (down arrow key)', function (hooks) { test('does not scroll the active option into view when closed', async function (assert) { assert.expect(1); + // check this + await render(); diff --git a/tests/integration/components/select-box/key-arrow-up-test.gjs b/tests/integration/components/select-box/index/key-arrow-up-test.gjs similarity index 78% rename from tests/integration/components/select-box/key-arrow-up-test.gjs rename to tests/integration/components/select-box/index/key-arrow-up-test.gjs index 84815d07..dd3806a1 100644 --- a/tests/integration/components/select-box/key-arrow-up-test.gjs +++ b/tests/integration/components/select-box/index/key-arrow-up-test.gjs @@ -65,12 +65,16 @@ module('select-box (up arrow key)', function (hooks) { await render(); @@ -178,7 +182,7 @@ module('select-box (up arrow key)', function (hooks) { await focus('.select-box__options'); await triggerKeyEvent('.select-box__options', 'keydown', 'ArrowUp'); - assert.true(event.defaultPrevented); + assert.true(event.defaultPrevented, 'prevents window scrolling'); }); test('up scrolls the active option into view', async function (assert) { @@ -187,19 +191,23 @@ module('select-box (up arrow key)', function (hooks) { await render(); @@ -210,30 +218,11 @@ module('select-box (up arrow key)', function (hooks) { assert.strictEqual(startTop, 16); assert.strictEqual(expectedTop, 0); - assert.strictEqual(find('.select-box__options').scrollTop, startTop); + assert.strictEqual(find('.dropdown__content').scrollTop, startTop); await triggerKeyEvent('.select-box__trigger', 'keydown', 'ArrowUp'); - assert.strictEqual(find('.select-box__options').scrollTop, expectedTop); - }); - - test('up on options will not open listbox', async function (assert) { - assert.expect(1); - - await render(); - - await focus('.select-box__options'); - await triggerKeyEvent('.select-box__options', 'keydown', 'ArrowUp'); - - assert.dom('.select-box').doesNotHaveAttribute('data-open'); + assert.strictEqual(find('.dropdown__content').scrollTop, expectedTop); }); test('up on trigger will open combobox', async function (assert) { @@ -241,12 +230,16 @@ module('select-box (up arrow key)', function (hooks) { await render(); @@ -254,7 +247,7 @@ module('select-box (up arrow key)', function (hooks) { await triggerKeyEvent('.select-box__trigger', 'keydown', 'ArrowUp'); assert - .dom('.select-box') + .dom('.select-box__dropdown') .hasAttribute('data-open', 'true', 'opens the select box'); assert @@ -271,12 +264,16 @@ module('select-box (up arrow key)', function (hooks) { await render(); @@ -291,25 +288,30 @@ module('select-box (up arrow key)', function (hooks) { test('up on input will not open combobox (behaviour undefined)', async function (assert) { assert.expect(2); + // We can assume a Trigger is for opening/closing. + // But if there's a dropdown and no trigger, then its + // probably a custom select box, and we make no assumptions + // The developer should probably just render both an input + // and a trigger. + await render(); await focus('.select-box__input'); await triggerKeyEvent('.select-box__input', 'keydown', 'ArrowUp'); - assert.dom('.select-box').doesNotHaveAttribute('data-open', 'false'); - - assert - .dom('.select-box__input') - .doesNotHaveAttribute('aria-expanded', 'false'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); }); test('down on options of a listbox after making a selection', async function (assert) { @@ -380,14 +382,20 @@ module('select-box (up arrow key)', function (hooks) { test('does not scroll the active option into view when closed', async function (assert) { assert.expect(1); + // check this + await render(); diff --git a/tests/integration/components/select-box/key-enter-test.gjs b/tests/integration/components/select-box/index/key-enter-test.gjs similarity index 73% rename from tests/integration/components/select-box/key-enter-test.gjs rename to tests/integration/components/select-box/index/key-enter-test.gjs index 99d4e2f3..56e1b3da 100644 --- a/tests/integration/components/select-box/key-enter-test.gjs +++ b/tests/integration/components/select-box/index/key-enter-test.gjs @@ -27,12 +27,16 @@ module('select-box (enter)', function (hooks) { await render(); @@ -42,7 +46,7 @@ module('select-box (enter)', function (hooks) { assert.verifySteps([], 'change event is not fired'); assert.true(event.defaultPrevented); - assert.dom('.select-box').hasAttribute('data-open', 'true'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'true'); assert @@ -56,7 +60,7 @@ module('select-box (enter)', function (hooks) { assert.true(event.defaultPrevented); assert - .dom('.select-box') + .dom('.select-box__dropdown') .hasAttribute( 'data-open', 'true', @@ -71,21 +75,25 @@ module('select-box (enter)', function (hooks) { await render(); await focus('.select-box__trigger'); await triggerKeyEvent('.select-box__trigger', 'keydown', 'Enter'); - assert.dom('.select-box').hasAttribute('data-open', 'true'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); await triggerKeyEvent('.select-box__trigger', 'keydown', 'Enter'); - assert.dom('.select-box').hasAttribute('data-open', 'true'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); }); test('enter in input of combobox', async function (assert) { @@ -93,12 +101,16 @@ module('select-box (enter)', function (hooks) { await render(); @@ -114,8 +126,8 @@ module('select-box (enter)', function (hooks) { assert.verifySteps([]); assert.true(event.defaultPrevented); - assert.dom('.select-box').doesNotHaveAttribute('data-open'); - assert.dom('.select-box__input').doesNotHaveAttribute('aria-expanded'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); assert.dom('.select-box__option[aria-current="true"]').doesNotExist(); assert.dom('.select-box__option[aria-selected="true"]').doesNotExist(); @@ -123,8 +135,8 @@ module('select-box (enter)', function (hooks) { assert.verifySteps([]); assert.true(event.defaultPrevented); - assert.dom('.select-box').doesNotHaveAttribute('data-open'); - assert.dom('.select-box__input').doesNotHaveAttribute('aria-expanded'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); assert.dom('.select-box__option[aria-current="true"]').doesNotExist(); assert.dom('.select-box__option[aria-selected="true"]').doesNotExist(); }); @@ -134,13 +146,17 @@ module('select-box (enter)', function (hooks) { await render(); @@ -152,7 +168,7 @@ module('select-box (enter)', function (hooks) { assert.verifySteps([]); assert.true(event.defaultPrevented); - assert.dom('.select-box').hasAttribute('data-open', 'true'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); assert.dom('.select-box__input').hasAttribute('aria-expanded', 'true'); assert @@ -164,7 +180,7 @@ module('select-box (enter)', function (hooks) { assert.verifySteps([]); assert.true(event.defaultPrevented); - assert.dom('.select-box').hasAttribute('data-open', 'false'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); assert @@ -178,12 +194,16 @@ module('select-box (enter)', function (hooks) { await render(); @@ -199,8 +219,8 @@ module('select-box (enter)', function (hooks) { because instead, it will select the active option` ); - assert.dom('.select-box').doesNotHaveAttribute('data-open'); - assert.dom('.select-box__input').doesNotHaveAttribute('aria-expanded'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); }); test('enter in listbox', async function (assert) { @@ -277,10 +297,14 @@ module('select-box (enter)', function (hooks) { await render(); diff --git a/tests/integration/components/select-box/key-escape-test.gjs b/tests/integration/components/select-box/index/key-escape-test.gjs similarity index 58% rename from tests/integration/components/select-box/key-escape-test.gjs rename to tests/integration/components/select-box/index/key-escape-test.gjs index 47f97e6c..6d160617 100644 --- a/tests/integration/components/select-box/key-escape-test.gjs +++ b/tests/integration/components/select-box/index/key-escape-test.gjs @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, click, triggerKeyEvent } from '@ember/test-helpers'; +import { render, click, focus, triggerKeyEvent } from '@ember/test-helpers'; import { on } from '@ember/modifier'; import SelectBox from '@zestia/ember-select-box/components/select-box'; @@ -17,11 +17,15 @@ module('select-box (escape)', function (hooks) { assert.expect(12); await render(); @@ -34,12 +38,12 @@ module('select-box (escape)', function (hooks) { .hasAttribute('aria-current', 'false') .hasAttribute('aria-selected', 'false'); - assert.dom('.select-box').hasAttribute('data-open', 'true'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'true'); await triggerKeyEvent('.select-box__trigger', 'keydown', 'Escape'); - assert.dom('.select-box').hasAttribute('data-open', 'false'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); assert.dom('.select-box__trigger').isFocused(); @@ -55,11 +59,15 @@ module('select-box (escape)', function (hooks) { assert.expect(12); await render(); @@ -72,12 +80,12 @@ module('select-box (escape)', function (hooks) { .hasAttribute('aria-current', 'false') .hasAttribute('aria-selected', 'false'); - assert.dom('.select-box').hasAttribute('data-open', 'true'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); assert.dom('.select-box__input').hasAttribute('aria-expanded', 'true'); await triggerKeyEvent('.select-box__input', 'keydown', 'Escape'); - assert.dom('.select-box').hasAttribute('data-open', 'false'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); assert.dom('.select-box__input').isFocused(); @@ -89,7 +97,34 @@ module('select-box (escape)', function (hooks) { assert.verifySteps(['close']); }); - test('escape inside something else escapable (e.g. a dropdown) - escape parent', async function (assert) { + test('escape on an interactive element', async function (assert) { + assert.expect(4); + + // This ensures our listeners are on the dropdown itself, + // and not just on the trigger. + + await render(); + + await click('.select-box__trigger'); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); + + await focus('.inside'); + await triggerKeyEvent('.select-box__dropdown', 'keydown', 'Escape'); + + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'false'); + + assert.verifySteps(['close']); + }); + + test('escape inside something else escapable (closed)', async function (assert) { assert.expect(1); let event; @@ -99,14 +134,15 @@ module('select-box (escape)', function (hooks) { await render(); - await triggerKeyEvent('.select-box__trigger', 'keydown', 'Escape'); + await triggerKeyEvent('.select-box__dropdown', 'keydown', 'Escape'); assert.true( event instanceof Event, @@ -114,7 +150,7 @@ module('select-box (escape)', function (hooks) { ); }); - test('escape inside something else escapable (e.g. a dropdown) - escape child', async function (assert) { + test('escape inside something else escapable (open)', async function (assert) { assert.expect(3); let event; @@ -124,20 +160,21 @@ module('select-box (escape)', function (hooks) { await render(); await click('.select-box__trigger'); - await triggerKeyEvent('.select-box__trigger', 'keydown', 'Escape'); + await triggerKeyEvent('.select-box__dropdown', 'keydown', 'Escape'); assert.notOk( event, `event propagation is stopped, since escape has caused the select box - to close. we don't want escape to also close the parent element (dropdown) + to close. we don't want escape to also close the parent element that the select box is contained within` ); diff --git a/tests/integration/components/select-box/key-options-test.gjs b/tests/integration/components/select-box/index/key-options-test.gjs similarity index 86% rename from tests/integration/components/select-box/key-options-test.gjs rename to tests/integration/components/select-box/index/key-options-test.gjs index 0247c1b2..d46b08e3 100644 --- a/tests/integration/components/select-box/key-options-test.gjs +++ b/tests/integration/components/select-box/index/key-options-test.gjs @@ -51,12 +51,16 @@ module('select-box (keydown on options)', function (hooks) { await render(); diff --git a/tests/integration/components/select-box/key-space-test.gjs b/tests/integration/components/select-box/index/key-space-test.gjs similarity index 70% rename from tests/integration/components/select-box/key-space-test.gjs rename to tests/integration/components/select-box/index/key-space-test.gjs index 5fae871b..f19ac2d1 100644 --- a/tests/integration/components/select-box/key-space-test.gjs +++ b/tests/integration/components/select-box/index/key-space-test.gjs @@ -26,12 +26,16 @@ module('select-box (space)', function (hooks) { await render(); @@ -41,7 +45,7 @@ module('select-box (space)', function (hooks) { assert.verifySteps([], 'change event is not fired'); assert.true(event.defaultPrevented); - assert.dom('.select-box').hasAttribute('data-open', 'true'); + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'true'); assert @@ -54,14 +58,10 @@ module('select-box (space)', function (hooks) { assert.verifySteps([], 'change event is not fired'); assert.true(event.defaultPrevented); - assert - .dom('.select-box') - .hasAttribute( - 'data-open', - 'true', - 'does not close (no option was active)' - ); + // Does not close (no option was active) + // This is counter to a normal dropdown, which toggles. + assert.dom('.select-box__dropdown').hasAttribute('data-open', 'true'); assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'true'); }); @@ -70,12 +70,16 @@ module('select-box (space)', function (hooks) { await render(); @@ -85,11 +89,13 @@ module('select-box (space)', function (hooks) { assert.verifySteps([], 'change event is not fired'); assert.false(event.defaultPrevented, 'can type spaces still'); - assert.dom('.select-box').doesNotHaveAttribute( - 'data-open', - `does not open, because the space character is typed into the input - also there is no trigger present to imply its openable` - ); + assert + .dom('.select-box__dropdown') + .hasAttribute( + 'data-open', + 'false', + 'does not open, because the space character is typed into the input' + ); assert .dom('.select-box__option:nth-child(1)') @@ -134,17 +140,21 @@ module('select-box (space)', function (hooks) { }); test('space on trigger of combobox whilst typing', async function (assert) { - assert.expect(10); + assert.expect(11); await render(); @@ -158,6 +168,8 @@ module('select-box (space)', function (hooks) { assert.false(event.defaultPrevented); + assert.verifySteps(['A'], 'change event fires'); + await triggerKeyEvent('.select-box__trigger', 'keydown', ' '); assert.true( @@ -169,14 +181,15 @@ module('select-box (space)', function (hooks) { assert.false(event.defaultPrevented); - assert.verifySteps(['A', 'A 2'], 'change event fires'); + assert.verifySteps(['A 2'], 'change event fires'); - assert.dom('.select-box').hasAttribute( + assert.dom('.select-box__dropdown').hasAttribute( 'data-open', 'false', `space usually toggles a select box open/closed. but in this case - we are in the middle of jumping to an option. (this is how native - select boxes behave).` + we are in the middle of jumping to an option and so that character + should form part of the 'search' for the option + (this is how native select boxes behave).` ); assert diff --git a/tests/integration/components/select-box/mouse-option-test.gjs b/tests/integration/components/select-box/index/mouse-option-test.gjs similarity index 84% rename from tests/integration/components/select-box/mouse-option-test.gjs rename to tests/integration/components/select-box/index/mouse-option-test.gjs index 6243a9e1..2ad9c5ec 100644 --- a/tests/integration/components/select-box/mouse-option-test.gjs +++ b/tests/integration/components/select-box/index/mouse-option-test.gjs @@ -99,36 +99,11 @@ module('select-box (mouseenter option)', function (hooks) { .doesNotHaveAttribute('aria-current'); }); - test("mousing into an option activates it even if the select box doesn't have focus", async function (assert) { - assert.expect(4); - - const handleActivateOption = () => assert.step('activate'); - - await render(); - - await triggerEvent('.select-box__option:nth-child(2)', 'mouseenter'); - - assert - .dom('.select-box__option:nth-child(1)') - .hasAttribute('aria-current', 'false'); - - assert - .dom('.select-box__option:nth-child(2)') - .hasAttribute('aria-current', 'true'); - - assert.verifySteps(['activate']); - }); - test('mousing over an option does not scroll to it', async function (assert) { assert.expect(1); + // This would create annoying shift as you move the mouse. + await render(); + + await click('.select-box__trigger'); + + const expectedTop = find('.select-box__option:nth-child(3)').offsetTop; + + assert.strictEqual(expectedTop, 32); + assert.strictEqual(find('.dropdown__content').scrollTop, expectedTop); + }); + + todo('dropdown trigger not available', async function (assert) { + assert.expect(1); + + await render(); + + assert.dom('.dropdown__trigger').doesNotExist(` + For valid aria, the select box implementation of the dropdown + trigger should be used (sb.Trigger), not (dd.Trigger) + Sadly, there's not that easy to prevent accidental use of this. + `); + }); +}); diff --git a/tests/integration/components/select-box/render-test.gjs b/tests/integration/components/select-box/index/render-test.gjs similarity index 58% rename from tests/integration/components/select-box/render-test.gjs rename to tests/integration/components/select-box/index/render-test.gjs index 32c6b695..1b56b63b 100644 --- a/tests/integration/components/select-box/render-test.gjs +++ b/tests/integration/components/select-box/index/render-test.gjs @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render } from '@ember/test-helpers'; +import { render, find } from '@ember/test-helpers'; import SelectBox from '@zestia/ember-select-box/components/select-box'; module('select-box', function (hooks) { @@ -30,27 +30,11 @@ module('select-box', function (hooks) { assert.dom('.select-box').hasClass('foo'); }); - test('expanded (listbox)', async function (assert) { + test('whitespace', async function (assert) { assert.expect(1); - await render(); - - assert.dom('.select-box').doesNotHaveAttribute('data-open'); - }); - - test('expanded (combobox)', async function (assert) { - assert.expect(1); - - await render(); + await render(); - assert.dom('.select-box').hasAttribute('data-open', 'false'); + assert.strictEqual(find('.select-box').innerHTML, ''); }); }); diff --git a/tests/integration/components/select-box/searching-test.gjs b/tests/integration/components/select-box/index/searching-test.gjs similarity index 92% rename from tests/integration/components/select-box/searching-test.gjs rename to tests/integration/components/select-box/index/searching-test.gjs index 21bef217..32b85232 100644 --- a/tests/integration/components/select-box/searching-test.gjs +++ b/tests/integration/components/select-box/index/searching-test.gjs @@ -19,8 +19,10 @@ module('select-box (searching)', function (hooks) { await render(); @@ -104,7 +106,6 @@ module('select-box (searching)', function (hooks) { const handleSearch = async (query, sb) => { await deferred.promise; - sb.open(); }; await render(); diff --git a/tests/integration/components/select-box/single-test.gjs b/tests/integration/components/select-box/index/single-test.gjs similarity index 84% rename from tests/integration/components/select-box/single-test.gjs rename to tests/integration/components/select-box/index/single-test.gjs index ec780e8a..0f9e7860 100644 --- a/tests/integration/components/select-box/single-test.gjs +++ b/tests/integration/components/select-box/index/single-test.gjs @@ -97,8 +97,8 @@ module('select-box (single)', function (hooks) { find('.select-box__options').scrollTop, 0, `does not trigger scroll into view on the active option - on initial render, we don't want to accidently cause - pages to jump around` + on initial render, we don't want to accidentally cause + the page to jump around` ); assert @@ -113,7 +113,7 @@ module('select-box (single)', function (hooks) { assert.strictEqual( find('.select-box__options').scrollTop, 0, - `changing the value programatically does not scroll the active + `changing the value programmatically does not scroll the active option into view. scrolling into view should only happen as a result of the user interacting with the select box` ); @@ -141,14 +141,18 @@ module('select-box (single)', function (hooks) { await render(); @@ -177,8 +181,8 @@ module('select-box (single)', function (hooks) { .hasAttribute('aria-selected', 'true'); }); - test('onActivate doesnt fire initially', async function (assert) { - assert.expect(1); + test("onActivate doesn't fire initially", async function (assert) { + assert.expect(2); const handleActivate = () => assert.step('activate'); @@ -190,6 +194,8 @@ module('select-box (single)', function (hooks) { ); + assert.dom('.select-box__option').hasAttribute('aria-current', 'true'); + assert.verifySteps([]); }); @@ -243,15 +249,17 @@ module('select-box (single)', function (hooks) { await render(); @@ -289,15 +297,17 @@ module('select-box (single)', function (hooks) { @onBuildSelection={{buildSelection}} as |sb| > - - {{state.value}}, - {{sb.value}} - - - One - Two - Three - + + + {{state.value}}, + {{sb.value}} + + + One + Two + Three + + ); diff --git a/tests/integration/components/select-box/value-test.gjs b/tests/integration/components/select-box/index/value-test.gjs similarity index 97% rename from tests/integration/components/select-box/value-test.gjs rename to tests/integration/components/select-box/index/value-test.gjs index df178028..cd355d89 100644 --- a/tests/integration/components/select-box/value-test.gjs +++ b/tests/integration/components/select-box/index/value-test.gjs @@ -44,10 +44,14 @@ module('select-box (value)', function (hooks) { await render(); diff --git a/tests/integration/components/input/render-test.gjs b/tests/integration/components/select-box/input/render-test.gjs similarity index 76% rename from tests/integration/components/input/render-test.gjs rename to tests/integration/components/select-box/input/render-test.gjs index c409a642..c2ecc5c3 100644 --- a/tests/integration/components/input/render-test.gjs +++ b/tests/integration/components/select-box/input/render-test.gjs @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { render, setupOnerror, resetOnerror } from '@ember/test-helpers'; +import { render } from '@ember/test-helpers'; import SelectBox from '@zestia/ember-select-box/components/select-box'; module('select-box/input', function (hooks) { @@ -47,8 +47,10 @@ module('select-box/input', function (hooks) { await render(); @@ -75,24 +77,4 @@ module('select-box/input', function (hooks) { .doesNotHaveAttribute('type', 'text') .doesNotHaveAttribute('type', 'search'); }); - - test('multiple inputs', async function (assert) { - assert.expect(1); - - setupOnerror((error) => { - assert.strictEqual( - error.message, - 'Assertion Failed: can only have 1 input' - ); - }); - - await render(); - - resetOnerror(); - }); }); diff --git a/tests/integration/components/select-box/opening-test.gjs b/tests/integration/components/select-box/opening-test.gjs deleted file mode 100644 index 8d25e268..00000000 --- a/tests/integration/components/select-box/opening-test.gjs +++ /dev/null @@ -1,429 +0,0 @@ -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'dummy/tests/helpers'; -import { - render, - rerender, - click, - focus, - find, - triggerEvent, - triggerKeyEvent -} from '@ember/test-helpers'; -import { on } from '@ember/modifier'; -import { array } from '@ember/helper'; -import { tracked } from '@glimmer/tracking'; -import SelectBox from '@zestia/ember-select-box/components/select-box'; - -module('select-box (opening)', function (hooks) { - setupRenderingTest(hooks); - - test('opening with api', async function (assert) { - assert.expect(7); - - let api; - - const handleReady = (sb) => (api = sb); - const handleOpen = () => assert.step('open'); - - await render(); - - assert.false(api.isOpen); - - await click('button'); - await click('button'); - - assert.true(api.isOpen); - - assert.verifySteps(['open']); - - assert.dom('.select-box').hasAttribute('data-open', 'true'); - assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'true'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'true'); - }); - - test('can set initial open state', async function (assert) { - assert.expect(6); - - await render(); - - assert.dom('.select-box').hasAttribute('data-open', 'true'); - assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'true'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'true'); - assert.dom('.select-box__trigger').isNotFocused(); - assert.dom('.select-box__input').isNotFocused(); - assert.dom('.select-box__option').hasAttribute('aria-current', 'true'); - }); - - test('initial open state of combobox without a trigger (custom behaviour!)', async function (assert) { - assert.expect(5); - - // The addon can't know if the combobox is one where the options are already visible, - // or one where they will be shown by interacting with the input element. If the developer - // had used a Trigger element, we make the assumption that it will control the expanded state. - - await render(); - - assert - .dom('.select-box') - .doesNotHaveAttribute( - 'data-open', - 'technically invalid, ought to have expanded attr' - ); - - // ...therefore, because the developer has opted out of having a Trigger button, - // they must manually set its initial expanded state and configure a way to - // control that expanded state, in order to be valid aria. Example: - - await render(); - - assert.dom('.select-box').hasAttribute('data-open', 'false'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); - - await focus('.select-box__input'); - - assert.dom('.select-box').hasAttribute('data-open', 'true'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'true'); - }); - - test('cannot manually open listbox', async function (assert) { - assert.expect(2); - - const handleOpen = () => assert.step('open'); - - await render(); - - assert.dom('.select-box').doesNotHaveAttribute('data-open'); - assert.verifySteps([]); - }); - - test('cannot open combobox manually with argument', async function (assert) { - assert.expect(3); - - const state = new (class { - @tracked isOpen; - })(); - - await render(); - - state.isOpen = true; - - await rerender(); - - assert.dom('.select-box').hasAttribute('data-open', 'false'); - assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'false'); - }); - - test('activates first option (undefined === undefined)', async function (assert) { - assert.expect(3); - - await render(); - - await click('.select-box__trigger'); - - assert - .dom('.select-box__option:nth-child(1)') - .hasAttribute('aria-current', 'true'); - - assert - .dom('.select-box__option:nth-child(2)') - .hasAttribute('aria-current', 'false'); - - assert - .dom('.select-box__option:nth-child(3)') - .hasAttribute('aria-current', 'false'); - }); - - test('activates option for value (single)', async function (assert) { - assert.expect(1); - - await render(); - - await click('.select-box__trigger'); - - assert - .dom('.select-box__option:nth-child(2)') - .hasAttribute('aria-current', 'true'); - }); - - test('activating option (multiple)', async function (assert) { - assert.expect(1); - - await render(); - - await click('.select-box__trigger'); - - assert - .dom('.select-box__option[aria-current="true"]') - .doesNotExist( - 'does not attempt to activate any of the options for the given value' - ); - }); - - test('activates option for value after they are rendered', async function (assert) { - assert.expect(1); - - await render(); - - await click('.select-box__trigger'); - - assert - .dom('.select-box__option:nth-child(2)') - .hasAttribute('aria-current', 'true'); - }); - - test('opening via the trigger does not lose focus', async function (assert) { - assert.expect(1); - - await render(); - - await click('.select-box__trigger'); - - assert.dom('.select-box__trigger').isFocused(); - }); - - test('opening via the trigger advances focus to the input (mouse)', async function (assert) { - assert.expect(1); - - await render(); - - await click('.select-box__trigger'); - - assert.dom('.select-box__input').isFocused(); - }); - - test('opening via the trigger advances focus to the input (keyboard)', async function (assert) { - assert.expect(1); - - await render(); - - await focus('.select-box__trigger'); - await triggerKeyEvent('.select-box__trigger', 'keydown', 'Enter'); - - assert.dom('.select-box__input').isFocused(); - }); - - test('opening via the api advances focus to the input', async function (assert) { - assert.expect(1); - - await render(); - - await triggerEvent('.select-box__trigger', 'mouseenter'); - - assert.dom('.select-box__input').isFocused(); - }); - - test('opening listbox', async function (assert) { - assert.expect(1); - - const handleOpen = () => assert.step('open'); - - await render(); - - await click('button'); - - assert.verifySteps([], 'listboxes cannot be opened'); - }); - - test('opening combo box with arg (trigger)', async function (assert) { - assert.expect(1); - - await render(); - - assert.dom('.select-box__trigger').isNotFocused('does not steal focus'); - }); - - test('opening combo box with arg (input)', async function (assert) { - assert.expect(1); - - await render(); - - assert.dom('.select-box__input').isNotFocused('does not steal focus'); - }); - - test('opening combobox with input only', async function (assert) { - assert.expect(4); - - await render(); - - assert.dom('.select-box[aria-current="true"]').doesNotExist(); - - await click('.select-box__input'); - - assert - .dom('.select-box__option:nth-child(1)') - .hasAttribute('aria-current', 'true'); - - assert.dom('.select-box').hasAttribute('data-open', 'true'); - assert.dom('.select-box__input').hasAttribute('aria-expanded', 'true'); - }); - - test('opening with a selected value scrolls it into view', async function (assert) { - assert.expect(2); - - await render(); - - await click('.select-box__trigger'); - - const expectedTop = find('.select-box__option:nth-child(3)').offsetTop; - - assert.strictEqual(expectedTop, 32); - assert.strictEqual(find('.select-box__options').scrollTop, expectedTop); - }); - - test('opening forgets previous active option', async function (assert) { - assert.expect(2); - - await render(); - - await click('.select-box__trigger'); - - await triggerEvent('.select-box__option:nth-child(2)', 'mouseenter'); - - assert - .dom('.select-box__option:nth-child(2)') - .hasAttribute('aria-current', 'true'); - - await click('.select-box__trigger'); - - assert.dom('.select-box__option[aria-current="true"]').doesNotExist(); - }); -}); diff --git a/tests/integration/components/option/api-test.gjs b/tests/integration/components/select-box/option/api-test.gjs similarity index 100% rename from tests/integration/components/option/api-test.gjs rename to tests/integration/components/select-box/option/api-test.gjs diff --git a/tests/integration/components/option/render-test.gjs b/tests/integration/components/select-box/option/render-test.gjs similarity index 100% rename from tests/integration/components/option/render-test.gjs rename to tests/integration/components/select-box/option/render-test.gjs diff --git a/tests/integration/components/options/render-test.gjs b/tests/integration/components/select-box/options/render-test.gjs similarity index 75% rename from tests/integration/components/options/render-test.gjs rename to tests/integration/components/select-box/options/render-test.gjs index 366f7a2a..7549d669 100644 --- a/tests/integration/components/options/render-test.gjs +++ b/tests/integration/components/select-box/options/render-test.gjs @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'dummy/tests/helpers'; -import { find, render, setupOnerror, resetOnerror } from '@ember/test-helpers'; +import { find, render } from '@ember/test-helpers'; import SelectBox from '@zestia/ember-select-box/components/select-box'; module('select-box/options', function (hooks) { @@ -75,9 +75,7 @@ module('select-box/options', function (hooks) { '-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` + aria controlled virtually by the input.` ); }); @@ -86,8 +84,12 @@ module('select-box/options', function (hooks) { await render(); @@ -95,8 +97,8 @@ module('select-box/options', function (hooks) { 'tabindex', '-1', `the main interactive element is the trigger (combobox) - focus should not move to the listbox, which is - aria controlled by the trigger` + focus should not move to the listbox (options element), which is + aria controlled virtually by the trigger` ); }); @@ -155,39 +157,4 @@ module('select-box/options', function (hooks) { .dom('.select-box__options') .hasAttribute('aria-multiselectable', 'true'); }); - - test('multiple listboxes', async function (assert) { - assert.expect(1); - - setupOnerror((error) => { - assert.strictEqual( - error.message, - 'Assertion Failed: can only have 1 listbox' - ); - }); - - await render(); - - resetOnerror(); - }); - - test('no listbox', async function (assert) { - assert.expect(1); - - setupOnerror((error) => { - assert.strictEqual( - error.message, - 'Assertion Failed: must have an interactive element' - ); - }); - - await render(); - - resetOnerror(); - }); }); diff --git a/tests/integration/components/trigger/render-test.gjs b/tests/integration/components/trigger/render-test.gjs deleted file mode 100644 index 3d656903..00000000 --- a/tests/integration/components/trigger/render-test.gjs +++ /dev/null @@ -1,171 +0,0 @@ -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'dummy/tests/helpers'; -import { - render, - find, - setupOnerror, - resetOnerror, - triggerEvent -} from '@ember/test-helpers'; -import SelectBox from '@zestia/ember-select-box/components/select-box'; - -module('select-box/trigger', function (hooks) { - setupRenderingTest(hooks); - - test('it renders', async function (assert) { - assert.expect(1); - - await render(); - - assert.dom('.select-box__trigger').hasTagName('div'); - }); - - test('splattributes', async function (assert) { - assert.expect(1); - - await render(); - - assert.dom('.select-box__trigger').hasClass('foo'); - }); - - test('aria defaults', async function (assert) { - assert.expect(6); - - await render(); - - assert.dom('.select-box__trigger').doesNotHaveAttribute('aria-busy'); - assert.dom('.select-box__trigger').doesNotHaveAttribute('aria-controls'); - assert.dom('.select-box__trigger').doesNotHaveAttribute('aria-disabled'); - assert.dom('.select-box__trigger').hasAttribute('aria-expanded', 'false'); - assert - .dom('.select-box__trigger') - .doesNotHaveAttribute('aria-activedescendant'); - - assert - .dom('.select-box__trigger') - .doesNotHaveAttribute( - 'aria-haspopup', - 'listbox', - 'spec says this is implicit due to role of combobox' - ); - }); - - test('aria controls', async function (assert) { - assert.expect(2); - - await render(); - - assert.ok( - find('.select-box__trigger') - .getAttribute('aria-controls') - .match(/[\w\d]+/) - ); - - assert - .dom('.select-box__options') - .hasAttribute( - 'id', - find('.select-box__trigger').getAttribute('aria-controls') - ); - }); - - test('role', async function (assert) { - assert.expect(1); - - await render(); - - assert.dom('.select-box__trigger').hasAttribute('role', 'combobox'); - }); - - test('role (with input)', async function (assert) { - assert.expect(1); - - await render(); - - assert - .dom('.select-box__trigger') - .hasAttribute( - 'role', - 'button', - 'the trigger is not the primary interactive element' - ); - }); - - test('whitespace', async function (assert) { - assert.expect(1); - - await render(); - - assert.strictEqual(find('.select-box__trigger').innerHTML, ''); - }); - - test('multiple triggers', async function (assert) { - assert.expect(1); - - setupOnerror((error) => { - assert.strictEqual( - error.message, - 'Assertion Failed: can only have 1 trigger' - ); - }); - - await render(); - - resetOnerror(); - }); - - test('active descendant (with input)', async function (assert) { - assert.expect(1); - - await render(); - - await triggerEvent('.select-box__option', 'mouseenter'); - - assert - .dom('.select-box__trigger') - .doesNotHaveAttribute('aria-activedescendant'); - }); -});