diff --git a/.changeset/two-pets-travel.md b/.changeset/two-pets-travel.md new file mode 100644 index 00000000..7393ae2b --- /dev/null +++ b/.changeset/two-pets-travel.md @@ -0,0 +1,6 @@ +--- +'@crowdstrike/ember-toucan-core': patch +'@crowdstrike/ember-toucan-form': patch +--- + +Adds "Select all" functionality to the `Multiselect` via a new component argument. Provide `@selectAllText` to opt-in to the functionality. diff --git a/docs/components/multiselect-field/index.md b/docs/components/multiselect-field/index.md index 12e79053..1d44cdc3 100644 --- a/docs/components/multiselect-field/index.md +++ b/docs/components/multiselect-field/index.md @@ -53,7 +53,7 @@ Provide a string to the `@label` component argument or content to the `:label` n Required. -A `:chip` block is required and is used for rendering each selected option. +A `:chip` block is required and is used for rendering each selected option. The block has the following block parameters: - `index`: The index of the current chip @@ -72,8 +72,8 @@ The `Chip` component allows for slight customization to the underlying chip. ``` -The `Remove` component contains the removal `X` on each selected chip. -Clicking the button will remove the item from the selected options array. +The `Remove` component contains the removal `X` on each selected chip. +Clicking the button will remove the item from the selected options array. When the multiselect is disabled or in the readonly state, the button will not be available. A `@label` argument is **required** for accessibility reasons for the Remove component. @@ -134,7 +134,7 @@ An example with translations may be something like: Required. -`@noResultsText` is shown when there are no results after filtering. +`@noResultsText` is shown when there are no results after filtering. ```hbs + <:chip as |chip|> + + {{chip.option}} + + + + + <:default as |multiselect|> + + {{multiselect.option}} + + + +``` + ## onFilter Optional. diff --git a/docs/components/multiselect/demo/base-demo.md b/docs/components/multiselect/demo/base-demo.md index d804a3ff..4807ed9d 100644 --- a/docs/components/multiselect/demo/base-demo.md +++ b/docs/components/multiselect/demo/base-demo.md @@ -2,7 +2,7 @@
+ + + <:chip as |chip|> + + {{chip.option}} + + + + + <:default as |multiselect|> + + {{multiselect.option}} + + +
``` @@ -78,6 +101,7 @@ export default class extends Component { @tracked selected; @tracked selected2; @tracked selected3; + @tracked selected4; options = [ { @@ -148,6 +172,12 @@ export default class extends Component { console.log(option); } + @action + onChange4(option) { + this.selected4 = option; + console.log(option); + } + @action onFilter(value) { console.log(`filtering with the value "${value}"`); diff --git a/docs/components/multiselect/index.md b/docs/components/multiselect/index.md index a6df2414..7c9dc23e 100644 --- a/docs/components/multiselect/index.md +++ b/docs/components/multiselect/index.md @@ -7,7 +7,7 @@ If you are building forms, you may be interested in the [MultiselectField](./mul Required. -A `:chip` block is required and is used for rendering each selected option. +A `:chip` block is required and is used for rendering each selected option. The block returns the following: - `index`: The index of the current chip @@ -26,8 +26,8 @@ The `Chip` component allows for slight customization to the underlying chip. ``` -The `Remove` component contains the removal `X` on each selected chip. -Clicking the button will remove the item from the selected options array. +The `Remove` component contains the removal `X` on each selected chip. +Clicking the button will remove the item from the selected options array. When the multiselect is disabled or in the readonly state, the button will not be available. A `@label` argument is **required** for accessibility reasons for the Remove component. @@ -36,7 +36,7 @@ The `option` for that chip is yielded back to the consumer so that an appropriat ```hbs + <:chip as |chip|> + + {{chip.option}} + + + + + <:default as |multiselect|> + + {{multiselect.option}} + + + +``` + ## onFilter Optional. @@ -214,7 +251,7 @@ Specify `onFilter` if you want to do something different. ```hbs +
+ {{! + We set `tabindex="-1"` here to remove the checkbox from the tab order. + Instead, we allow the user to use the keyboard arrows to "focus" and + make options active. If we remove this line, you'll notice that the + focus returns to the body when tabbing out of the multiautocomplete + component, which is a strange user experience. + + Template lint doesn't like us setting tabindex, but we need it here + as we don't want these elements focusable! + }} + {{! template-lint-disable no-nested-interactive }} + + + + {{yield}} + +
+ +{{/let}} \ No newline at end of file diff --git a/packages/ember-toucan-core/src/-private/components/form/controls/multiselect/select-all.ts b/packages/ember-toucan-core/src/-private/components/form/controls/multiselect/select-all.ts new file mode 100644 index 00000000..c2514a34 --- /dev/null +++ b/packages/ember-toucan-core/src/-private/components/form/controls/multiselect/select-all.ts @@ -0,0 +1,96 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +import { className } from './option'; + +interface ToucanFormMultiselectSelectAllControlComponentSignature { + Args: { + /** + * When true, means that the option is currently hovered over with a mouse + * or "focused" with a keyboard. + */ + isActive?: boolean; + + /** + * Sets the underlying checkbox element to disabled. + */ + isDisabled?: boolean; + + /** + * Sets the undelrying checkbox to the indeterminate state. + */ + isIndeterminate?: boolean; + + /** + * Sets the underlying checkbox element to readonly. + */ + isReadOnly?: boolean; + + /** + * When set to true, the list item will have `aria-selected` set to true + * and have selected styling. + */ + isSelected?: boolean; + + /** + * The index number of the list item when in a list. + */ + index: number; + + /** + * The event called when the item is clicked. + */ + onClick: () => void; + + /** + * The event called when the mouse rolls over the item. + */ + onMouseover: () => void; + + /** + * The `id` attribute of the popover this option is associated with. + */ + popoverId: string; + + /** + * Sets the underlying checkbox element `value` attribute. + */ + value?: string; + }; + Blocks: { + default: []; + }; + Element: HTMLLIElement; +} + +export default class ToucanFormMultiselectSelectAllControlComponent extends Component { + // NOTE: This shares the className with the option component + // so that both listbox items have a common selector. + className = className; + + get styles() { + if (this.args.isActive) { + return 'bg-overlay-1 text-titles-and-attributes'; + } + + if (this.args.isSelected) { + return 'text-titles-and-attributes'; + } + + return 'text-body-and-labels'; + } + + @action + onClick(event: Event) { + // Both "click" and "mousedown" steal focus, which we want to remain on the input. + event.preventDefault(); + + this.args.onClick(); + } + + @action + onMousedown(event: Event) { + // Both "click" and "mousedown" steal focus, which we want to remain on the input. + event.preventDefault(); + } +} diff --git a/packages/ember-toucan-core/src/components/form/controls/multiselect.hbs b/packages/ember-toucan-core/src/components/form/controls/multiselect.hbs index bcced5cd..a0155358 100644 --- a/packages/ember-toucan-core/src/components/form/controls/multiselect.hbs +++ b/packages/ember-toucan-core/src/components/form/controls/multiselect.hbs @@ -1,9 +1,5 @@ {{#if - (this.assertRequiredBlocksExist - (hash - chipBlockExists=(has-block "chip") - ) - ) + (this.assertRequiredBlocksExist (hash chipBlockExists=(has-block "chip"))) }} {{#if this.options}} + {{#if this.isSelectAllVisible}} + + {{@selectAllText}} + + {{/if}} + {{#each this.options as |option index|}} - {{yield - (hash - Option=(component - (ensure-safe-component this.Option) - isActive=(this.isEqual index this.activeIndex) - isDisabled=@isDisabled - isSelected=(this.isSelected option) - isReadOnly=@isReadOnly - onClick=(fn this.onChange index) - onMouseover=(fn this.onOptionMouseover index) - popoverId=this.popoverId - index=index + {{#let (this.generateIndex index) as |generatedIndex|}} + {{yield + (hash + Option=(component + (ensure-safe-component this.Option) + isActive=(this.isEqual generatedIndex this.activeIndex) + isDisabled=@isDisabled + isSelected=(this.isSelected option) + isReadOnly=@isReadOnly + onClick=this.onChange + onMouseover=(fn this.onOptionMouseover generatedIndex) + popoverId=this.popoverId + index=(if this.isSelectAllEnabled generatedIndex index) + ) + option=option ) - option=option - ) - }} + }} + {{/let}} {{/each}} {{else}}
  • { + return this.isSelectAllVisible ? index + 1 : index; + }; + velcroMiddleware: VelcroMiddleware[] = [ offset({ mainAxis: 8, @@ -176,6 +198,56 @@ export default class ToucanFormMultiselectControlComponent extends Component 0 && + // We need to use this comparison method when comparing arrays + // as Ember's `isEqual` is only for objects. + // This is relatively safe as `@selected` can only contain unique values + // due to the business logic of re-selecting an already selected item removes + // it from `@selected`. + JSON.stringify(this.selected?.sort()) !== + JSON.stringify(this.options?.sort()) + ); + } + /** * This state is used to determine if we should add event handlers to the input element or not. */ @@ -313,8 +385,63 @@ export default class ToucanFormMultiselectControlComponent extends Component diff --git a/packages/ember-toucan-core/src/components/form/fields/multiselect.ts b/packages/ember-toucan-core/src/components/form/fields/multiselect.ts index 2b650619..0bcf5cf5 100644 --- a/packages/ember-toucan-core/src/components/form/fields/multiselect.ts +++ b/packages/ember-toucan-core/src/components/form/fields/multiselect.ts @@ -70,6 +70,19 @@ export interface ToucanFormMultiselectFieldComponentSignature { */ rootTestSelector?: string; + /** + * A string to render as the "Select all" option label. By providing this argument, + * you are opting into the "Select all" functionality and the checkbox will be rendered + * at the top of the popover. + * + * - The checkbox only appears when filtering is not active. + * - The checkbox will be checked when all options are selected. + * - If no options are selected, the checkbox will be unchecked. + * - If more than one option is selected, but not all of them, then the checkbox will be in the indeterminate state. + * - When the checkbox is in the indeterminate state, clicking the checkbox re-selects all options. + */ + selectAllText?: string; + /** * The currently selected option. */ diff --git a/packages/ember-toucan-form/src/-private/multiselect-field.hbs b/packages/ember-toucan-form/src/-private/multiselect-field.hbs index 099d73c4..6b16c391 100644 --- a/packages/ember-toucan-form/src/-private/multiselect-field.hbs +++ b/packages/ember-toucan-form/src/-private/multiselect-field.hbs @@ -31,6 +31,7 @@ @onFilter={{@onFilter}} @options={{@options}} @rootTestSelector={{@rootTestSelector}} + @selectAllText={{@selectAllText}} @selected={{this.assertSelected field.value}} name={{@name}} ...attributes @@ -73,6 +74,7 @@ @onFilter={{@onFilter}} @options={{@options}} @rootTestSelector={{@rootTestSelector}} + @selectAllText={{@selectAllText}} @selected={{this.assertSelected field.value}} name={{@name}} ...attributes @@ -115,6 +117,7 @@ @onFilter={{@onFilter}} @options={{@options}} @rootTestSelector={{@rootTestSelector}} + @selectAllText={{@selectAllText}} @selected={{this.assertSelected field.value}} name={{@name}} ...attributes @@ -157,6 +160,7 @@ @onFilter={{@onFilter}} @options={{@options}} @rootTestSelector={{@rootTestSelector}} + @selectAllText={{@selectAllText}} @selected={{this.assertSelected field.value}} name={{@name}} ...attributes diff --git a/test-app/tests/integration/components/multiselect-field-test.gts b/test-app/tests/integration/components/multiselect-field-test.gts index 3ab95247..14725ceb 100644 --- a/test-app/tests/integration/components/multiselect-field-test.gts +++ b/test-app/tests/integration/components/multiselect-field-test.gts @@ -376,6 +376,37 @@ module('Integration | Component | Fields | Multiselect', function (hooks) { assert.verifySteps(['handleChange']); }); + // NOTE: This functionality is tested more in-depth via the control component + // integration test. We simply want to ensure the component argument gets + // passed through in this test. + test('it renders the `Select all` option', async function (assert) { + await render(); + + await click('[data-multiselect]'); + + assert.dom('[data-multiselect-select-all-option]').exists(); + assert.dom('[data-multiselect-select-all-option]').hasText('Select all'); + }); + test('it throws an assertion error if no `@label` is provided', async function (assert) { assert.expect(1); diff --git a/test-app/tests/integration/components/multiselect-test.gts b/test-app/tests/integration/components/multiselect-test.gts index 66969437..9cb15ff9 100644 --- a/test-app/tests/integration/components/multiselect-test.gts +++ b/test-app/tests/integration/components/multiselect-test.gts @@ -1,3 +1,4 @@ +import { tracked } from '@glimmer/tracking'; import { click, fillIn, @@ -1501,6 +1502,134 @@ module('Integration | Component | Multiselect', function (hooks) { assert.dom('[role="option"]').exists({ count: 2 }); }); + test('it renders the `Select all` option when provided with `@selectAllText`', async function (assert) { + await render(); + + await click('[data-multiselect]'); + + assert.dom('[data-multiselect-select-all-option]').exists(); + assert.dom('[data-multiselect-select-all-option]').hasText('Select all'); + + assert.dom('[data-multiselect-select-all-checkbox]').exists(); + }); + + test('it handles all possible state changes when the `Select all` option is interacted with', async function (assert) { + assert.expect(22); + + class State { + @tracked selected: string[] = []; + } + + let state = new State(); + + let handleChange = (values: string[]) => { + state.selected = values; + assert.step(values?.length > 0 ? values.join(',') : 'empty'); + }; + + await render(); + + // Open the popover + await click('[data-multiselect]'); + + // Verify default state of `Select all` is not checked + assert.dom('[data-multiselect-select-all-checkbox]').hasProperty('indeterminate', false); + assert.dom('[data-multiselect-select-all-checkbox]').isNotChecked(); + + // Verify the list item has the proper default aria-selected attribute of false + assert.dom('[data-multiselect-select-all-option]').hasAttribute('aria-selected', 'false'); + + // Unchecked -> checked should make all options selected + await click('[data-multiselect-select-all-option]'); + + // Verify `Select all` checkbox state + assert.dom('[data-multiselect-select-all-checkbox]').hasProperty('indeterminate', false); + assert.dom('[data-multiselect-select-all-checkbox]').isChecked(); + + // Verify the list item has the proper aria-selected attribute of true, since it's selected + assert.dom('[data-multiselect-select-all-option]').hasAttribute('aria-selected', 'true'); + + assert.verifySteps(['blue,red']); + + // Remove the "red" option by selecting it + await triggerKeyEvent('[data-multiselect]', 'keydown', 'ArrowDown'); + await triggerKeyEvent('[data-multiselect]', 'keydown', 'ArrowDown'); + await triggerKeyEvent('[data-multiselect]', 'keydown', 'Enter'); + + // Our selected item should now only be "blue" since we removed "red" above + assert.verifySteps(['blue']); + + // Verify the `Select all` checkbox is now indeterminate + assert.dom('[data-multiselect-select-all-checkbox]').hasProperty('indeterminate', true); + + // Verify the list item has the proper aria-selected attribute of false, since the + // checkbox is in the indeterminate state + assert.dom('[data-multiselect-select-all-option]').hasAttribute('aria-selected', 'false'); + + // When the `Select all` checkbox is indeterminate, clicking it should re-select all options + await triggerKeyEvent('[data-multiselect]', 'keydown', 'Enter'); + + assert.verifySteps(['blue,red']); + + assert.dom('[data-multiselect-select-all-checkbox]').hasProperty('indeterminate', false); + assert.dom('[data-multiselect-select-all-checkbox]').isChecked(); + + // Verify the list item has the proper aria-selected attribute of true, since it's selected + assert.dom('[data-multiselect-select-all-option]').hasAttribute('aria-selected', 'true'); + + // Re-clicking the `Select all` checkbox when it's checked should un-check all options + // Go back to the top and select it + await triggerKeyEvent('[data-multiselect]', 'keydown', 'Home'); + await triggerKeyEvent('[data-multiselect]', 'keydown', 'Enter'); + + assert.verifySteps(['empty']); + + assert.dom('[data-multiselect-select-all-checkbox]').hasProperty('indeterminate', false); + assert.dom('[data-multiselect-select-all-checkbox]').isNotChecked(); + + // Verify the list item has the proper aria-selected attribute of false, since it's + // no longer selected + assert.dom('[data-multiselect-select-all-option]').hasAttribute('aria-selected', 'false'); + }); + test('it throws an assertion error if no `:chip` block is provided', async function (assert) { assert.expect(1); diff --git a/test-app/tests/integration/components/toucan-form/form-multiselect-test.gts b/test-app/tests/integration/components/toucan-form/form-multiselect-test.gts index d77334fc..53627cfc 100644 --- a/test-app/tests/integration/components/toucan-form/form-multiselect-test.gts +++ b/test-app/tests/integration/components/toucan-form/form-multiselect-test.gts @@ -1,4 +1,4 @@ -import { render } from '@ember/test-helpers'; +import { click, render } from '@ember/test-helpers'; import { module, test } from 'qunit'; import ToucanForm from '@crowdstrike/ember-toucan-form/components/toucan-form'; @@ -153,4 +153,40 @@ module('Integration | Component | ToucanForm | Multiselect', function (hooks) { assert.dom('[data-label-block]').exists(); assert.dom('[data-hint-block]').exists(); }); + + test('it renders the `Select all` option', async function (assert) { + const data: TestData = { + selection: undefined, + }; + + await render(); + + await click('[data-multiselect]'); + + assert.dom('[data-multiselect-select-all-option]').exists(); + assert.dom('[data-multiselect-select-all-option]').hasText('Select all'); + }); });