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(
+
+ <:chip as |chip|>
+
+ {{chip.option}}
+
+
+
+
+ <:default as |multiselect|>
+ {{multiselect.option}}
+
+
+ );
+
+ 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(
+
+ <:chip as |chip|>
+
+ {{chip.option}}
+
+
+
+
+ <:default as |multiselect|>
+ {{multiselect.option}}
+
+
+ );
+
+ 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(
+
+ <:chip as |chip|>
+
+ {{chip.option}}
+
+
+
+
+ <:default as |multiselect|>
+ {{multiselect.option}}
+
+
+ );
+
+ // 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(
+
+
+ <:chip as |chip|>
+
+ {{chip.option}}
+
+
+
+
+ <:default as |multiselect|>
+ {{multiselect.option}}
+
+
+
+ );
+
+ await click('[data-multiselect]');
+
+ assert.dom('[data-multiselect-select-all-option]').exists();
+ assert.dom('[data-multiselect-select-all-option]').hasText('Select all');
+ });
});