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}} + + + +