diff --git a/.changeset/twelve-gifts-camp.md b/.changeset/twelve-gifts-camp.md
new file mode 100644
index 00000000..8f118acb
--- /dev/null
+++ b/.changeset/twelve-gifts-camp.md
@@ -0,0 +1,61 @@
+---
+'@crowdstrike/ember-toucan-core': patch
+'@crowdstrike/ember-toucan-form': patch
+---
+
+Added a `Combobox` component to both core and form packages.
+
+If you're using `toucan-core`, the control and field components are exposed:
+
+```hbs
+
+
+ {{combobox.option.label}}
+
+
+
+
+
+ {{combobox.option.label}}
+
+
+```
+
+If you're using `toucan-form`, the component is exposed via:
+
+```hbs
+
+
+ {{combobox.option}}
+
+
+```
+
+For more information on using these components, view [the docs](https://ember-toucan-core.pages.dev/docs/components/combobox).
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aa65d598..af3cd796 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -118,8 +118,9 @@ jobs:
- ember-release
- ember-beta
- ember-canary
- - "'ember-release + embroider-optimized'"
- - "'ember-lts-4.8 + embroider-optimized'"
+ # @todo Temporarily disabling embroider optimized tests due to https://github.com/CrowdStrike/ember-toucan-core/issues/210
+ # - "'ember-release + embroider-optimized'"
+ # - "'ember-lts-4.8 + embroider-optimized'"
steps:
- uses: actions/checkout@v3
diff --git a/docs-app/package.json b/docs-app/package.json
index 03769c1f..f0275889 100644
--- a/docs-app/package.json
+++ b/docs-app/package.json
@@ -135,6 +135,7 @@
"ember-browser-services": "^4.0.4",
"ember-modifier": "^4.1.0",
"ember-resources": "^6.0.0",
+ "ember-velcro": "^2.1.0",
"highlight.js": "^11.6.0",
"highlightjs-glimmer": "^2.0.0",
"tracked-built-ins": "^3.1.0"
diff --git a/docs/components/combobox-field/demo/base-demo.md b/docs/components/combobox-field/demo/base-demo.md
new file mode 100644
index 00000000..064880b4
--- /dev/null
+++ b/docs/components/combobox-field/demo/base-demo.md
@@ -0,0 +1,74 @@
+```hbs template
+
+
+ {{combobox.option.label}}
+
+
+```
+
+```js component
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+
+export default class extends Component {
+ @tracked selected;
+ @tracked errorMessage;
+
+ options = [
+ {
+ label: 'Blue',
+ name: 'blue',
+ },
+ {
+ label: 'Green',
+ name: 'green',
+ },
+ {
+ label: 'Yellow',
+ name: 'yellow',
+ },
+ {
+ label: 'Orange',
+ name: 'orange',
+ },
+ {
+ label: 'Red',
+ name: 'red',
+ },
+ {
+ label: 'Purple',
+ name: 'purple',
+ },
+ {
+ label: 'Teal',
+ name: 'teal',
+ },
+ ];
+
+ @action
+ onChange(option) {
+ this.selected = option;
+ console.log(option);
+
+ if (option.label !== 'Blue') {
+ this.errorMessage = 'Please select "Blue"';
+ return;
+ }
+
+ this.errorMessage = null;
+ }
+}
+```
diff --git a/docs/components/combobox-field/index.md b/docs/components/combobox-field/index.md
new file mode 100644
index 00000000..726101a2
--- /dev/null
+++ b/docs/components/combobox-field/index.md
@@ -0,0 +1,311 @@
+# Combobox Field
+
+Provides a Toucan-styled combobox with filtering that builds on top of the Field component.
+
+## Label
+
+Required.
+
+Use either the `@label` component argument or the `:label` named block.
+
+Provide a string to the `@label` component argument or content to the `:label` named block to render into the Label section of the Field.
+
+### `@label`
+
+```hbs
+
+
+ {{combobox.option}}
+
+
+```
+
+### `:label`
+
+```hbs
+
+ <:label>Here is a label
+
+
+
+
+
+ <:label>Here is a label
+ <:default>
+
+ {{combobox.option}}
+
+
+
+```
+
+## Hint
+
+Optional.
+
+Use either the `@hint` component argument or the `:hint` named block.
+
+Provide a string to the `@hint` component argument or content to `:hint` named block to render into the Hint section of the Field.
+
+### @hint
+
+```hbs
+
+
+ {{combobox.option}}
+
+
+```
+
+### `:hint`
+
+```hbs
+
+ <:hint>Here is a hint Link
+ <:default>
+
+ {{combobox.option}}
+
+
+
+```
+
+## Error
+
+Optional.
+
+Provide a string or array of strings to `@error` to render the text into the Error section of the Field.
+
+```hbs
+
+```
+
+```hbs
+
+
+ {{combobox.option}}
+
+
+```
+
+## `@onChange`
+
+Provide an `@onChange` callback to be notified when the user's selections have changed.
+`@onChange` will receive the value as its only argument.
+
+```hbs
+
+
+ {{combobox.option}}
+
+
+```
+
+```js
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+
+export default class extends Component {
+ @action
+ onChange(values) {
+ console.log(values);
+ }
+}
+```
+
+## Disabled State
+
+Set the `@isDisabled` argument to disable the input.
+
+## Read Only State
+
+Set the `@isReadOnly` argument to put the input in the read only state.
+
+## Attributes and Modifiers
+
+Consumers have direct access to the underlying [input element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input), so all attributes are supported.
+Modifiers can also be added directly to the input as shown in the demo.
+
+## Test Selectors
+
+### Root Element
+
+Provide a custom selector via `@rootTestSelector`.
+This test selector will be used as the value for the `data-root-field` attribute.
+The Field can be targeted via:
+
+```hbs
+
+```
+
+```js
+assert.dom('[data-root-field="example"]');
+// targeting this field's specific label
+assert.dom('[data-root-field="example"] > [data-label]');
+```
+
+### Label
+
+Target the label element via `data-label`.
+
+### Hint
+
+Target the hint block via `data-hint`.
+
+### Error
+
+Target the error block via `data-error`.
+
+## UI States
+
+### ComboboxField with `@label`
+
+
+
+
+
+### ComboboxField with `@label` and `@hint`
+
+
+
+
+
+### ComboboxField with `:label` and `:hint` blocks
+
+
+
+ <:label>Label
+ <:hint>Hint text link
+ <:default as |combobox|>
+
+ {{combobox.option}}
+
+
+
+
+
+### ComboboxField with `@label` and `@error`
+
+
+
+
+
+### ComboboxField with `@label`, `@hint`, and `@error`
+
+
+
+
+
+### ComboboxField with `@label` and `@isDisabled`
+
+
+
+
+
+### ComboboxField with `@label`, `@value`, and `@isDisabled`
+
+
+
+
+ {{combobox.option}}
+
+
+
+
+### ComboboxField with multiple errors
+
+
+
+
+ {{combobox.option}}
+
+
+
+
+### ComboboxField with `@isReadOnly`
+
+
+
+
+
+### ComboboxField with `@isReadOnly` and `@selected`
+
+
+
+
+ {{combobox.option}}
+
+
+
diff --git a/docs/components/combobox/demo/base-demo.md b/docs/components/combobox/demo/base-demo.md
new file mode 100644
index 00000000..a84e190a
--- /dev/null
+++ b/docs/components/combobox/demo/base-demo.md
@@ -0,0 +1,142 @@
+```hbs template
+
+
+
+ {{combobox.option.label}}
+
+
+
+
+
+ {{combobox.option}}
+
+
+
+
+
+ {{combobox.option.label}}
+
+
+
+```
+
+```js component
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+
+export default class extends Component {
+ @tracked selected;
+ @tracked selected2;
+ @tracked selected3;
+
+ options = [
+ {
+ label: 'Blue',
+ name: 'blue',
+ value: 'blue',
+ },
+ {
+ label: 'Green',
+ name: 'green',
+ value: 'green',
+ },
+ {
+ label: 'Yellow',
+ name: 'yellow',
+ value: 'yellow',
+ },
+ {
+ label: 'Orange',
+ name: 'orange',
+ value: 'orange',
+ },
+ {
+ label: 'Red',
+ name: 'red',
+ value: 'red',
+ },
+ {
+ label: 'Purple',
+ name: 'purple',
+ value: 'purple',
+ },
+ {
+ label: 'Teal',
+ name: 'teal',
+ value: 'teal',
+ },
+ ];
+
+ options2 = [
+ 'Billy',
+ 'Bob',
+ 'Cameron',
+ 'Clinton',
+ 'Daniel',
+ 'David',
+ 'Mary',
+ 'Nicole',
+ 'Simon',
+ 'Tony',
+ ];
+
+ @action
+ onChange(option) {
+ this.selected = option;
+ console.log(option);
+ }
+
+ @action
+ onChange2(option) {
+ this.selected2 = option;
+ console.log(option);
+ }
+
+ @action
+ onChange3(option) {
+ this.selected3 = option;
+ console.log(option);
+ }
+
+ @action
+ onFilterBy(input) {
+ console.log(`filtering with the value "${input}"`);
+
+ if (input.length > 0) {
+ return this.options.filter((option) =>
+ option.label.toLowerCase().startsWith(input.toLowerCase())
+ );
+ } else {
+ return this.options;
+ }
+ }
+}
+```
diff --git a/docs/components/combobox/index.md b/docs/components/combobox/index.md
new file mode 100644
index 00000000..ba2f5917
--- /dev/null
+++ b/docs/components/combobox/index.md
@@ -0,0 +1,256 @@
+# Combobox
+
+Provides a Toucan-styled combobox with filtering.
+If you are building forms, you may be interested in the ComboboxField component instead.
+
+## Popover z-index
+
+A CSS class to add to this component's content container. Commonly used to specify a `z-index`.
+
+```hbs
+
+```
+
+## Options
+
+`@options` forms the content of this component. To support a variety of data shapes, `@options` is typed as `unknown[]` and treated as though it were opaque. `@options` is simply iterated over then passed back to you as a block parameter (`combobox.option`).
+
+```hbs
+
+
+
+ {{combobox.option}}
+
+
+```
+
+## Selected
+
+The currently selected option. Can be either an object or a string. If `@options` is an array of strings, provide a string. If `@options` is an array of objects, pass the entire object. Works in combination with `@onChange`.
+
+```hbs
+
+
+
+ {{combobox.option}}
+
+
+```
+
+```js
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+
+export default class extends Component {
+ @tracked selected;
+}
+```
+
+## onChange
+
+Called when the user makes a selection. It is called with the selected option (derived from `@options`) as its only argument. You'll want to update `@selected` with the new value in your on change handler.
+
+```hbs
+
+
+ {{combobox.option}}
+
+
+```
+
+```js
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+
+export default class extends Component {
+ @tracked selected;
+
+ options = ['Blue', 'Red', 'Yellow'];
+
+ @action
+ handleChange(option) {
+ this.selected = option;
+ }
+}
+```
+
+## Option Key
+
+Optional.
+
+The `@optionKey` argument is used when your `@options` take the shape of an array of objects. The `@optionKey` is used to determine two things internally:
+
+1. The displayed value inside of the input of the combobox
+2. Used as the key in the default filtering scenario where we filter `@options`. To properly filter the `@options` based on the user input from the textbox, we need to know how to compare the entered value to each object. The `@optionKey` tells us which key of the object to use for this filtering.
+
+In the example below, we set `@optionKey='label'`. Our `@options` objects have a `label` key and we want the label of the selected option to be used for the selected value, as well as for filtering as the user types.
+
+```hbs
+
+
+ {{combobox.option.label}}
+
+
+```
+
+```js
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+
+export default class extends Component {
+ @tracked selected;
+
+ options = [
+ {
+ label: 'Blue',
+ value: 'blue',
+ },
+ {
+ label: 'Green',
+ value: 'green',
+ },
+ {
+ label: 'Yellow',
+ value: 'yellow',
+ },
+ {
+ label: 'Orange',
+ value: 'orange',
+ },
+ {
+ label: 'Red',
+ value: 'red',
+ },
+ {
+ label: 'Purple',
+ value: 'purple',
+ },
+ {
+ label: 'Teal',
+ value: 'teal',
+ },
+ ];
+
+ @action
+ handleChange(option) {
+ this.selected = option;
+ }
+}
+```
+
+## onFilter
+
+Optional.
+
+By default, when `@options` are an array of strings, the built-in filtering does simple `startsWith` filtering. When `@options` are an array of objects, the same filtering logic applies, but the key of each object is determined by the provided `@optionKey`. There may be cases where you need to write your own filtering logic completely that is more complex than the built-in `startsWith` filtering described. To do so, leverage `@onFilter` instead. This function should return an array of items that will then be used to populate the dropdown results.
+
+```hbs
+
+
+ {{combobox.option}}
+
+
+```
+
+```js
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+import { tracked } from '@glimmer/tracking';
+
+export default class extends Component {
+ @tracked selected;
+
+ options = [
+ {
+ label: 'Blue',
+ value: 'blue',
+ },
+ {
+ label: 'Green',
+ value: 'green',
+ },
+ {
+ label: 'Yellow',
+ value: 'yellow',
+ },
+ {
+ label: 'Orange',
+ value: 'orange',
+ },
+ {
+ label: 'Red',
+ value: 'red',
+ },
+ {
+ label: 'Purple',
+ value: 'purple',
+ },
+ {
+ label: 'Teal',
+ value: 'teal',
+ },
+ ];
+
+ @action
+ handleChange(option) {
+ this.selected = option;
+ }
+
+ @action
+ handleFilter(value) {
+ return this.options.filter((option) => option.label === value);
+ }
+}
+```
+
+## Disabled State
+
+Set the `@isDisabled` argument to disable the input.
+
+```hbs
+
+```
+
+## Read Only State
+
+Set the `@isReadOnly` argument to put the input in the read only state.
+
+```hbs
+
+```
+
+## Error State
+
+Set the `@hasError` argument to apply an error box shadow to the ` `.
+
+```hbs
+
+```
diff --git a/docs/toucan-form/changeset-validation/demo/base-demo.md b/docs/toucan-form/changeset-validation/demo/base-demo.md
index 6293538c..5ad958bf 100644
--- a/docs/toucan-form/changeset-validation/demo/base-demo.md
+++ b/docs/toucan-form/changeset-validation/demo/base-demo.md
@@ -26,6 +26,20 @@
+
+
+ {{combobox.option.label}}
+
+
+
+
+ {{yield}}
+
+ {{! TODO: Do we make `@value` required? }}
+ {{!
+ We'll need Form::Controls::Select to work with real forms and the native `FormData` API.
+ That means it'll need to be backed by a real INPUT. Screenreaders already have all the information
+ they need, which means the INPUT is superfluous and is thus noise?
+ }}
+ {{! template-lint-disable no-nested-interactive }}
+ {{! template-lint-disable require-input-label }}
+
+
\ No newline at end of file
diff --git a/packages/ember-toucan-core/src/-private/components/form/controls/combobox/option.ts b/packages/ember-toucan-core/src/-private/components/form/controls/combobox/option.ts
new file mode 100644
index 00000000..eebd48e5
--- /dev/null
+++ b/packages/ember-toucan-core/src/-private/components/form/controls/combobox/option.ts
@@ -0,0 +1,94 @@
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+
+import Check from '../../../../../-private/icons/check';
+
+interface ToucanFormComboboxOptionControlComponentSignature {
+ Args: {
+ /**
+ * When true, means that the option is currently hovered over with a mouse
+ * or "focused" with a keyboard.
+ */
+ isActive?: boolean;
+
+ /**
+ * Sets the underlying, hidden input element to disabled.
+ */
+ isDisabled?: boolean;
+
+ /**
+ * Sets the underlying, hidden input 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, hidden input element `value` attribute.
+ */
+ value?: string;
+ };
+ Blocks: {
+ default: [];
+ };
+ Element: HTMLLIElement;
+}
+
+const className = 'toucan-form-select-option-control';
+
+export const selector = `.${className}`;
+
+export default class ToucanFormComboboxOptionControlComponent extends Component {
+ className = className;
+ Check = Check;
+
+ 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/-private/icons/check.hbs b/packages/ember-toucan-core/src/-private/icons/check.hbs
new file mode 100644
index 00000000..b00179bc
--- /dev/null
+++ b/packages/ember-toucan-core/src/-private/icons/check.hbs
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/packages/ember-toucan-core/src/-private/icons/check.ts b/packages/ember-toucan-core/src/-private/icons/check.ts
new file mode 100644
index 00000000..3fa3b84c
--- /dev/null
+++ b/packages/ember-toucan-core/src/-private/icons/check.ts
@@ -0,0 +1,11 @@
+import templateOnlyComponent from '@ember/component/template-only';
+
+export interface ToucanCheckIconComponentSignature {
+ Args: {};
+ Blocks: {
+ default: [];
+ };
+ Element: SVGElement;
+}
+
+export default templateOnlyComponent();
diff --git a/packages/ember-toucan-core/src/-private/icons/chevron.hbs b/packages/ember-toucan-core/src/-private/icons/chevron.hbs
new file mode 100644
index 00000000..5fafbac0
--- /dev/null
+++ b/packages/ember-toucan-core/src/-private/icons/chevron.hbs
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/packages/ember-toucan-core/src/-private/icons/chevron.ts b/packages/ember-toucan-core/src/-private/icons/chevron.ts
new file mode 100644
index 00000000..27d3ec96
--- /dev/null
+++ b/packages/ember-toucan-core/src/-private/icons/chevron.ts
@@ -0,0 +1,11 @@
+import templateOnlyComponent from '@ember/component/template-only';
+
+export interface ToucanChevronIconComponentSignature {
+ Args: {};
+ Blocks: {
+ default: [];
+ };
+ Element: SVGElement;
+}
+
+export default templateOnlyComponent();
diff --git a/packages/ember-toucan-core/src/-private/components/lock-icon.hbs b/packages/ember-toucan-core/src/-private/icons/lock.hbs
similarity index 100%
rename from packages/ember-toucan-core/src/-private/components/lock-icon.hbs
rename to packages/ember-toucan-core/src/-private/icons/lock.hbs
diff --git a/packages/ember-toucan-core/src/-private/components/lock-icon.ts b/packages/ember-toucan-core/src/-private/icons/lock.ts
similarity index 52%
rename from packages/ember-toucan-core/src/-private/components/lock-icon.ts
rename to packages/ember-toucan-core/src/-private/icons/lock.ts
index e5789940..4381eea5 100644
--- a/packages/ember-toucan-core/src/-private/components/lock-icon.ts
+++ b/packages/ember-toucan-core/src/-private/icons/lock.ts
@@ -1,6 +1,6 @@
import templateOnlyComponent from '@ember/component/template-only';
-export interface ToucanLockIconComponentSignature {
+export interface ToucanLockIconSignature {
Element: SVGElement;
Args: {};
Blocks: {
@@ -8,4 +8,4 @@ export interface ToucanLockIconComponentSignature {
};
}
-export default templateOnlyComponent();
+export default templateOnlyComponent();
diff --git a/packages/ember-toucan-core/src/components/form/controls/combobox.hbs b/packages/ember-toucan-core/src/components/form/controls/combobox.hbs
new file mode 100644
index 00000000..8c6eae6b
--- /dev/null
+++ b/packages/ember-toucan-core/src/components/form/controls/combobox.hbs
@@ -0,0 +1,107 @@
+
+
+ {{! template-lint-disable no-redundant-role }}
+
+
+
+
+
+ {{#if this.isPopoverOpen}}
+
+ {{#if this.options}}
+ {{#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.isEqual @selected option)
+ isReadOnly=@isReadOnly
+ onClick=(fn this.onChange index)
+ onMouseover=(fn this.onOptionMouseover index)
+ popoverId=this.popoverId
+ index=index
+ )
+ option=option
+ )
+ }}
+ {{/each}}
+ {{else}}
+ {{@noResultsText}}
+ {{/if}}
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/packages/ember-toucan-core/src/components/form/controls/combobox.ts b/packages/ember-toucan-core/src/components/form/controls/combobox.ts
new file mode 100644
index 00000000..a0bb4dbd
--- /dev/null
+++ b/packages/ember-toucan-core/src/components/form/controls/combobox.ts
@@ -0,0 +1,521 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { assert } from '@ember/debug';
+import { action } from '@ember/object';
+import { guidFor } from '@ember/object/internals';
+import { next } from '@ember/runloop';
+import { isEqual as emberIsEqual } from '@ember/utils';
+
+import { offset, size } from '@floating-ui/dom';
+
+import OptionComponent, {
+ selector as optionComponentSelector,
+} from '../../../-private/components/form/controls/combobox/option';
+import Chevron from '../../../-private/icons/chevron';
+
+import type { Middleware as VelcroMiddleware } from '@floating-ui/dom';
+import type { WithBoundArgs } from '@glint/template';
+
+export type Option = string | Record | undefined;
+
+export interface ToucanFormComboboxControlComponentSignature<
+ OPTION extends Option
+> {
+ Args: {
+ /**
+ * A CSS class to add to this component's content container.
+ * Commonly used to specify a `z-index`.
+ */
+ contentClass?: string;
+
+ /**
+ * Sets the input to an errored-state via styling.
+ */
+ hasError?: boolean;
+
+ /**
+ * Sets the `disabled` attribute of the input.
+ */
+ isDisabled?: boolean;
+
+ /**
+ * Sets the `readonly` attribute of the input.
+ */
+ isReadOnly?: boolean;
+
+ /**
+ * A string to display when there are no results after the user's filter.
+ */
+ noResultsText?: string;
+
+ /**
+ * Called when the user makes a selection.
+ * It is called with the selected option (derived from `@options`) as its only argument.
+ */
+ onChange?: (option: unknown) => void;
+
+ /**
+ * The function called when a user types into the combobox textbox, typically used to write custom filtering logic.
+ */
+ onFilter?: (input: string) => OPTION[];
+
+ /**
+ * `@options` forms the content of this component.
+ *
+ * `@options` is simply iterated over then passed back to you as a block parameter (`select.option`).
+ */
+ options?: OPTION[];
+
+ /**
+ * When `@options` is an array of objects, `@selected` is also an object.
+ * The `@optionKey` is used to determine which key of the object should
+ * be used for both filtering and displayed the selected value in the
+ * textbox.
+ */
+ optionKey?: OPTION extends Record
+ ? keyof OPTION
+ : undefined;
+
+ /**
+ * The currently selected option. If `@options` is an array of strings, provide a string. If `@options` is an array of objects, pass the entire object.
+ */
+ selected?: OPTION;
+ };
+ Blocks: {
+ default: [
+ {
+ option: OPTION;
+ Option: WithBoundArgs<
+ typeof OptionComponent,
+ | 'index'
+ | 'isActive'
+ | 'isDisabled'
+ | 'isReadOnly'
+ | 'onClick'
+ | 'onMouseover'
+ | 'popoverId'
+ >;
+ }
+ ];
+ };
+ Element: HTMLInputElement;
+}
+
+export default class ToucanFormComboboxControlComponent<
+ OPTION extends Option
+> extends Component> {
+ @tracked activeIndex: number | null = null;
+ @tracked inputValue: string | undefined;
+ @tracked isPopoverOpen = false;
+ @tracked filteredOptions: OPTION[] | undefined;
+
+ Chevron = Chevron;
+ Option = OptionComponent;
+ popoverId = `popover--${guidFor(this)}`;
+
+ constructor(
+ owner: unknown,
+ args: ToucanFormComboboxControlComponentSignature['Args']
+ ) {
+ super(owner, args);
+
+ // We need to set our input tag's value attribute
+ // if we have a selected option provided on render
+ let { selected, optionKey } = this.args;
+
+ this.inputValue =
+ typeof selected === 'object' && optionKey
+ ? (selected[optionKey] as string | undefined)
+ : (selected as string | undefined);
+ }
+
+ velcroMiddleware: VelcroMiddleware[] = [
+ offset({
+ mainAxis: 8,
+ }),
+ size({
+ apply({ rects, elements }) {
+ Object.assign(elements.floating.style, {
+ width: `${rects.reference.width}px`,
+ });
+ },
+ }),
+ ];
+
+ /**
+ * This is required for accessibility so that we can announce to the screenreader the highlighted option as the user uses the arrow keys.
+ */
+ get activeDescendant() {
+ // For the case where nothing is selected on render
+ if (this.activeIndex === null) {
+ return null;
+ }
+
+ return `${this.popoverId}-${this.activeIndex}`;
+ }
+
+ /**
+ * This state is used to determine if we should add event handlers to the input element or not.
+ */
+ get isDisabledOrReadOnlyOrWithoutOptions() {
+ return (
+ this.args.isDisabled ||
+ this.args.isReadOnly ||
+ this.args.options === undefined
+ );
+ }
+
+ /**
+ * We apply different styles to the input tag based on our current state.
+ */
+ get styles() {
+ if (this.args.isDisabled) {
+ return 'shadow-focusable-outline bg-overlay-1 text-disabled pointer-events-none placeholder:text-disabled';
+ }
+
+ if (this.args.isReadOnly) {
+ return 'focus:shadow-focus-outline bg-surface-xl shadow-read-only-outline text-titles-and-attributes placeholder:text-titles-and-attributes';
+ }
+
+ if (this.args.hasError) {
+ return 'shadow-error-outline focus:shadow-error-focus-outline bg-overlay-1 text-titles-and-attributes';
+ }
+
+ return 'shadow-focusable-outline focus:shadow-focus-outline bg-overlay-1 text-titles-and-attributes';
+ }
+
+ /**
+ * The options to render inside of the popover list.
+ */
+ get options() {
+ return this.filteredOptions || this.args?.options;
+ }
+
+ /**
+ * Attempts to scroll the active or newly highlighted item into view for the user.
+ */
+ scrollActiveOptionIntoView(alignToTop?: boolean) {
+ assert('`this.activeIndex` cannot be `null`', this.activeIndex !== null);
+
+ const optionsElements = document.querySelectorAll(
+ `#${this.popoverId} ${optionComponentSelector}`
+ );
+ const optionElement = optionsElements[this.activeIndex];
+
+ if (!optionElement) {
+ return;
+ }
+
+ optionElement.scrollIntoView(alignToTop);
+ }
+
+ @action
+ closePopover() {
+ this.isPopoverOpen = false;
+ }
+
+ @action
+ noop() {
+ // eslint-disable @typescript-eslint/no-empty-function
+ }
+
+ /**
+ * Action called when a new item is selected. Ultimately calls the provided `@onChange` with the newly selected item.
+ */
+ @action
+ onChange() {
+ assert('`this.activeIndex` cannot be `null`', this.activeIndex !== null);
+
+ assert(
+ '`this.args.options` cannot be `undefined`',
+ this.args.options !== undefined
+ );
+
+ let { optionKey, onChange } = this.args;
+
+ this.closePopover();
+
+ assert(
+ '`this.options` was unexpectedly empty in an on change handler. If you see this, please report it as a bug to ember-toucan-core!',
+ this.options
+ );
+
+ // This shouldn't be possible, but to satisfy TS
+ if (this.activeIndex === null) {
+ onChange?.(null);
+
+ return;
+ }
+
+ let selectedOption = this.options[this.activeIndex];
+
+ if (typeof selectedOption === 'string') {
+ this.inputValue = selectedOption;
+ }
+
+ if (selectedOption && typeof selectedOption === 'object' && optionKey) {
+ let option = (selectedOption as Record)[optionKey];
+
+ this.inputValue = option;
+ }
+
+ this.args.onChange?.(this.options[this.activeIndex]);
+
+ this.filteredOptions = undefined;
+ }
+
+ @action
+ isEqual(one: number | Option | null, two: number | Option | null) {
+ return emberIsEqual(one, two);
+ }
+
+ /**
+ * Handle keyboard events to operate like a combobox as defined at https://www.w3.org/WAI/ARIA/apg/patterns/combobox/.
+ */
+ @action
+ onKeydown(event: KeyboardEvent) {
+ if (event.key === 'Tab') {
+ return;
+ }
+
+ if (!this.isPopoverOpen) {
+ // Prevents keys like ArrowDown and ArrowUp from scrolling the page.
+ event.preventDefault();
+
+ this.openPopover();
+
+ return;
+ }
+
+ if (!this.isPopoverOpen && event.key === 'Escape') {
+ this.openPopover();
+
+ return;
+ }
+
+ if (event.key === 'Escape') {
+ this.closePopover();
+
+ return;
+ }
+
+ if (event.key === 'Enter' && this.activeIndex !== null) {
+ // Prevents a "click" event from firing and reopening the popover.
+ event.preventDefault();
+
+ this.onChange();
+
+ return;
+ }
+
+ if (event.key === ' ' && this.activeIndex !== null) {
+ return;
+ }
+
+ if (
+ (event.key === 'ArrowDown' && event.metaKey) ||
+ event.key === 'PageDown' ||
+ event.key === 'End'
+ ) {
+ assert(
+ '`this.args.options` cannot be `undefined`',
+ this.args.options !== undefined
+ );
+
+ // By default, arrowing up and down moves the insertion point to the beginning or end
+ // of an input field. We don't want this. We want to reserve arrowing for moving
+ // vertically through the list.
+ event.preventDefault();
+
+ const activeIndex = this.args.options.length - 1;
+
+ this.activeIndex = activeIndex;
+ this.scrollActiveOptionIntoView(false);
+
+ return;
+ }
+
+ if (event.key === 'ArrowDown') {
+ assert('`this.activeIndex` cannot be `null`', this.activeIndex !== null);
+ assert(
+ '`this.args.options` cannot be `undefined`',
+ this.args.options !== undefined
+ );
+ event.preventDefault();
+
+ const activeIndex =
+ this.activeIndex === this.args.options.length - 1
+ ? this.activeIndex
+ : this.activeIndex + 1;
+
+ this.activeIndex = activeIndex;
+
+ this.scrollActiveOptionIntoView(false);
+
+ return;
+ }
+
+ if (
+ (event.key === 'ArrowUp' && event.metaKey) ||
+ event.key === 'PageUp' ||
+ event.key === 'Home'
+ ) {
+ event.preventDefault();
+
+ const activeIndex = 0;
+
+ this.activeIndex = activeIndex;
+ this.scrollActiveOptionIntoView();
+
+ return;
+ }
+
+ if (event.key === 'ArrowUp') {
+ assert('`this.activeIndex` cannot be `null`', this.activeIndex !== null);
+ event.preventDefault();
+
+ const activeIndex =
+ this.activeIndex === 0 ? this.activeIndex : this.activeIndex - 1;
+
+ this.activeIndex = activeIndex;
+ this.scrollActiveOptionIntoView();
+
+ return;
+ }
+ }
+
+ /**
+ * Handles filtering when a user types into the input element.
+ */
+ @action
+ async onInput(event: Event | InputEvent) {
+ assert(
+ 'Expected HTMLInputElement',
+ event.target instanceof HTMLInputElement
+ );
+
+ const value = event.target.value;
+
+ this.inputValue = value;
+
+ const { options, optionKey, onFilter } = this.args;
+ const optionsArgument = options ? [...options] : [];
+
+ let filteredOptions: OPTION[] = [];
+
+ if (!onFilter && !optionKey) {
+ filteredOptions = (optionsArgument as string[])?.filter(
+ (option: string) =>
+ option.toLowerCase().startsWith(value.toLowerCase()) as unknown
+ ) as OPTION[];
+ }
+
+ if (!onFilter && optionKey) {
+ filteredOptions = optionsArgument?.filter((option) =>
+ ((option as Record)[optionKey] as string)
+ ?.toLowerCase()
+ ?.startsWith(value.toLowerCase())
+ );
+ }
+
+ if (onFilter) {
+ filteredOptions = onFilter(value);
+ }
+
+ this.filteredOptions = filteredOptions;
+
+ if (this.filteredOptions?.length > 0) {
+ this.activeIndex = 0;
+ }
+ }
+
+ @action
+ onOptionMouseover(index: number) {
+ this.activeIndex = index;
+ }
+
+ @action
+ openPopover() {
+ this.isPopoverOpen = true;
+
+ if (this.activeIndex === null) {
+ this.activeIndex = 0;
+ } else {
+ // Wait until the options have been rendered.
+ next(() => {
+ this.scrollActiveOptionIntoView(false);
+ });
+ }
+ }
+
+ /**
+ * Action that resets the input on blur to the selected option, if one was chosen (otherwise null).
+ *
+ * The use cases for this is two-fold:
+ *
+ * 1) The combobox value is optional. The user selected an option
+ * but then realized they no longer want that selected option.
+ * Since it is not required, we allow them to clear the input
+ * and call the provided `@onChange` with `null` to signal
+ * that the selection was cleared
+ * 2) The combobox's `@selected` item is valid, but then a user
+ * enters garbage into the input and then tabs out of the
+ * element. In that case, we don't want to store their
+ * garbage entry. Instead, we reset it back to the selected
+ * option.
+ */
+ @action
+ resetValue(event: Event) {
+ assert(
+ 'Expected HTMLInputElement',
+ event.target instanceof HTMLInputElement
+ );
+
+ let { selected, optionKey, onChange } = this.args;
+
+ if (!selected) {
+ return;
+ }
+
+ // Reset our filtered options as we are going to "clear" the input
+ this.filteredOptions = undefined;
+
+ if (event.target.value === '') {
+ this.inputValue = undefined;
+ onChange?.(null);
+
+ return;
+ }
+
+ if (typeof selected === 'string' && event.target.value !== selected) {
+ this.inputValue = selected;
+
+ return;
+ }
+
+ if (
+ typeof selected === 'object' &&
+ optionKey &&
+ event.target.value !== selected[optionKey]
+ ) {
+ this.inputValue = selected[optionKey] as string;
+ }
+ }
+
+ /**
+ * Highlights the entered value of the input when the combobox input is clicked.
+ */
+ @action
+ selectInput(event: Event) {
+ assert(
+ 'Expected HTMLInputElement',
+ event.target instanceof HTMLInputElement
+ );
+
+ if (!this.args?.selected) {
+ return;
+ }
+
+ event.target.select();
+ }
+}
diff --git a/packages/ember-toucan-core/src/components/form/fields/checkbox-group.ts b/packages/ember-toucan-core/src/components/form/fields/checkbox-group.ts
index 8f2f28f9..ef588fba 100644
--- a/packages/ember-toucan-core/src/components/form/fields/checkbox-group.ts
+++ b/packages/ember-toucan-core/src/components/form/fields/checkbox-group.ts
@@ -2,7 +2,7 @@ import Component from '@glimmer/component';
import { action } from '@ember/object';
import assertBlockOrArgumentExists from '../../../-private/assert-block-or-argument-exists';
-import LockIcon from '../../../-private/components/lock-icon';
+import LockIcon from '../../../-private/icons/lock';
import CheckboxFieldComponent from './checkbox';
import type { AssertBlockOrArg } from '../../../-private/assert-block-or-argument-exists';
diff --git a/packages/ember-toucan-core/src/components/form/fields/checkbox.ts b/packages/ember-toucan-core/src/components/form/fields/checkbox.ts
index 59385374..e8e389de 100644
--- a/packages/ember-toucan-core/src/components/form/fields/checkbox.ts
+++ b/packages/ember-toucan-core/src/components/form/fields/checkbox.ts
@@ -2,7 +2,7 @@ import Component from '@glimmer/component';
import { assert } from '@ember/debug';
import assertBlockOrArgumentExists from '../../../-private/assert-block-or-argument-exists';
-import LockIcon from '../../../-private/components/lock-icon';
+import LockIcon from '../../../-private/icons/lock';
import type { AssertBlockOrArg } from '../../../-private/assert-block-or-argument-exists';
import type { ErrorMessage } from '../../../-private/types';
diff --git a/packages/ember-toucan-core/src/components/form/fields/combobox.hbs b/packages/ember-toucan-core/src/components/form/fields/combobox.hbs
new file mode 100644
index 00000000..a1afd74f
--- /dev/null
+++ b/packages/ember-toucan-core/src/components/form/fields/combobox.hbs
@@ -0,0 +1,73 @@
+
+
+ {{#if
+ (this.assertBlockOrArgumentExists
+ (hash
+ blockExists=(has-block "label")
+ argName="label"
+ arg=@label
+ isRequired=true
+ )
+ )
+ }}
+
+ {{#if (has-block "label")}}
+ {{yield to="label"}}
+ {{else}}
+ {{@label}}
+ {{/if}}
+
+ {{#if this.isReadOnlyOrDisabled}}
+
+ {{/if}}
+
+ {{/if}}
+
+ {{#if
+ (this.assertBlockOrArgumentExists
+ (hash blockExists=(has-block "hint") argName="hint" arg=@hint)
+ )
+ }}
+
+ {{#if (has-block "hint")}}
+ {{yield to="hint"}}
+ {{else}}
+ {{@hint}}
+ {{/if}}
+
+ {{/if}}
+
+
+ {{yield select}}
+
+
+
+ {{#if @error}}
+
+ {{/if}}
+
+
\ No newline at end of file
diff --git a/packages/ember-toucan-core/src/components/form/fields/combobox.ts b/packages/ember-toucan-core/src/components/form/fields/combobox.ts
new file mode 100644
index 00000000..e3bb1363
--- /dev/null
+++ b/packages/ember-toucan-core/src/components/form/fields/combobox.ts
@@ -0,0 +1,123 @@
+import Component from '@glimmer/component';
+
+import assertBlockOrArgumentExists from '../../../-private/assert-block-or-argument-exists';
+import LockIcon from '../../../-private/icons/lock';
+
+import type { AssertBlockOrArg } from '../../../-private/assert-block-or-argument-exists';
+import type { ErrorMessage } from '../../../-private/types';
+import type {
+ Option as ControlOption,
+ ToucanFormComboboxControlComponentSignature,
+} from '../controls/combobox';
+
+export type Option = ControlOption;
+
+export interface ToucanFormComboboxFieldComponentSignature<
+ OPTION extends ControlOption
+> {
+ Element: HTMLInputElement;
+ Args: {
+ /**
+ * A CSS class to add to this component's content container.
+ * Commonly used to specify a `z-index`.
+ */
+ contentClass?: string;
+
+ /**
+ * Provide a string or array of strings to this argument to render an error message and apply error styling to the Field.
+ */
+ error?: ErrorMessage;
+
+ /**
+ * Provide a string to this argument to render a hint message to help describe the control.
+ */
+ hint?: string;
+
+ /**
+ * Sets the disabled attribute on the input.
+ */
+ isDisabled?: boolean;
+
+ /**
+ * Sets the readonly attribute of the input.
+ */
+ isReadOnly?: boolean;
+
+ /**
+ * Provide a string to this argument to render inside of the label tag.
+ */
+ label?: string;
+
+ /**
+ * A string to display when there are no results after the user's filter.
+ */
+ noResultsText?: string;
+
+ /**
+ * The function called when a new selection is made.
+ */
+ onChange?: ToucanFormComboboxControlComponentSignature['Args']['onChange'];
+
+ /**
+ * The function called when a user types into the combobox textbox.
+ *
+ * Typically used for making a request to the server and populating
+ * `@options` with the results.
+ */
+ onFilter?: ToucanFormComboboxControlComponentSignature ['Args']['onFilter'];
+
+ /**
+ * When `@options` is an array of objects, `@selected` is also an object.
+ * The `@optionKey` is used to determine which key of the object should
+ * be used for both filtering and displayed the selected value in the
+ * textbox.
+ */
+ optionKey?: ToucanFormComboboxControlComponentSignature ['Args']['optionKey'];
+
+ /**
+ * `@options` forms the content of this component.
+ *
+ * To support a variety of data shapes, `@options` is typed as `unknown[]` and treated as though it were opaque.
+ * `@options` is simply iterated over then passed back to you as a block parameter (`select.option`).
+ */
+ options?: ToucanFormComboboxControlComponentSignature ['Args']['options'];
+
+ /**
+ * A test selector for targeting the root element of the field.
+ * In this case, the wrapping div element.
+ */
+ rootTestSelector?: string;
+
+ /**
+ * The currently selected option. If `@options` is an array of strings, provide a string. If `@options` is an array of objects, pass the entire object and use `@optionKey`.
+ */
+ selected?: ToucanFormComboboxControlComponentSignature ['Args']['selected'];
+ };
+ Blocks: {
+ default: ToucanFormComboboxControlComponentSignature ['Blocks']['default'];
+ label: [];
+ hint: [];
+ };
+}
+
+export default class ToucanFormComboboxFieldComponent<
+ OPTION extends ControlOption
+> extends Component> {
+ LockIcon = LockIcon;
+
+ assertBlockOrArgumentExists = ({
+ blockExists,
+ argName,
+ arg,
+ isRequired,
+ }: AssertBlockOrArg) =>
+ assertBlockOrArgumentExists({ blockExists, argName, arg, isRequired });
+
+ get hasError() {
+ return Boolean(this.args?.error);
+ }
+
+ get isReadOnlyOrDisabled() {
+ return this.args?.isDisabled || this.args?.isReadOnly;
+ }
+}
diff --git a/packages/ember-toucan-core/src/components/form/fields/file-input.ts b/packages/ember-toucan-core/src/components/form/fields/file-input.ts
index a011d4d1..d48ce40e 100644
--- a/packages/ember-toucan-core/src/components/form/fields/file-input.ts
+++ b/packages/ember-toucan-core/src/components/form/fields/file-input.ts
@@ -3,7 +3,7 @@ import { assert } from '@ember/debug';
import { action } from '@ember/object';
import assertBlockOrArgumentExists from '../../../-private/assert-block-or-argument-exists';
-import LockIcon from '../../../-private/components/lock-icon';
+import LockIcon from '../../../-private/icons/lock';
import type { AssertBlockOrArg } from '../../../-private/assert-block-or-argument-exists';
import type { ErrorMessage } from '../../../-private/types';
diff --git a/packages/ember-toucan-core/src/components/form/fields/input.ts b/packages/ember-toucan-core/src/components/form/fields/input.ts
index 334f2179..c77680c9 100644
--- a/packages/ember-toucan-core/src/components/form/fields/input.ts
+++ b/packages/ember-toucan-core/src/components/form/fields/input.ts
@@ -4,7 +4,7 @@ import { assert } from '@ember/debug';
import { action } from '@ember/object';
import assertBlockOrArgumentExists from '../../../-private/assert-block-or-argument-exists';
-import LockIcon from '../../../-private/components/lock-icon';
+import LockIcon from '../../../-private/icons/lock';
import CharacterCount from '../../../components/form/controls/character-count';
import type { AssertBlockOrArg } from '../../../-private/assert-block-or-argument-exists';
diff --git a/packages/ember-toucan-core/src/components/form/fields/radio-group.ts b/packages/ember-toucan-core/src/components/form/fields/radio-group.ts
index 829d7a07..3d24866a 100644
--- a/packages/ember-toucan-core/src/components/form/fields/radio-group.ts
+++ b/packages/ember-toucan-core/src/components/form/fields/radio-group.ts
@@ -2,7 +2,7 @@ import Component from '@glimmer/component';
import { action } from '@ember/object';
import assertBlockOrArgumentExists from '../../../-private/assert-block-or-argument-exists';
-import LockIcon from '../../../-private/components/lock-icon';
+import LockIcon from '../../../-private/icons/lock';
import RadioFieldComponent from './radio';
import type { AssertBlockOrArg } from '../../../-private/assert-block-or-argument-exists';
diff --git a/packages/ember-toucan-core/src/components/form/fields/textarea.ts b/packages/ember-toucan-core/src/components/form/fields/textarea.ts
index 6f91eebd..efffb641 100644
--- a/packages/ember-toucan-core/src/components/form/fields/textarea.ts
+++ b/packages/ember-toucan-core/src/components/form/fields/textarea.ts
@@ -4,7 +4,7 @@ import { assert } from '@ember/debug';
import { action } from '@ember/object';
import assertBlockOrArgumentExists from '../../../-private/assert-block-or-argument-exists';
-import LockIcon from '../../../-private/components/lock-icon';
+import LockIcon from '../../../-private/icons/lock';
import CharacterCount from '../../../components/form/controls/character-count';
import type { AssertBlockOrArg } from '../../../-private/assert-block-or-argument-exists';
diff --git a/packages/ember-toucan-core/src/template-registry.ts b/packages/ember-toucan-core/src/template-registry.ts
index 24694ea4..467b703f 100644
--- a/packages/ember-toucan-core/src/template-registry.ts
+++ b/packages/ember-toucan-core/src/template-registry.ts
@@ -1,6 +1,7 @@
import type ButtonComponent from './components/button';
import type FormControlsCharacterCount from './components/form/controls/character-count';
import type CheckboxControlComponent from './components/form/controls/checkbox';
+import type ComboboxControlComponent from './components/form/controls/combobox';
import type FormControlsFileInputComponent from './components/form/controls/file-input';
import type InputControlComponent from './components/form/controls/input';
import type RadioControlComponent from './components/form/controls/radio';
@@ -8,6 +9,7 @@ import type TextareaControlComponent from './components/form/controls/textarea';
import type FieldComponent from './components/form/field';
import type CheckboxFieldComponent from './components/form/fields/checkbox';
import type CheckboxGroupFieldComponent from './components/form/fields/checkbox-group';
+import type ComboboxFieldComponent from './components/form/fields/combobox';
import type FormFileInputFieldComponent from './components/form/fields/file-input';
import type InputFieldComponent from './components/form/fields/input';
import type RadioFieldComponent from './components/form/fields/radio';
@@ -21,8 +23,10 @@ export default interface Registry {
'Form::Field': typeof FieldComponent;
'Form::Fields::Checkbox': typeof CheckboxFieldComponent;
'Form::Fields::CheckboxGroup': typeof CheckboxGroupFieldComponent;
+ 'Form::Fields::Combobox': typeof ComboboxFieldComponent;
'Form::Fields::Input': typeof InputFieldComponent;
'Form::Controls::Checkbox': typeof CheckboxControlComponent;
+ 'Form::Controls::Combobox': typeof ComboboxControlComponent;
'Form::Controls::FileInput': typeof FormControlsFileInputComponent;
'Form::Controls::Input': typeof InputControlComponent;
'Form::Controls::Radio': typeof RadioControlComponent;
diff --git a/packages/ember-toucan-core/unpublished-development-types/index.d.ts b/packages/ember-toucan-core/unpublished-development-types/index.d.ts
index 3af066b6..eb266d25 100644
--- a/packages/ember-toucan-core/unpublished-development-types/index.d.ts
+++ b/packages/ember-toucan-core/unpublished-development-types/index.d.ts
@@ -9,6 +9,7 @@ import { ComponentLike } from '@glint/template';
import type ToucanCoreRegistry from '../src/template-registry';
import type TemplateRegistry from '@glint/environment-ember-loose/registry';
+import type EmberVelcroRegistry from 'ember-velcro/template-registry';
// importing this directly from the published types (https://github.com/embroider-build/embroider/blob/main/packages/util/index.d.ts) does not work,
// see point 3 in Dan's comment here: https://github.com/typed-ember/glint/issues/518#issuecomment-1400306133
@@ -27,7 +28,9 @@ declare class EnsureSafeComponentHelper<
}> {}
declare module '@glint/environment-ember-loose/registry' {
- export default interface Registry extends ToucanCoreRegistry {
+ export default interface Registry
+ extends ToucanCoreRegistry,
+ EmberVelcroRegistry {
// local entries
'ensure-safe-component': typeof EnsureSafeComponentHelper;
diff --git a/packages/ember-toucan-form/src/-private/combobox-field.hbs b/packages/ember-toucan-form/src/-private/combobox-field.hbs
new file mode 100644
index 00000000..578afb96
--- /dev/null
+++ b/packages/ember-toucan-form/src/-private/combobox-field.hbs
@@ -0,0 +1,118 @@
+{{!
+ Regarding Conditionals
+
+ This looks really messy, but Form::Fields::Combobox exposes named blocks; HOWEVER,
+ we cannot conditionally render named blocks due to https://github.com/emberjs/rfcs/issues/735.
+
+ We *can* conditionally render components though, based on the blocks and argument combinations
+ users provide us. This is very brittle, but until https://github.com/emberjs/rfcs/issues/735
+ is resolved and a solution is found, this appears to be the only way to truly expose
+ conditional named blocks.
+
+ ---
+
+ Regarding glint-expect-error
+
+ "@onChange" of the component expects a string or object typed value, but field.setValue is generic,
+ accepting anything that DATA[KEY] could be. Similar case with "@selected", but there casting is easy.
+}}
+<@form.Field @name={{@name}} as |field|>
+ {{#if (this.hasOnlyLabelBlock (has-block 'label') (has-block 'hint'))}}
+
+ <:label>{{yield to='label'}}
+ <:default as |select|>
+ {{yield (hash Option=select.Option option=select.option)}}
+
+
+ {{else if (this.hasHintAndLabelBlocks (has-block 'label') (has-block 'hint'))
+ }}
+
+ <:label>{{yield to='label'}}
+ <:hint>{{yield to='hint'}}
+ <:default as |select|>
+ {{yield (hash Option=select.Option option=select.option)}}
+
+
+ {{else if (this.hasLabelArgAndHintBlock @label (has-block 'hint'))}}
+
+ <:hint>{{yield to='hint'}}
+ <:default as |select|>
+ {{yield (hash Option=select.Option option=select.option)}}
+
+
+ {{else}}
+ {{! Argument-only case }}
+
+ {{yield (hash Option=select.Option option=select.option)}}
+
+ {{/if}}
+@form.Field>
\ No newline at end of file
diff --git a/packages/ember-toucan-form/src/-private/combobox-field.ts b/packages/ember-toucan-form/src/-private/combobox-field.ts
new file mode 100644
index 00000000..4d6bbde9
--- /dev/null
+++ b/packages/ember-toucan-form/src/-private/combobox-field.ts
@@ -0,0 +1,72 @@
+import Component from '@glimmer/component';
+import { assert } from '@ember/debug';
+import { action } from '@ember/object';
+
+import type { HeadlessFormBlock, UserData } from './types';
+import type {
+ Option,
+ ToucanFormComboboxFieldComponentSignature as BaseComboboxFieldSignature,
+} from '@crowdstrike/ember-toucan-core/components/form/fields/combobox';
+import type { FormData, FormKey, ValidationError } from 'ember-headless-form';
+
+export interface ToucanFormComboboxFieldComponentSignature<
+ DATA extends UserData,
+ KEY extends FormKey> = FormKey>
+> {
+ Element: HTMLInputElement;
+ Args: Omit<
+ BaseComboboxFieldSignature['Args'],
+ 'error' | 'onChange'
+ > & {
+ /**
+ * The name of your field, which must match a property of the `@data` passed to the form
+ */
+ name: KEY;
+
+ /*
+ * @internal
+ */
+ form: HeadlessFormBlock;
+ };
+ // TODO: How do we get this to play nicely with our
+ // generic in toucan-core?
+ // `BaseComboboxFieldSignature['Blocks'];`
+ // gives a glint error!
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ Blocks: BaseComboboxFieldSignature['Blocks'];
+}
+
+export default class ToucanFormComboboxFieldComponent<
+ DATA extends UserData,
+ KEY extends FormKey> = FormKey>
+> extends Component> {
+ hasOnlyLabelBlock = (hasLabel: boolean, hasHint: boolean) =>
+ hasLabel && !hasHint;
+ hasHintAndLabelBlocks = (hasLabel: boolean, hasHint: boolean) =>
+ hasLabel && hasHint;
+ hasLabelArgAndHintBlock = (hasLabel: string | undefined, hasHint: boolean) =>
+ hasLabel && hasHint;
+
+ mapErrors = (errors?: ValidationError[]) => {
+ if (!errors) {
+ return;
+ }
+
+ // @todo we need to figure out what to do when message is undefined
+ return errors.map((error) => error.message ?? error.type);
+ };
+
+ @action
+ assertSelected(value: unknown): Option {
+ assert(
+ `Only string or object values are expected for ${String(
+ this.args.name
+ )}, but you passed ${typeof value}`,
+ typeof value === 'undefined' ||
+ typeof value === 'string' ||
+ typeof value === 'object'
+ );
+
+ return value as string | Record;
+ }
+}
diff --git a/packages/ember-toucan-form/src/components/toucan-form.hbs b/packages/ember-toucan-form/src/components/toucan-form.hbs
index 83ee07cc..0d9b4fd0 100644
--- a/packages/ember-toucan-form/src/components/toucan-form.hbs
+++ b/packages/ember-toucan-form/src/components/toucan-form.hbs
@@ -17,6 +17,9 @@
CheckboxGroup=(component
(ensure-safe-component this.CheckboxGroupComponent) form=form
)
+ Combobox=(component
+ (ensure-safe-component this.ComboboxFieldComponent) form=form
+ )
Field=form.Field
FileInput=(component
(ensure-safe-component this.FileInputFieldComponent) form=form
diff --git a/packages/ember-toucan-form/src/components/toucan-form.ts b/packages/ember-toucan-form/src/components/toucan-form.ts
index 3a9e64cf..ad1f5da0 100644
--- a/packages/ember-toucan-form/src/components/toucan-form.ts
+++ b/packages/ember-toucan-form/src/components/toucan-form.ts
@@ -2,6 +2,7 @@ import Component from '@glimmer/component';
import CheckboxFieldComponent from '../-private/checkbox-field';
import CheckboxGroupFieldComponent from '../-private/checkbox-group-field';
+import ComboboxFieldComponent from '../-private/combobox-field';
import FileInputFieldComponent from '../-private/file-input-field';
import InputFieldComponent from '../-private/input-field';
import RadioGroupFieldComponent from '../-private/radio-group-field';
@@ -30,6 +31,7 @@ export interface ToucanFormComponentSignature<
typeof CheckboxGroupFieldComponent,
'form'
>;
+ Combobox: WithBoundArgs, 'form'>;
Field: HeadlessFormBlock['Field'];
FileInput: WithBoundArgs, 'form'>;
Input: WithBoundArgs, 'form'>;
@@ -68,6 +70,7 @@ export default class ToucanFormComponent<
FileInputFieldComponent = FileInputFieldComponent;
InputFieldComponent = InputFieldComponent;
RadioGroupFieldComponent = RadioGroupFieldComponent;
+ ComboboxFieldComponent = ComboboxFieldComponent;
TextareaFieldComponent = TextareaFieldComponent;
get validateOn() {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 62819ed7..db504949 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -33,7 +33,7 @@ importers:
version: 1.1.3(@babel/core@7.20.12)(@crowdstrike/tailwind-toucan-base@3.5.0)(@docfy/core@0.6.0)(@docfy/ember@0.6.0)(@glimmer/component@1.1.2)(@glint/environment-ember-loose@1.0.2)(@glint/template@1.0.2)(@tailwindcss/typography@0.5.9)(ember-source@5.1.0)(highlight.js@11.7.0)(highlightjs-glimmer@2.0.1)
'@crowdstrike/ember-toucan-core':
specifier: workspace:*
- version: file:packages/ember-toucan-core(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@3.2.4)
+ version: file:packages/ember-toucan-core(@babel/core@7.20.12)(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@3.2.4)
'@crowdstrike/ember-toucan-form':
specifier: workspace:*
version: file:packages/ember-toucan-form(@crowdstrike/ember-toucan-core@0.2.1)(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(ember-headless-form@1.0.0-beta.3)(ember-source@5.1.0)(postcss@8.4.21)(tailwindcss@3.2.4)
@@ -55,6 +55,9 @@ importers:
ember-resources:
specifier: ^6.0.0
version: 6.0.0(@ember/test-waiters@3.0.2)(@glimmer/component@1.1.2)(@glimmer/tracking@1.1.2)(@glint/template@1.0.2)(ember-source@5.1.0)
+ ember-velcro:
+ specifier: ^2.1.0
+ version: 2.1.0(@babel/core@7.20.12)(ember-source@5.1.0)
highlight.js:
specifier: ^11.6.0
version: 11.7.0
@@ -355,6 +358,12 @@ importers:
'@embroider/addon-shim':
specifier: ^1.0.0
version: 1.8.4
+ '@floating-ui/dom':
+ specifier: ^1.4.2
+ version: 1.4.4
+ ember-velcro:
+ specifier: ^2.1.0
+ version: 2.1.0(@babel/core@7.20.12)(ember-source@5.1.0)
devDependencies:
'@babel/core':
specifier: ^7.17.0
@@ -557,7 +566,7 @@ importers:
version: 7.18.6(@babel/core@7.21.4)
'@crowdstrike/ember-toucan-core':
specifier: workspace:*
- version: file:packages/ember-toucan-core(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@2.2.19)
+ version: file:packages/ember-toucan-core(@babel/core@7.21.4)(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@2.2.19)
'@crowdstrike/ember-toucan-styles':
specifier: ^2.0.1
version: 2.0.1(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(postcss@8.4.21)(tailwindcss@2.2.19)
@@ -728,7 +737,7 @@ importers:
version: 7.19.1(@babel/core@7.20.12)(eslint@8.33.0)
'@crowdstrike/ember-toucan-core':
specifier: workspace:*
- version: file:packages/ember-toucan-core(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@2.2.19)
+ version: file:packages/ember-toucan-core(@babel/core@7.20.12)(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@2.2.19)
'@crowdstrike/ember-toucan-form':
specifier: workspace:*
version: file:packages/ember-toucan-form(@crowdstrike/ember-toucan-core@0.2.1)(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(ember-headless-form@1.0.0-beta.3)(ember-source@5.1.0)(postcss@8.4.21)(tailwindcss@2.2.19)
@@ -921,6 +930,9 @@ importers:
ember-try:
specifier: ^2.0.0
version: 2.0.0
+ ember-velcro:
+ specifier: ^2.1.0
+ version: 2.1.0(@babel/core@7.20.12)(ember-source@5.1.0)
eslint:
specifier: ^8.32.0
version: 8.33.0
@@ -3884,6 +3896,56 @@ packages:
- webpack
dev: true
+ /@embroider/compat@2.1.1:
+ resolution: {integrity: sha512-HNq5vv7NpQ1Jr+4slzmLBqsy5NDsIHilYeQiWboMrPAyHr5NHlKYWciIcmxdgPgz2kf/8D5nDiANgJznZedlyw==}
+ engines: {node: 12.* || 14.* || >= 16}
+ hasBin: true
+ peerDependencies:
+ '@embroider/core': ^2.0.0
+ dependencies:
+ '@babel/code-frame': 7.18.6
+ '@babel/core': 7.20.12
+ '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.20.12)
+ '@babel/preset-env': 7.20.2(@babel/core@7.20.12)
+ '@babel/traverse': 7.20.13
+ '@embroider/macros': 1.10.0
+ '@types/babel__code-frame': 7.0.3
+ '@types/yargs': 17.0.22
+ assert-never: 1.2.1
+ babel-plugin-ember-template-compilation: 2.0.0
+ babel-plugin-syntax-dynamic-import: 6.18.0
+ babylon: 6.18.0
+ bind-decorator: 1.0.11
+ broccoli: 3.5.2
+ broccoli-concat: 4.2.5
+ broccoli-file-creator: 2.1.1
+ broccoli-funnel: 3.0.8
+ broccoli-merge-trees: 4.2.0
+ broccoli-persistent-filter: 3.1.3
+ broccoli-plugin: 4.0.7
+ broccoli-source: 3.0.1
+ chalk: 4.1.2
+ debug: 4.3.4
+ fs-extra: 9.1.0
+ fs-tree-diff: 2.0.1
+ jsdom: 16.7.0
+ lodash: 4.17.21
+ pkg-up: 3.1.0
+ resolve: 1.22.1
+ resolve-package-path: 4.0.3
+ semver: 7.3.8
+ symlink-or-copy: 1.3.1
+ tree-sync: 2.1.0
+ typescript-memoize: 1.1.1
+ walk-sync: 3.0.0
+ yargs: 17.6.2
+ transitivePeerDependencies:
+ - bufferutil
+ - canvas
+ - supports-color
+ - utf-8-validate
+ dev: true
+
/@embroider/compat@2.1.1(@embroider/core@2.1.1):
resolution: {integrity: sha512-HNq5vv7NpQ1Jr+4slzmLBqsy5NDsIHilYeQiWboMrPAyHr5NHlKYWciIcmxdgPgz2kf/8D5nDiANgJznZedlyw==}
engines: {node: 12.* || 14.* || >= 16}
@@ -4132,6 +4194,14 @@ packages:
- supports-color
dev: true
+ /@floating-ui/core@1.3.1:
+ resolution: {integrity: sha512-Bu+AMaXNjrpjh41znzHqaz3r2Nr8hHuHZT6V2LBKMhyMl0FgKA62PNYbqnfgmzOhoWZj70Zecisbo4H1rotP5g==}
+
+ /@floating-ui/dom@1.4.4:
+ resolution: {integrity: sha512-21hhDEPOiWkGp0Ys4Wi6Neriah7HweToKra626CIK712B5m9qkdz54OP9gVldUg+URnBTpv/j/bi/skmGdstXQ==}
+ dependencies:
+ '@floating-ui/core': 1.3.1
+
/@glimmer/compiler@0.84.2:
resolution: {integrity: sha512-rU8qpqbqxIPwrEQH82yDDFi1hgv6ud1agYexmnmCXlaLS5uCVATJAqKsVozc7aHOgmmF4Ukd/LoF4NYfGr8X3w==}
dependencies:
@@ -6842,7 +6912,6 @@ packages:
loader-utils: 2.0.4
make-dir: 3.1.0
schema-utils: 2.7.1
- dev: true
/babel-loader@8.3.0(@babel/core@7.21.4)(webpack@5.75.0):
resolution: {integrity: sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q==}
@@ -8957,7 +9026,6 @@ packages:
postcss-value-parser: 4.2.0
schema-utils: 3.1.1
semver: 7.5.1
- dev: true
/css-loader@5.2.7(webpack@5.75.0):
resolution: {integrity: sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==}
@@ -9600,7 +9668,6 @@ packages:
transitivePeerDependencies:
- supports-color
- webpack
- dev: true
/ember-auto-import@2.6.3(webpack@5.75.0):
resolution: {integrity: sha512-uLhrRDJYWCRvQ4JQ1e64XlSrqAKSd6PXaJ9ZsZI6Tlms9T4DtQFxNXasqji2ZRJBVrxEoLCRYX3RTldsQ0vNGQ==}
@@ -10233,6 +10300,19 @@ packages:
- supports-color
dev: true
+ /ember-functions-as-helper-polyfill@2.1.1(ember-source@5.1.0):
+ resolution: {integrity: sha512-vZ2w9G/foohwtPm99Jos1m6bhlXyyyiJ4vhLbxyjWB4wh7bcpRzXPgCewDRrwefZQ2BwtHg3c9zvVMlI0g+o2Q==}
+ engines: {node: '>= 14.0.0'}
+ peerDependencies:
+ ember-source: ^3.25.0 || ^4.0.0
+ dependencies:
+ ember-cli-babel: 7.26.11
+ ember-cli-typescript: 5.2.1
+ ember-cli-version-checker: 5.1.2
+ ember-source: 5.1.0(@babel/core@7.20.12)(@glimmer/component@1.1.2)
+ transitivePeerDependencies:
+ - supports-color
+
/ember-get-config@1.1.0:
resolution: {integrity: sha512-diD+HwwY8QqpEk5DnDYfH7onYwl6NOgr1qv1ENbXih+/iiWYUVS/e0S/PlM7A4gdorD9spn1bnisnTLTf49Wpw==}
engines: {node: 12.* || 14.* || >= 16}
@@ -10290,6 +10370,33 @@ packages:
- supports-color
dev: true
+ /ember-modifier@3.2.7(@babel/core@7.20.12):
+ resolution: {integrity: sha512-ezcPQhH8jUfcJQbbHji4/ZG/h0yyj1jRDknfYue/ypQS8fM8LrGcCMo0rjDZLzL1Vd11InjNs3BD7BdxFlzGoA==}
+ engines: {node: 12.* || >= 14}
+ dependencies:
+ ember-cli-babel: 7.26.11
+ ember-cli-normalize-entity-name: 1.0.0
+ ember-cli-string-utils: 1.1.0
+ ember-cli-typescript: 5.2.1
+ ember-compatibility-helpers: 1.2.6(@babel/core@7.20.12)
+ transitivePeerDependencies:
+ - '@babel/core'
+ - supports-color
+
+ /ember-modifier@3.2.7(@babel/core@7.21.4):
+ resolution: {integrity: sha512-ezcPQhH8jUfcJQbbHji4/ZG/h0yyj1jRDknfYue/ypQS8fM8LrGcCMo0rjDZLzL1Vd11InjNs3BD7BdxFlzGoA==}
+ engines: {node: 12.* || >= 14}
+ dependencies:
+ ember-cli-babel: 7.26.11
+ ember-cli-normalize-entity-name: 1.0.0
+ ember-cli-string-utils: 1.1.0
+ ember-cli-typescript: 5.2.1
+ ember-compatibility-helpers: 1.2.6(@babel/core@7.21.4)
+ transitivePeerDependencies:
+ - '@babel/core'
+ - supports-color
+ dev: true
+
/ember-modifier@4.1.0(ember-source@5.1.0):
resolution: {integrity: sha512-YFCNpEYj6jdyy3EjslRb2ehNiDvaOrXTilR9+ngq+iUqSHYto2zKV0rleiA1XJQ27ELM1q8RihT29U6Lq5EyqQ==}
peerDependencies:
@@ -10474,7 +10581,6 @@ packages:
- rsvp
- supports-color
- webpack
- dev: true
/ember-source@5.1.0(@babel/core@7.20.12)(@glimmer/component@1.1.2)(webpack@5.75.0):
resolution: {integrity: sha512-atUeliA3TGyL8LB8EYIouvJukLtlbqFdtNT83Lst9TEtKtqnoyGJhr/5C/C+AxOX7r5s5Lo5cBBNBQadJgNFNg==}
@@ -10745,6 +10851,33 @@ packages:
- supports-color
dev: true
+ /ember-velcro@2.1.0(@babel/core@7.20.12)(ember-source@5.1.0):
+ resolution: {integrity: sha512-5m1JDAGmTT9J8SrVL1NrBj6qe1L0dgb3T1muWL2b36HO4sCHn8/njT/GMHXorcbYz7C/PawYuxKpbILNaGa50A==}
+ engines: {node: 14.* || >= 16}
+ dependencies:
+ '@embroider/addon-shim': 1.8.4
+ '@floating-ui/dom': 1.4.4
+ ember-functions-as-helper-polyfill: 2.1.1(ember-source@5.1.0)
+ ember-modifier: 3.2.7(@babel/core@7.20.12)
+ transitivePeerDependencies:
+ - '@babel/core'
+ - ember-source
+ - supports-color
+
+ /ember-velcro@2.1.0(@babel/core@7.21.4)(ember-source@5.1.0):
+ resolution: {integrity: sha512-5m1JDAGmTT9J8SrVL1NrBj6qe1L0dgb3T1muWL2b36HO4sCHn8/njT/GMHXorcbYz7C/PawYuxKpbILNaGa50A==}
+ engines: {node: 14.* || >= 16}
+ dependencies:
+ '@embroider/addon-shim': 1.8.4
+ '@floating-ui/dom': 1.4.4
+ ember-functions-as-helper-polyfill: 2.1.1(ember-source@5.1.0)
+ ember-modifier: 3.2.7(@babel/core@7.21.4)
+ transitivePeerDependencies:
+ - '@babel/core'
+ - ember-source
+ - supports-color
+ dev: true
+
/ember-window-mock@0.8.1:
resolution: {integrity: sha512-wl9TJuBYFWKsPqDY2gms2jbre1L39AkrPQ9EqbhqHbZI4aEq8u8IZJ0nJaOa7IVr/Jy/kSUXYQGTgvNhz1AzPw==}
engines: {node: 12.* || 14.* || >= 16}
@@ -14375,7 +14508,6 @@ packages:
optional: true
dependencies:
schema-utils: 4.0.0
- dev: true
/mini-css-extract-plugin@2.7.2(webpack@5.75.0):
resolution: {integrity: sha512-EdlUizq13o0Pd+uCp+WO/JpkLvHRVGt97RqfeGhXqAcorYo1ypJSpkV+WDT0vY/kmh/p7wRdJNJtuyK540PXDw==}
@@ -17373,7 +17505,6 @@ packages:
dependencies:
loader-utils: 2.0.4
schema-utils: 3.1.1
- dev: true
/style-loader@2.0.0(webpack@5.75.0):
resolution: {integrity: sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==}
@@ -18909,7 +19040,7 @@ packages:
/zwitch@1.0.5:
resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}
- file:packages/ember-toucan-core(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@2.2.19):
+ file:packages/ember-toucan-core(@babel/core@7.20.12)(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@2.2.19):
resolution: {directory: packages/ember-toucan-core, type: directory}
id: file:packages/ember-toucan-core
name: '@crowdstrike/ember-toucan-core'
@@ -18926,19 +19057,23 @@ packages:
dependencies:
'@babel/runtime': 7.21.5
'@crowdstrike/ember-toucan-styles': 2.0.1(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(postcss@8.4.21)(tailwindcss@2.2.19)
- '@ember/test-helpers': 3.1.0(ember-source@5.1.0)
+ '@ember/test-helpers': 3.1.0(ember-source@5.1.0)(webpack@5.75.0)
'@embroider/addon-shim': 1.8.4
+ '@embroider/compat': 2.1.1
+ '@floating-ui/dom': 1.4.4
'@glimmer/tracking': 1.1.2
autoprefixer: 10.4.13(postcss@8.4.21)
- ember-source: 5.1.0(@babel/core@7.21.4)(@glimmer/component@1.1.2)
+ ember-source: 5.1.0(@babel/core@7.20.12)(@glimmer/component@1.1.2)(webpack@5.75.0)
+ ember-velcro: 2.1.0(@babel/core@7.20.12)(ember-source@5.1.0)
fractal-page-object: 0.4.0
postcss: 8.4.21
tailwindcss: 2.2.19(autoprefixer@10.4.13)(postcss@8.4.21)
transitivePeerDependencies:
+ - '@babel/core'
- supports-color
dev: true
- file:packages/ember-toucan-core(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@3.2.4):
+ file:packages/ember-toucan-core(@babel/core@7.20.12)(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@3.2.4):
resolution: {directory: packages/ember-toucan-core, type: directory}
id: file:packages/ember-toucan-core
name: '@crowdstrike/ember-toucan-core'
@@ -18957,16 +19092,52 @@ packages:
'@crowdstrike/ember-toucan-styles': 2.0.1(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(postcss@8.4.21)(tailwindcss@3.2.4)
'@ember/test-helpers': 3.1.0(ember-source@5.1.0)(webpack@5.75.0)
'@embroider/addon-shim': 1.8.4
+ '@floating-ui/dom': 1.4.4
'@glimmer/tracking': 1.1.2
autoprefixer: 10.4.13(postcss@8.4.21)
ember-source: 5.1.0(@babel/core@7.20.12)(@glimmer/component@1.1.2)(webpack@5.75.0)
+ ember-velcro: 2.1.0(@babel/core@7.20.12)(ember-source@5.1.0)
fractal-page-object: 0.4.0
postcss: 8.4.21
tailwindcss: 3.2.4(postcss@8.4.21)
transitivePeerDependencies:
+ - '@babel/core'
- supports-color
dev: false
+ file:packages/ember-toucan-core(@babel/core@7.21.4)(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@2.2.19):
+ resolution: {directory: packages/ember-toucan-core, type: directory}
+ id: file:packages/ember-toucan-core
+ name: '@crowdstrike/ember-toucan-core'
+ version: 0.2.1
+ peerDependencies:
+ '@crowdstrike/ember-toucan-styles': ^2.0.1
+ '@ember/test-helpers': ^2.8.1 || ^3.0.0
+ '@glimmer/tracking': ^1.1.2
+ autoprefixer: ^10.0.2
+ ember-source: '>=4.8.0'
+ fractal-page-object: ^0.3.0
+ postcss: ^8.2.14
+ tailwindcss: ^2.2.15 || ^3.0.0
+ dependencies:
+ '@babel/runtime': 7.21.5
+ '@crowdstrike/ember-toucan-styles': 2.0.1(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(postcss@8.4.21)(tailwindcss@2.2.19)
+ '@ember/test-helpers': 3.1.0(ember-source@5.1.0)
+ '@embroider/addon-shim': 1.8.4
+ '@embroider/compat': 2.1.1
+ '@floating-ui/dom': 1.4.4
+ '@glimmer/tracking': 1.1.2
+ autoprefixer: 10.4.13(postcss@8.4.21)
+ ember-source: 5.1.0(@babel/core@7.21.4)(@glimmer/component@1.1.2)
+ ember-velcro: 2.1.0(@babel/core@7.21.4)(ember-source@5.1.0)
+ fractal-page-object: 0.4.0
+ postcss: 8.4.21
+ tailwindcss: 2.2.19(autoprefixer@10.4.13)(postcss@8.4.21)
+ transitivePeerDependencies:
+ - '@babel/core'
+ - supports-color
+ dev: true
+
file:packages/ember-toucan-form(@crowdstrike/ember-toucan-core@0.2.1)(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(ember-headless-form@1.0.0-beta.3)(ember-source@5.1.0)(postcss@8.4.21)(tailwindcss@2.2.19):
resolution: {directory: packages/ember-toucan-form, type: directory}
id: file:packages/ember-toucan-form
@@ -18983,7 +19154,7 @@ packages:
tailwindcss: ^2.2.15 || ^3.0.0
dependencies:
'@babel/runtime': 7.21.5
- '@crowdstrike/ember-toucan-core': file:packages/ember-toucan-core(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@2.2.19)
+ '@crowdstrike/ember-toucan-core': file:packages/ember-toucan-core(@babel/core@7.20.12)(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@2.2.19)
'@crowdstrike/ember-toucan-styles': 2.0.1(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(postcss@8.4.21)(tailwindcss@2.2.19)
'@ember/test-helpers': 3.1.0(ember-source@5.1.0)(webpack@5.75.0)
'@embroider/addon-shim': 1.8.4
@@ -19013,7 +19184,7 @@ packages:
tailwindcss: ^2.2.15 || ^3.0.0
dependencies:
'@babel/runtime': 7.21.5
- '@crowdstrike/ember-toucan-core': file:packages/ember-toucan-core(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@3.2.4)
+ '@crowdstrike/ember-toucan-core': file:packages/ember-toucan-core(@babel/core@7.20.12)(@crowdstrike/ember-toucan-styles@2.0.1)(@ember/test-helpers@3.1.0)(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(fractal-page-object@0.4.0)(postcss@8.4.21)(tailwindcss@3.2.4)
'@crowdstrike/ember-toucan-styles': 2.0.1(@glimmer/tracking@1.1.2)(autoprefixer@10.4.13)(ember-source@5.1.0)(postcss@8.4.21)(tailwindcss@3.2.4)
'@ember/test-helpers': 3.1.0(ember-source@5.1.0)(webpack@5.75.0)
'@embroider/addon-shim': 1.8.4
diff --git a/test-app/package.json b/test-app/package.json
index e498ef08..d6fe1ec7 100644
--- a/test-app/package.json
+++ b/test-app/package.json
@@ -90,11 +90,12 @@
"ember-load-initializers": "^2.1.2",
"ember-qunit": "^7.0.0",
"ember-resolver": "^10.0.0",
- "ember-source": "~5.1.0",
"ember-source-channel-url": "^3.0.0",
+ "ember-source": "~5.1.0",
"ember-template-imports": "^3.1.2",
"ember-template-lint": "^5.8.0",
"ember-try": "^2.0.0",
+ "ember-velcro": "^2.1.0",
"eslint": "^8.32.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-ember": "^11.8.0",
diff --git a/test-app/tests/integration/components/combobox-field-test.gts b/test-app/tests/integration/components/combobox-field-test.gts
new file mode 100644
index 00000000..86c2d7eb
--- /dev/null
+++ b/test-app/tests/integration/components/combobox-field-test.gts
@@ -0,0 +1,247 @@
+import { click, fillIn, find, render, setupOnerror } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+
+import ComboboxField from '@crowdstrike/ember-toucan-core/components/form/fields/combobox';
+import { setupRenderingTest } from 'test-app/tests/helpers';
+
+module('Integration | Component | Fields | Combobox', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders', async function (assert) {
+ await render(
+
+ );
+
+ assert.dom('[data-label]').hasText('Label');
+
+ assert
+ .dom('[data-hint]')
+ .doesNotExist(
+ 'Expected hint block not to be displayed as a hint was not provided'
+ );
+
+ assert.dom('[data-combobox]').hasTagName('input');
+ assert.dom('[data-combobox]').hasAttribute('id');
+ assert.dom('[data-combobox]').hasClass('text-titles-and-attributes');
+
+ assert
+ .dom('[data-error]')
+ .doesNotExist(
+ 'Expected hint block not to be displayed as an error or @hint was not provided'
+ );
+
+ assert.dom('[data-lock-icon]').doesNotExist();
+ });
+
+ test('it renders with a hint', async function (assert) {
+ await render(
+
+ );
+
+ let hint = find('[data-hint]');
+
+ assert.dom(hint).hasText('Hint text');
+ assert.dom(hint).hasAttribute('id');
+
+ let hintId = hint?.getAttribute('id') || '';
+
+ assert.ok(hintId, 'Expected hintId to be truthy');
+
+ let describedby =
+ find('[data-combobox]')?.getAttribute('aria-describedby') || '';
+
+ assert.ok(
+ describedby.includes(hintId),
+ 'Expected hintId to be included in the aria-describedby'
+ );
+ });
+
+ test('it renders with a hint and label block', async function (assert) {
+ await render(
+
+ <:label>label block content
+ <:hint>hint block content
+
+ );
+
+ assert.dom('[data-hint]').hasText('hint block content');
+ assert.dom('[data-label]').hasText('label block content');
+ });
+
+ test('it renders with an error', async function (assert) {
+ await render(
+
+ );
+
+ let error = find('[data-error]');
+
+ assert.dom(error).hasText('Error text');
+ assert.dom(error).hasAttribute('id');
+
+ let errorId = error?.getAttribute('id') || '';
+
+ assert.ok(errorId, 'Expected errorId to be truthy');
+
+ let describedby =
+ find('[data-combobox]')?.getAttribute('aria-describedby') || '';
+
+ assert.ok(
+ describedby.includes(errorId),
+ 'Expected errorId to be included in the aria-describedby'
+ );
+
+ assert.dom('[data-combobox]').hasAttribute('aria-invalid', 'true');
+
+ assert.dom('[data-combobox]').hasClass('shadow-error-outline');
+ assert.dom('[data-combobox]').hasClass('focus:shadow-error-focus-outline');
+ assert.dom('[data-combobox]').doesNotHaveClass('shadow-focusable-outline');
+ });
+
+ test('it sets aria-describedby when both a hint and error are provided using the hint and errorIds', async function (assert) {
+ await render(
+
+ );
+
+ let errorId = find('[data-error]')?.getAttribute('id') || '';
+
+ assert.ok(errorId, 'Expected errorId to be truthy');
+
+ let hintId = find('[data-hint]')?.getAttribute('id') || '';
+
+ assert.ok(hintId, 'Expected hintId to be truthy');
+
+ assert
+ .dom('[data-combobox]')
+ .hasAttribute('aria-describedby', `${errorId} ${hintId}`);
+ });
+
+ test('it disables the input using `@isDisabled` and renders a lock icon', async function (assert) {
+ await render(
+
+ );
+
+ assert.dom('[data-combobox]').isDisabled();
+ assert.dom('[data-combobox]').hasClass('text-disabled');
+
+ assert.dom('[data-lock-icon]').exists();
+ });
+
+ test('it sets readonly on the input using `@isReadOnly` and renders a lock icon', async function (assert) {
+ await render(
+
+ );
+
+ assert.dom('[data-combobox]').hasAttribute('readonly');
+
+ assert.dom('[data-lock-icon]').exists();
+ });
+
+ test('it spreads attributes to the underlying input', async function (assert) {
+ await render(
+
+ );
+
+ assert
+ .dom('[data-combobox]')
+ .hasAttribute('placeholder', 'Placeholder text');
+ });
+
+ test('it sets the value attribute via `@selected`', async function (assert) {
+ await render(
+
+ );
+
+ assert.dom('[data-combobox]').hasValue('blue');
+ });
+
+ // NOTE: This functionality is deeply tested in the Control, as it can
+ // get pretty complex. This test is to ensure `@onChange` is generally
+ // working.
+ test('it calls `@onChange` when an option is selected', async function (assert) {
+ assert.expect(5);
+
+ let options = ['blue', 'red'];
+
+ let handleChange = (value: unknown) => {
+ assert.strictEqual(value, 'blue', 'Expected input to match');
+ assert.step('handleChange');
+ };
+
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ assert.verifySteps([]);
+
+ await fillIn('[data-combobox]', 'blue');
+
+ assert.dom('[role="option"]').exists({ count: 1 });
+
+ await click('[role="option"]');
+
+ assert.verifySteps(['handleChange']);
+ });
+
+ test('it applies the provided `@rootTestSelector` to the data-root-field attribute', async function (assert) {
+ await render(
+
+ );
+
+ assert.dom('[data-root-field="selector"]').exists();
+ });
+
+ test('it throws an assertion error if no `@label` is provided', async function (assert) {
+ assert.expect(1);
+
+ setupOnerror((e: Error) => {
+ assert.ok(
+ e.message.includes(
+ 'Assertion Failed: You need either :label or @label'
+ ),
+ 'Expected assertion error message'
+ );
+ });
+
+ await render( );
+ });
+
+ test('it throws an assertion error if a `@label` and :label are provided', async function (assert) {
+ assert.expect(1);
+
+ setupOnerror((e: Error) => {
+ assert.ok(
+ e.message.includes(
+ 'Assertion Failed: You can have :label or @label, but not both'
+ ),
+ 'Expected assertion error message'
+ );
+ });
+
+ await render(
+
+ <:label>Label
+
+ );
+ });
+});
diff --git a/test-app/tests/integration/components/combobox-test.gts b/test-app/tests/integration/components/combobox-test.gts
new file mode 100644
index 00000000..12d1841d
--- /dev/null
+++ b/test-app/tests/integration/components/combobox-test.gts
@@ -0,0 +1,881 @@
+import { click, fillIn, render, triggerKeyEvent } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+
+import ComboboxControl from '@crowdstrike/ember-toucan-core/components/form/controls/combobox';
+import { setupRenderingTest } from 'test-app/tests/helpers';
+
+let testColors = ['blue', 'red'];
+
+module('Integration | Component | Combobox', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders', async function (assert) {
+ await render( );
+
+ assert.dom('[data-combobox]').hasTagName('input');
+ assert.dom('[data-combobox]').hasClass('text-titles-and-attributes');
+ assert.dom('[data-combobox]').hasClass('shadow-focusable-outline');
+ assert.dom('[data-combobox]').doesNotHaveClass('text-disabled');
+ assert.dom('[data-combobox]').doesNotHaveClass('shadow-error-outline');
+ assert
+ .dom('[data-combobox]')
+ .doesNotHaveClass('focus:shadow-error-focus-outline');
+
+ assert.dom('[data-combobox]').hasAttribute('aria-autocomplete', 'list');
+ assert.dom('[data-combobox]').hasAttribute('aria-haspopup', 'listbox');
+ assert.dom('[data-combobox]').hasAttribute('autocapitalize', 'none');
+ assert.dom('[data-combobox]').hasAttribute('autocomplete', 'off');
+ assert.dom('[data-combobox]').hasAttribute('autocorrect', 'off');
+ assert.dom('[data-combobox]').hasAttribute('role', 'combobox');
+ assert.dom('[data-combobox]').hasAttribute('spellcheck', 'false');
+ assert.dom('[data-combobox]').hasAttribute('type', 'text');
+ });
+
+ test('it disables the combobox using `@isDisabled`', async function (assert) {
+ await render(
+
+ );
+
+ assert.dom('[data-combobox]').isDisabled();
+ assert.dom('[data-combobox]').hasClass('text-disabled');
+ assert
+ .dom('[data-combobox]')
+ .doesNotHaveClass('text-titles-and-attributes');
+ });
+
+ test('it sets readonly on the combobox using `@isReadOnly`', async function (assert) {
+ await render(
+
+ );
+
+ assert.dom('[data-combobox]').hasAttribute('readonly');
+
+ assert.dom('[data-combobox]').hasClass('shadow-read-only-outline');
+ assert.dom('[data-combobox]').hasClass('bg-surface-xl');
+ assert.dom('[data-combobox]').hasNoClass('bg-overlay-1');
+ assert.dom('[data-combobox]').hasNoClass('text-disabled');
+ assert.dom('[data-combobox]').hasNoClass('shadow-error-outline');
+ assert.dom('[data-combobox]').hasNoClass('shadow-focusable-outline');
+ });
+
+ test('it spreads attributes to the underlying combobox', async function (assert) {
+ await render(
+
+ );
+
+ assert
+ .dom('[data-combobox]')
+ .hasAttribute('placeholder', 'Placeholder text');
+ });
+
+ test('it applies the error shadow when `@hasError={{true}}`', async function (assert) {
+ await render(
+
+ );
+
+ assert.dom('[data-combobox]').hasClass('shadow-error-outline');
+ assert.dom('[data-combobox]').hasClass('focus:shadow-error-focus-outline');
+ assert.dom('[data-combobox]').doesNotHaveClass('shadow-focusable-outline');
+ });
+
+ test('it opens the popover on click', async function (assert) {
+ await render(
+
+ );
+
+ assert.dom('[role="listbox"]').doesNotExist();
+
+ await click('[data-combobox]');
+
+ assert.dom('[role="listbox"]').exists();
+ });
+
+ test('it opens the popover when the input receives input', async function (assert) {
+ await render(
+
+ );
+
+ assert.dom('[role="listbox"]').doesNotExist();
+
+ await fillIn('[data-combobox]', 'b');
+
+ assert.dom('[role="listbox"]').exists();
+ });
+
+ test('it sets `aria-expanded` based on the popover state', async function (assert) {
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ assert.dom('[role="listbox"]').doesNotExist();
+
+ assert.dom('[data-combobox]').hasNoAttribute('aria-expanded');
+
+ await click('[data-combobox]');
+
+ assert.dom('[data-combobox]').hasAttribute('aria-expanded');
+ });
+
+ test('it sets `aria-controls`', async function (assert) {
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ assert.dom('[data-combobox]').hasAttribute('aria-controls');
+ });
+
+ test('it applies the provided `@contentClass` to the popover content list', async function (assert) {
+ await render(
+
+ );
+
+ await click('[data-combobox]');
+
+ assert.dom('[role="listbox"]').exists();
+ assert.dom('[role="listbox"]').hasClass('test-class');
+ });
+
+ test('it renders the provided options in the popover list', async function (assert) {
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ await click('[data-combobox]');
+
+ assert.dom('[role="option"]').exists({ count: 2 });
+ assert.dom('[data-option]').exists({ count: 2 });
+ });
+
+ test('it sets the value attribute via `@selected`', async function (assert) {
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ assert.dom('[data-combobox]').hasValue('blue');
+ });
+
+ test('it sets the value attribute via `@selected` and `@optionKey` when `@selected` is an object', async function (assert) {
+ let options = [
+ {
+ label: 'Blue',
+ value: 'blue',
+ },
+ ];
+
+ let selected = options[0];
+
+ await render(
+
+ {{combobox.option.label}}
+
+ );
+
+ assert.dom('[data-combobox]').hasValue('Blue');
+ });
+
+ test('it sets `aria-selected` properly on the list item that is currently selected', async function (assert) {
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ await click('[data-combobox]');
+
+ // Since `@selected="blue"`, we expect it to be selected
+ assert
+ .dom('[role="option"]:first-child')
+ .hasAttribute('aria-selected', 'true');
+
+ // ...but not the "red" one!
+ assert
+ .dom('[role="option"]:last-child')
+ .hasAttribute('aria-selected', 'false');
+ });
+
+ test('it provides default filtering when `@options` is an array of strings', async function (assert) {
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ await fillIn('[data-combobox]', 'blue');
+
+ // Filtering works as we expect
+ assert.dom('[role="option"]').exists({ count: 1 });
+ assert.dom('[role="option"]').hasText('blue');
+
+ // Resetting the filter by clearing the input should
+ // display all available options
+ await fillIn('[data-combobox]', '');
+ assert.dom('[role="option"]').exists({ count: 2 });
+
+ // Verify we can filter again after clearing
+ await fillIn('[data-combobox]', 'red');
+ assert.dom('[role="option"]').exists({ count: 1 });
+ assert.dom('[role="option"]').hasText('red');
+ });
+
+ test('it provides default filtering when `@options` is an array of objects and is provided with `@optionKey`', async function (assert) {
+ let options = [
+ {
+ label: 'Blue',
+ value: 'blue',
+ },
+ {
+ label: 'Red',
+ value: 'red',
+ },
+ ];
+
+ await render(
+
+ {{combobox.option.label}}
+
+ );
+
+ await fillIn('[data-combobox]', 'blue');
+
+ // Filtering works as we expect
+ assert.dom('[role="option"]').exists({ count: 1 });
+ // NOTE: We should be using option.label (capitalized "Blue" instead of "blue")
+ assert.dom('[role="option"]').hasText('Blue');
+
+ // Resetting the filter by clearing the input should
+ // display all available options
+ await fillIn('[data-combobox]', '');
+ assert.dom('[role="option"]').exists({ count: 2 });
+
+ // Verify we can filter again after clearing
+ await fillIn('[data-combobox]', 'red');
+ assert.dom('[role="option"]').exists({ count: 1 });
+ // NOTE: We should be using option.label (capitalized "Red" instead of "red")
+ assert.dom('[role="option"]').hasText('Red');
+ });
+
+ test('it uses the provided `@noResultsText` when no results are found with filtering', async function (assert) {
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ await fillIn('[data-combobox]', 'something-not-in-the-list');
+
+ // We should not have any list items
+ assert.dom('[role="option"]').exists({ count: 0 });
+
+ // ...but we should have our no results item!
+ assert.dom('[role="status"]').exists();
+ assert.dom('[role="status"]').hasTagName('li');
+ assert.dom('[role="status"]').hasText('No results');
+ assert
+ .dom('[role="status"]')
+ .hasAttribute(
+ 'aria-live',
+ 'assertive',
+ 'Expected assertive so it is announced to screenreaders'
+ );
+ });
+
+ test('it calls `@onChange` when an option is selected via mouse click', async function (assert) {
+ assert.expect(5);
+
+ let handleChange = (value: unknown) => {
+ assert.strictEqual(value, 'blue', 'Expected input to match');
+ assert.step('handleChange');
+ };
+
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ assert.verifySteps([]);
+
+ await fillIn('[data-combobox]', 'blue');
+
+ assert.dom('[role="option"]').exists({ count: 1 });
+
+ await click('[role="option"]');
+
+ assert.verifySteps(['handleChange']);
+ });
+
+ test('it calls `@onChange` when an option is selected via the keyboard with ENTER', async function (assert) {
+ assert.expect(5);
+
+ let handleChange = (value: unknown) => {
+ assert.strictEqual(value, 'blue', 'Expected input to match');
+ assert.step('handleChange');
+ };
+
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ assert.verifySteps([]);
+
+ await fillIn('[data-combobox]', 'blue');
+
+ assert.dom('[role="option"]').exists({ count: 1 });
+
+ await triggerKeyEvent('[data-combobox]', 'keydown', 'Enter');
+
+ assert.verifySteps(['handleChange']);
+ });
+
+ test('it uses the results from `@onFilter` to populate the filtered options', async function (assert) {
+ assert.expect(5);
+
+ let handleFilter = (value: unknown) => {
+ assert.strictEqual(
+ value,
+ 'y',
+ 'Expected the input to match what was entered via fillIn'
+ );
+ assert.step('onFilter');
+
+ return ['yellow'];
+ };
+
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ await fillIn('[data-combobox]', 'y');
+
+ assert.verifySteps(['onFilter']);
+
+ assert.dom('[role="option"]').exists({ count: 1 });
+ assert.dom('[role="option"]').hasText('yellow');
+ });
+
+ test('it sets the "active" item to the first one in the list when the combobox gains focus', async function (assert) {
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ await click('[data-combobox]');
+
+ assert.dom('[role="option"]').exists({ count: 2 });
+
+ assert
+ .dom('[role="option"]:first-child')
+ .hasAttribute('data-active', 'true');
+ assert
+ .dom('[role="option"]:last-child')
+ .hasAttribute('data-active', 'false');
+ });
+
+ test('it sets the "active" item to the next item in the list when using the DOWN arrow', async function (assert) {
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ await click('[data-combobox]');
+
+ assert.dom('[role="option"]').exists({ count: 2 });
+
+ await triggerKeyEvent('[data-combobox]', 'keydown', 'ArrowDown');
+
+ assert
+ .dom('[role="option"]:first-child')
+ .hasAttribute('data-active', 'false');
+ assert
+ .dom('[role="option"]:last-child')
+ .hasAttribute('data-active', 'true');
+ });
+
+ test('it sets the "active" item to the previous item in the list when using the UP arrow', async function (assert) {
+ // NOTE: Setting the selected option to "red" here so that
+ // the last item in the list will be active so that we can
+ // move up!
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ await click('[data-combobox]');
+
+ assert.dom('[role="option"]').exists({ count: 2 });
+
+ await triggerKeyEvent('[data-combobox]', 'keydown', 'ArrowUp');
+
+ assert
+ .dom('[role="option"]:first-child')
+ .hasAttribute('data-active', 'true');
+ assert
+ .dom('[role="option"]:last-child')
+ .hasAttribute('data-active', 'false');
+ });
+
+ test('it closes an open popover when the ESCAPE key is pressed', async function (assert) {
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ await click('[data-combobox]');
+
+ assert.dom('[role="listbox"]').exists();
+
+ await triggerKeyEvent('[data-combobox]', 'keydown', 'Escape');
+
+ assert.dom('[role="listbox"]').doesNotExist();
+ });
+
+ test('it closes an open popover when the component is blurred', async function (assert) {
+ await render(
+ {{! template-lint-disable require-input-label }}
+
+
+
+ {{combobox.option}}
+
+ );
+
+ // Open the popover
+ await click('[data-combobox]');
+
+ assert.dom('[role="listbox"]').exists();
+
+ // Now blur the elment
+ await click('[data-input]');
+
+ assert.dom('[role="listbox"]').doesNotExist();
+ });
+
+ test('it reopens the popover when any key is pressed if the popover is closed', async function (assert) {
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ // Open the popover by clicking it
+ await click('[data-combobox]');
+
+ // Now close it
+ await triggerKeyEvent('[data-combobox]', 'keydown', 'Escape');
+ // Now reopen it
+ await triggerKeyEvent('[data-combobox]', 'keydown', 'ArrowDown');
+
+ assert.dom('[role="listbox"]').exists();
+ });
+
+ test('it makes the first option "active" when the metakey and UP arrow is pressed', async function (assert) {
+ let options = ['a', 'b', 'c', 'd', 'e', 'f'];
+
+ // NOTE: Our selected option is currently at the bottom of the list!
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ // Open the popover by clicking it
+ await click('[data-combobox]');
+
+ await triggerKeyEvent('[data-combobox]', 'keydown', 'ArrowUp', {
+ metaKey: true,
+ });
+
+ assert
+ .dom('[role="option"]:first-child')
+ .hasAttribute('data-active', 'true');
+ // Verify our last item is no longer "active"
+ assert
+ .dom('[role="option"]:last-child')
+ .hasAttribute('data-active', 'false');
+ });
+
+ test('it makes the last option "active" when the metakey and DOWN arrow is pressed', async function (assert) {
+ let options = ['a', 'b', 'c', 'd', 'e', 'f'];
+
+ // NOTE: Our selected option is currently at the top of the list!
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ // Open the popover by clicking it
+ await click('[data-combobox]');
+
+ await triggerKeyEvent('[data-combobox]', 'keydown', 'ArrowDown', {
+ metaKey: true,
+ });
+
+ assert
+ .dom('[role="option"]:last-child')
+ .hasAttribute('data-active', 'true');
+
+ // Verify our first item is no longer "active"
+ assert
+ .dom('[role="option"]:first-child')
+ .hasAttribute('data-active', 'false');
+ });
+
+ test('it makes the last option "active" when the PAGEDOWN key is pressed', async function (assert) {
+ let options = ['a', 'b', 'c', 'd', 'e', 'f'];
+
+ // NOTE: Our selected option is currently at the top of the list!
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ // Open the popover by clicking it
+ await click('[data-combobox]');
+
+ await triggerKeyEvent('[data-combobox]', 'keydown', 'PageDown');
+
+ assert
+ .dom('[role="option"]:last-child')
+ .hasAttribute('data-active', 'true');
+
+ // Verify our first item is no longer "active"
+ assert
+ .dom('[role="option"]:first-child')
+ .hasAttribute('data-active', 'false');
+ });
+
+ test('it makes the first option "active" when the PAGEUP key is pressed', async function (assert) {
+ let options = ['a', 'b', 'c', 'd', 'e', 'f'];
+
+ // NOTE: Our selected option is currently at the bottom of the list!
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ // Open the popover by clicking it
+ await click('[data-combobox]');
+
+ await triggerKeyEvent('[data-combobox]', 'keydown', 'PageUp');
+
+ assert
+ .dom('[role="option"]:first-child')
+ .hasAttribute('data-active', 'true');
+ // Verify our last item is no longer "active"
+ assert
+ .dom('[role="option"]:last-child')
+ .hasAttribute('data-active', 'false');
+ });
+
+ test('it makes the first option "active" when the HOME key is pressed', async function (assert) {
+ let options = ['a', 'b', 'c', 'd', 'e', 'f'];
+
+ // NOTE: Our selected option is currently at the bottom of the list!
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ // Open the popover by clicking it
+ await click('[data-combobox]');
+
+ await triggerKeyEvent('[data-combobox]', 'keydown', 'Home');
+
+ assert
+ .dom('[role="option"]:first-child')
+ .hasAttribute('data-active', 'true');
+ // Verify our last item is no longer "active"
+ assert
+ .dom('[role="option"]:last-child')
+ .hasAttribute('data-active', 'false');
+ });
+
+ /**
+ * This test covers the case where we have a selected option,
+ * the user clears the input completely, and then the component's
+ * input tag is blurred. In that case, we want to clear the input
+ * and reset their selection to null.
+ */
+ test('it calls `@onChange` with null after a user clears their original input selection', async function (assert) {
+ assert.expect(4);
+
+ let handleChange = (value: unknown) => {
+ assert.strictEqual(value, null, 'Expected `value` to be null');
+ assert.step('handleChange');
+ };
+
+ // NOTE: We have a selected option
+ // NOTE: We add an input tag so we have something to blur to (by focusing another element)
+ await render(
+
+ {{combobox.option}}
+
+
+ {{! template-lint-disable require-input-label }}
+
+ );
+
+ await click('[data-combobox]');
+
+ // Clear the input
+ await fillIn('[data-combobox]', '');
+
+ // Now blur the elment
+ await click('[data-input]');
+
+ assert.dom('[data-combobox]').hasValue('');
+ assert.verifySteps(['handleChange']);
+ });
+
+ test('it reverts to the selected option when a user enters garbage after previously having a valid selection (with `@selected` as a string)', async function (assert) {
+ assert.expect(3);
+
+ let handleChange = () => {
+ assert.step('do-not-expect-this-to-be-called!');
+ };
+
+ // NOTE: We have a selected option
+ // NOTE: We add an input tag so we have something to blur to (by focusing another element)
+ await render(
+
+ {{combobox.option}}
+
+
+ {{! template-lint-disable require-input-label }}
+
+ );
+
+ await click('[data-combobox]');
+
+ // Enter garbage into the input
+ await fillIn('[data-combobox]', 'some-garbage');
+
+ // Now blur the elment
+ await click('[data-input]');
+
+ // Verify the input is reset to our `@selected` option
+ assert.dom('[data-combobox]').hasValue('blue');
+
+ // NOTE: We do not expect the `@onChange` to be called in this
+ // case as we are only visually resetting to the previously
+ // selected value
+ assert.verifySteps([]);
+
+ // We want to verify the original options are re-displayed
+ // rather than the input being filtered to garbage
+ await click('[data-combobox]');
+
+ assert.dom('[role="option"]').exists({ count: 2 });
+ });
+
+ test('it reverts to the selected option when a user enters garbage after previously having a valid selection (with `@selected` as an object)', async function (assert) {
+ assert.expect(3);
+
+ let options = [
+ {
+ label: 'Blue',
+ value: 'blue',
+ },
+ {
+ label: 'Red',
+ value: 'red',
+ },
+ ];
+
+ let selected = options[0];
+
+ let handleChange = () => {
+ assert.step('do-not-expect-this-to-be-called!');
+ };
+
+ // NOTE: We have a selected option
+ // NOTE: We add an input tag so we have something to blur to (by focusing another element)
+ await render(
+
+ {{combobox.option.label}}
+
+
+ {{! template-lint-disable require-input-label }}
+
+ );
+
+ await click('[data-combobox]');
+
+ // Enter garbage into the input
+ await fillIn('[data-combobox]', 'some-garbage');
+
+ // Now blur the elment
+ await click('[data-input]');
+
+ // Verify the input is reset to our `@selected` option
+ assert.dom('[data-combobox]').hasValue('Blue');
+
+ // NOTE: We do not expect the `@onChange` to be called in this
+ // case as we are only visually resetting to the previously
+ // selected value
+ assert.verifySteps([]);
+
+ // We want to verify the original options are re-displayed
+ // rather than the input being filtered to garbage
+ await click('[data-combobox]');
+
+ assert.dom('[role="option"]').exists({ count: 2 });
+ });
+
+ test('it reselects the entered input value when there is a selected item and the input regains focus', async function (assert) {
+ await render(
+
+ {{combobox.option}}
+
+ );
+
+ assert.strictEqual(
+ document.getSelection()?.toString(),
+ '',
+ 'Expected nothing to be selected by default as we have not interacted with the component'
+ );
+
+ await click('[data-combobox]');
+
+ assert.strictEqual(
+ document.getSelection()?.toString(),
+ 'blue',
+ 'Expected "blue" to be selected since that is our `@selected` value'
+ );
+ });
+});
diff --git a/test-app/tests/integration/components/toucan-form/form-combobox-test.gts b/test-app/tests/integration/components/toucan-form/form-combobox-test.gts
new file mode 100644
index 00000000..23522417
--- /dev/null
+++ b/test-app/tests/integration/components/toucan-form/form-combobox-test.gts
@@ -0,0 +1,116 @@
+import { render } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+
+import ToucanForm from '@crowdstrike/ember-toucan-form/components/toucan-form';
+import { setupRenderingTest } from 'test-app/tests/helpers';
+
+const options = ['blue', 'red', 'yellow'];
+
+interface TestData {
+ selection?: string;
+}
+
+module('Integration | Component | ToucanForm | Combobox', function (hooks) {
+ setupRenderingTest(hooks);
+
+ test('it renders `@label` and `@hint` component arguments', async function (assert) {
+ const data: TestData = {
+ selection: '',
+ };
+
+ await render(
+
+
+ {{combobox.option}}
+
+
+ );
+
+ assert.dom('[data-label]').exists();
+ assert.dom('[data-hint]').exists();
+ });
+
+ test('it renders a `:label` named block with a `@hint` argument', async function (assert) {
+ const data: TestData = {
+ selection: '',
+ };
+
+ await render(
+
+
+ <:label>Label
+ <:default as |combobox|>
+ {{combobox.option}}
+
+
+
+ );
+
+ assert.dom('[data-label-block]').exists();
+
+ // NOTE: `data-hint` comes from `@hint`.
+ assert.dom('[data-hint]').exists();
+ assert.dom('[data-hint]').hasText('Hint');
+ });
+
+ test('it renders a `:hint` named block with a `@label` argument', async function (assert) {
+ const data: TestData = {
+ selection: '',
+ };
+
+ await render(
+
+
+ <:hint>Hint
+ <:default as |combobox|>
+ {{combobox.option}}
+
+
+
+ );
+
+ // NOTE: `data-label` comes from `@label`.
+ assert.dom('[data-label]').exists();
+ assert.dom('[data-label]').hasText('Label');
+
+ assert.dom('[data-hint-block]').exists();
+ });
+
+ test('it renders both a `:label` and `:hint` named block', async function (assert) {
+ const data: TestData = {
+ selection: '',
+ };
+
+ await render(
+
+
+ <:label>Label
+ <:hint>Hint
+ <:default as |combobox|>
+ {{combobox.option}}
+
+
+
+ );
+
+ assert.dom('[data-label-block]').exists();
+ assert.dom('[data-hint-block]').exists();
+ });
+});
diff --git a/test-app/tests/integration/components/toucan-form/toucan-form-test.gts b/test-app/tests/integration/components/toucan-form/toucan-form-test.gts
index dfdd949b..92bffaba 100644
--- a/test-app/tests/integration/components/toucan-form/toucan-form-test.gts
+++ b/test-app/tests/integration/components/toucan-form/toucan-form-test.gts
@@ -1,6 +1,12 @@
/* eslint-disable no-undef -- Until https://github.com/ember-cli/eslint-plugin-ember/issues/1747 is resolved... */
import { on } from '@ember/modifier';
-import { click, fillIn, render, triggerEvent } from '@ember/test-helpers';
+import {
+ click,
+ fillIn,
+ render,
+ triggerEvent,
+ triggerKeyEvent,
+} from '@ember/test-helpers';
import { module, test } from 'qunit';
import ToucanForm from '@crowdstrike/ember-toucan-form/components/toucan-form';
@@ -12,8 +18,11 @@ const testFile = new File(['Some sample content'], 'file.txt', {
type: 'text/plain',
});
+const options = ['blue', 'red', 'yellow'];
+
interface TestData {
checkboxes?: Array;
+ combobox?: string;
comment?: string;
firstName?: string;
radio?: string;
@@ -132,6 +141,7 @@ module('Integration | Component | ToucanForm', function (hooks) {
test('it sets the yielded component values based on `@data`', async function (assert) {
const data: TestData = {
checkboxes: ['option-1', 'option-3'],
+ combobox: 'blue',
comment: 'multi-line text',
firstName: 'single line text',
radio: 'option-2',
@@ -178,6 +188,16 @@ module('Integration | Component | ToucanForm', function (hooks) {
@trigger="Select files"
@deleteLabel="Delete"
/>
+
+
+ {{combobox.option}}
+
);
@@ -208,10 +228,14 @@ module('Integration | Component | ToucanForm', function (hooks) {
// File input
assert.dom('[data-files] [data-file-name]').hasText('file.txt');
+
+ // Combobox
+ assert.dom('[data-combobox]').hasAttribute('name', 'combobox');
+ assert.dom('[data-combobox]').hasValue('blue');
});
test('it triggers validation and shows error messages in the Toucan Core components', async function (assert) {
- assert.expect(18);
+ assert.expect(19);
const handleSubmit = ({ files, ...data }: TestData) => {
// Checking deep equality on files can get really tricky due
@@ -232,6 +256,7 @@ module('Integration | Component | ToucanForm', function (hooks) {
data,
{
checkboxes: ['option-2'],
+ combobox: 'red',
comment: 'A comment.',
firstName: 'CrowdStrike',
radio: 'option-2',
@@ -246,6 +271,7 @@ module('Integration | Component | ToucanForm', function (hooks) {
const formValidateCallback = ({
checkboxes,
+ combobox,
comment,
firstName,
radio,
@@ -264,6 +290,16 @@ module('Integration | Component | ToucanForm', function (hooks) {
];
}
+ if (!combobox) {
+ errors.combobox = [
+ {
+ type: 'required',
+ value: combobox,
+ message: 'One combobox item must be selected',
+ },
+ ];
+ }
+
if (!comment) {
errors.comment = [
{
@@ -387,6 +423,17 @@ module('Integration | Component | ToucanForm', function (hooks) {
data-file-input-field
/>
+
+ {{combobox.option}}
+
+
Submit
);
@@ -427,6 +474,9 @@ module('Integration | Component | ToucanForm', function (hooks) {
assert
.dom('[data-root-field="data-file-input-wrapper"] [data-error]')
.hasText('A file must be added');
+ assert
+ .dom('[data-root-field="data-combobox-wrapper"] [data-error]')
+ .hasText('One combobox item must be selected');
// Satisfy the validation and submit the form
await fillIn('[data-textarea]', 'A comment.');
@@ -437,6 +487,8 @@ module('Integration | Component | ToucanForm', function (hooks) {
await triggerEvent('[data-file-input-field]', 'change', {
files: [testFile],
});
+ await fillIn('[data-combobox]', 'red');
+ await triggerKeyEvent('[data-combobox]', 'keydown', 'Enter');
await click('[data-test-submit]');