From dcda232fa98575eb26b86f02337f28463b0962f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benoi=CC=82t=20Burgener?= Date: Thu, 14 Jan 2021 16:59:10 +0100 Subject: [PATCH] feat(components): introduce select/listbox component feat(components): introduce select/listbox component --- .../src/components/CSelect/CSelect.spec.js | 95 +++ .../chusho/src/components/CSelect/CSelect.ts | 170 +++++ .../src/components/CSelect/CSelectBtn.spec.js | 112 ++++ .../src/components/CSelect/CSelectBtn.ts | 45 ++ .../components/CSelect/CSelectGroup.spec.js | 60 ++ .../src/components/CSelect/CSelectGroup.ts | 53 ++ .../CSelect/CSelectGroupLabel.spec.js | 47 ++ .../components/CSelect/CSelectGroupLabel.ts | 34 + .../components/CSelect/CSelectOption.spec.js | 211 ++++++ .../src/components/CSelect/CSelectOption.ts | 113 ++++ .../components/CSelect/CSelectOptions.spec.js | 207 ++++++ .../src/components/CSelect/CSelectOptions.ts | 185 ++++++ .../chusho/src/components/CSelect/index.ts | 15 + packages/chusho/src/components/index.ts | 1 + packages/chusho/src/types/index.ts | 12 +- packages/chusho/src/utils/objects.ts | 13 + .../.vuepress/components/Example/Select.vue | 628 ++++++++++++++++++ packages/docs/.vuepress/config.js | 1 + packages/docs/.vuepress/public/icons.svg | 6 + packages/docs/chusho.config.js | 31 + packages/docs/guide/components/select.md | 99 +++ packages/docs/guide/config.md | 6 + packages/playground/chusho.config.js | 30 + packages/playground/package-lock.json | 6 + packages/playground/package.json | 1 + packages/playground/public/icons.svg | 20 +- .../examples/components/select/Default.vue | 627 +++++++++++++++++ .../examples/components/select/Disabled.vue | 45 ++ .../components/select/DisabledOptions.vue | 66 ++ .../components/select/OptionsGroup.vue | 82 +++ .../components/select/WithValidation.vue | 50 ++ .../src/components/examples/routes.json | 25 + 32 files changed, 3091 insertions(+), 5 deletions(-) create mode 100644 packages/chusho/src/components/CSelect/CSelect.spec.js create mode 100644 packages/chusho/src/components/CSelect/CSelect.ts create mode 100644 packages/chusho/src/components/CSelect/CSelectBtn.spec.js create mode 100644 packages/chusho/src/components/CSelect/CSelectBtn.ts create mode 100644 packages/chusho/src/components/CSelect/CSelectGroup.spec.js create mode 100644 packages/chusho/src/components/CSelect/CSelectGroup.ts create mode 100644 packages/chusho/src/components/CSelect/CSelectGroupLabel.spec.js create mode 100644 packages/chusho/src/components/CSelect/CSelectGroupLabel.ts create mode 100644 packages/chusho/src/components/CSelect/CSelectOption.spec.js create mode 100644 packages/chusho/src/components/CSelect/CSelectOption.ts create mode 100644 packages/chusho/src/components/CSelect/CSelectOptions.spec.js create mode 100644 packages/chusho/src/components/CSelect/CSelectOptions.ts create mode 100644 packages/chusho/src/components/CSelect/index.ts create mode 100644 packages/docs/.vuepress/components/Example/Select.vue create mode 100644 packages/docs/guide/components/select.md create mode 100644 packages/playground/src/components/examples/components/select/Default.vue create mode 100644 packages/playground/src/components/examples/components/select/Disabled.vue create mode 100644 packages/playground/src/components/examples/components/select/DisabledOptions.vue create mode 100644 packages/playground/src/components/examples/components/select/OptionsGroup.vue create mode 100644 packages/playground/src/components/examples/components/select/WithValidation.vue diff --git a/packages/chusho/src/components/CSelect/CSelect.spec.js b/packages/chusho/src/components/CSelect/CSelect.spec.js new file mode 100644 index 00000000..edc08fc6 --- /dev/null +++ b/packages/chusho/src/components/CSelect/CSelect.spec.js @@ -0,0 +1,95 @@ +import { mount } from '@vue/test-utils'; +import { h } from 'vue'; + +import CSelect from './CSelect'; +import CSelectBtn from './CSelectBtn'; + +describe('CSelect', () => { + it('provides select API', () => { + const wrapper = mount(CSelect, { + slots: { + default: h(CSelectBtn), + }, + }); + + expect(wrapper.findComponent(CSelectBtn).vm.select).toEqual( + wrapper.vm.select + ); + }); + + it('renders with config class', () => { + const wrapper = mount(CSelect, { + global: { + provide: { + $chusho: { + options: { + components: { + select: { + class: 'select', + }, + }, + }, + }, + }, + }, + }); + + expect(wrapper.classes()).toEqual(['select']); + }); + + it.each(['Tab', 'Esc', 'Escape'])('closes when pressing %s key', (key) => { + const wrapper = mount(CSelect, { + props: { + open: true, + }, + }); + + expect(wrapper.vm.select.toggle.isOpen.value).toEqual(true); + wrapper.trigger('keydown', { key }); + expect(wrapper.vm.select.toggle.isOpen.value).toEqual(false); + }); + + it.each([ + ['Object', { value: 'Object Truth' }, 'Object Truth'], + ['String', 'Truth', 'Truth'], + ])( + 'renders a hidden input holding the current %s value', + (type, modelValue, actualValue) => { + const wrapper = mount(CSelect, { + props: { + modelValue, + }, + }); + + expect(wrapper.find('input').html()).toEqual( + `` + ); + } + ); + + it('forwards the `name` prop to the underlying input', () => { + const wrapper = mount(CSelect, { + props: { + name: 'field-name', + }, + }); + + expect(wrapper.find('input').html()).toEqual( + `` + ); + }); + + it('applies the `input` prop as attributes on the underlying input', () => { + const wrapper = mount(CSelect, { + props: { + input: { + 'data-test': 'my-input', + }, + }, + }); + + expect(wrapper.find('input').html()).toEqual( + `` + ); + }); +}); diff --git a/packages/chusho/src/components/CSelect/CSelect.ts b/packages/chusho/src/components/CSelect/CSelect.ts new file mode 100644 index 00000000..596c533d --- /dev/null +++ b/packages/chusho/src/components/CSelect/CSelect.ts @@ -0,0 +1,170 @@ +import { + computed, + ComputedRef, + defineComponent, + h, + inject, + InjectionKey, + mergeProps, + provide, +} from 'vue'; + +import { DollarChusho } from '../../types'; +import { generateConfigClass } from '../../utils/components'; +import uuid from '../../utils/uuid'; +import componentMixin from '../mixins/componentMixin'; +import useSelected, { + SelectedItem, + UseSelected, +} from '../../composables/useSelected'; +import useToggle from '../../composables/useToggle'; +import { isObject, isPrimitive } from '../../utils/objects'; + +export const SelectSymbol: InjectionKey = Symbol('CSelect'); + +type SelectValue = unknown; + +export interface SelectOptionData { + disabled: boolean; + text: string; +} + +export type SelectOption = SelectedItem; + +export interface UseSelect { + uuid: string; + value: ComputedRef; + setValue: (value: SelectValue) => void; + disabled: ComputedRef; + toggle: ReturnType; + selected: UseSelected; +} + +export default defineComponent({ + name: 'CSelect', + + mixins: [componentMixin], + + inheritAttrs: false, + + props: { + /** + * Bind the Select value with the parent component. + */ + modelValue: { + type: [String, Number, Array, Object], + default: null, + }, + /** + * Bind the SelectOptions opening state with the parent component. + */ + open: { + type: Boolean, + default: false, + }, + /** + * Forwarded to the underlying `input` holding the select value. + */ + name: { + type: String, + default: null, + }, + /** + * Additional attributes to be applied to the hidden input holding the select value. + * For example: `{ 'data-test': 'my-input' }` + */ + input: { + type: Object, + default: null, + }, + /** + * Method to resolve the currently selected item value. + * For example: `(item) => item.value` + */ + itemValue: { + type: Function, + default: (item: unknown) => { + if (isPrimitive(item)) { + return item; + } else if (isObject(item) && item.value) { + return item.value; + } + return null; + }, + }, + /** + * Prevent opening the SelectOptions and therefor changing the Select value. + */ + disabled: { + type: Boolean, + default: false, + }, + }, + + emits: ['update:modelValue', 'update:open'], + + setup(props, { emit }) { + const api: UseSelect = { + uuid: uuid('chusho-select'), + value: computed(() => props.modelValue), + setValue: (value: unknown) => { + emit('update:modelValue', value); + }, +<<<<<<< HEAD + toggle: useToggle(props.open, 'open'), + selected: useSelected(), +======= + disabled: computed(() => props.disabled), + togglable: useTogglable(props.open, 'open'), + selectable: useSelectable(), +>>>>>>> 3d49c53 (select bis) + }; + + provide(SelectSymbol, api); + + return { + select: api, + }; + }, + + methods: { + handleKeydown(e: KeyboardEvent) { + switch (e.key) { + case 'Tab': + case 'Esc': + case 'Escape': + this.select.toggle.close(); + break; + } + }, + }, + + /** + * @slot + * @binding {boolean} open `true` when the select is open + */ + render() { + const selectConfig = inject('$chusho', null)?.options + ?.components?.select; + const elementProps: Record = { + ...generateConfigClass(selectConfig?.class, this.$props), + onKeydown: this.handleKeydown, + }; + const inputProps: Record = { + type: 'hidden', + name: this.$props.name, + value: this.$props.itemValue(this.$props.modelValue), + }; + + return h('div', mergeProps(this.$attrs, elementProps), { + default: () => { + const children = + this.$slots?.default?.({ + open: this.select.toggle.isOpen.value, + }) ?? []; + children.unshift(h('input', mergeProps(this.$props.input, inputProps))); + return children; + }, + }); + }, +}); diff --git a/packages/chusho/src/components/CSelect/CSelectBtn.spec.js b/packages/chusho/src/components/CSelect/CSelectBtn.spec.js new file mode 100644 index 00000000..ffcaee69 --- /dev/null +++ b/packages/chusho/src/components/CSelect/CSelectBtn.spec.js @@ -0,0 +1,112 @@ +import { mount } from '@vue/test-utils'; +import { h } from 'vue'; + +import CSelect from './CSelect'; +import CSelectBtn from './CSelectBtn'; + +describe('CSelectBtn', () => { + it('renders with the right attributes when closed', () => { + const wrapper = mount(CSelect, { + slots: { + default: h(CSelectBtn, null, { default: () => 'Label' }), + }, + }); + + expect(wrapper.findComponent(CSelectBtn).html()).toBe( + '' + ); + }); + + it('renders with the right attributes when open', () => { + const wrapper = mount(CSelect, { + props: { + open: true, + }, + slots: { + default: h(CSelectBtn, null, { default: () => 'Label' }), + }, + }); + + expect(wrapper.findComponent(CSelectBtn).html()).toBe( + '' + ); + }); + + it('provides active state to default slot', () => { + const wrapper = mount(CSelect, { + slots: { + default: (params) => JSON.stringify(params), + }, + }); + + expect(wrapper.text()).toContain('{"open":false}'); + }); + + it('renders with config class', () => { + const wrapper = mount(CSelect, { + props: { + open: true, + }, + global: { + provide: { + $chusho: { + options: { + components: { + selectBtn: { + class: ({ active }) => ['select-btn', { active }], + }, + }, + }, + }, + }, + }, + slots: { + default: h(CSelectBtn), + }, + }); + + expect(wrapper.findComponent(CSelectBtn).classes()).toEqual([ + 'select-btn', + 'active', + ]); + }); + + it('does not inherit btn classes', () => { + const wrapper = mount(CSelect, { + global: { + provide: { + $chusho: { + options: { + components: { + btn: { + class: 'btn', + }, + selectBtn: { + class: 'select-btn', + }, + }, + }, + }, + }, + }, + slots: { + default: h(CSelectBtn), + }, + }); + + expect(wrapper.findComponent(CSelectBtn).classes()).toEqual(['select-btn']); + }); + + it('is disabled if CSelect is disabled', () => { + const wrapper = mount(CSelect, { + props: { + disabled: true, + }, + slots: { + default: h(CSelectBtn, null, { default: () => 'Label' }), + }, + }); + + expect(wrapper.findComponent(CSelectBtn).attributes('disabled')).toBe(''); + }); +}); diff --git a/packages/chusho/src/components/CSelect/CSelectBtn.ts b/packages/chusho/src/components/CSelect/CSelectBtn.ts new file mode 100644 index 00000000..45cdc0dd --- /dev/null +++ b/packages/chusho/src/components/CSelect/CSelectBtn.ts @@ -0,0 +1,45 @@ +import { defineComponent, h, inject, mergeProps } from 'vue'; + +import { DollarChusho } from '../../types'; +import { generateConfigClass } from '../../utils/components'; +import componentMixin from '../mixins/componentMixin'; + +import { CBtn } from '../CBtn'; +import { SelectSymbol } from './CSelect'; + +export default defineComponent({ + name: 'CSelectBtn', + + mixins: [componentMixin], + + inheritAttrs: false, + + setup() { + const select = inject(SelectSymbol); + + return { + select, + }; + }, + + render() { + const selectBtnConfig = inject('$chusho', null) + ?.options?.components?.selectBtn; + const open = this.select?.toggle.isOpen.value; + const elementProps: Record = { + ...this.select?.toggle.attrs.btn.value, + 'aria-haspopup': 'listbox', + ...generateConfigClass(selectBtnConfig?.class, { + ...this.$props, + active: open, + disabled: this.select?.disabled.value, + }), + bare: true, + disabled: this.select?.disabled.value, + }; + + return h(CBtn, mergeProps(this.$attrs, this.$props, elementProps), () => + this.$slots?.default?.({ open }) + ); + }, +}); diff --git a/packages/chusho/src/components/CSelect/CSelectGroup.spec.js b/packages/chusho/src/components/CSelect/CSelectGroup.spec.js new file mode 100644 index 00000000..4a7dfbd8 --- /dev/null +++ b/packages/chusho/src/components/CSelect/CSelectGroup.spec.js @@ -0,0 +1,60 @@ +import { mount } from '@vue/test-utils'; +import { h } from 'vue'; + +import CSelect from './CSelect'; +import CSelectGroup from './CSelectGroup'; +import CSelectGroupLabel from './CSelectGroupLabel'; + +describe('CSelectGroup', () => { + it('provides selectGroup API', () => { + const wrapper = mount(CSelectGroup, { + slots: { + default: h(CSelectGroupLabel), + }, + }); + + expect(wrapper.findComponent(CSelectGroupLabel).vm.selectGroup).toEqual( + wrapper.vm.selectGroup + ); + }); + + it('renders with the right attributes', () => { + const wrapper = mount(CSelect, { + slots: { + default: h(CSelectGroup, null, { default: () => 'Slot' }), + }, + }); + + expect(wrapper.findComponent(CSelectGroup).html()).toBe( + '
Slot
' + ); + }); + + it('renders with config class', () => { + const wrapper = mount(CSelect, { + props: { + open: true, + }, + global: { + provide: { + $chusho: { + options: { + components: { + selectGroup: { + class: 'select-group', + }, + }, + }, + }, + }, + }, + slots: { + default: h(CSelectGroup), + }, + }); + + expect(wrapper.findComponent(CSelectGroup).classes()).toEqual([ + 'select-group', + ]); + }); +}); diff --git a/packages/chusho/src/components/CSelect/CSelectGroup.ts b/packages/chusho/src/components/CSelect/CSelectGroup.ts new file mode 100644 index 00000000..a6b6243f --- /dev/null +++ b/packages/chusho/src/components/CSelect/CSelectGroup.ts @@ -0,0 +1,53 @@ +import { + defineComponent, + h, + inject, + InjectionKey, + mergeProps, + provide, +} from 'vue'; + +import { DollarChusho } from '../../types'; +import { generateConfigClass } from '../../utils/components'; +import uuid from '../../utils/uuid'; +import componentMixin from '../mixins/componentMixin'; + +export const SelectGroupSymbol: InjectionKey = Symbol( + 'CSelectGroup' +); + +export interface UseSelectGroup { + labelId: string; +} + +export default defineComponent({ + name: 'CSelectGroup', + + mixins: [componentMixin], + + inheritAttrs: false, + + setup() { + const api: UseSelectGroup = { + labelId: uuid('chusho-select-group-label'), + }; + + provide(SelectGroupSymbol, api); + + return { + selectGroup: api, + }; + }, + + render() { + const selectGroupConfig = inject('$chusho', null) + ?.options?.components?.selectGroup; + const elementProps: Record = { + role: 'group', + 'aria-labelledby': this.selectGroup.labelId, + ...generateConfigClass(selectGroupConfig?.class, this.$props), + }; + + return h('div', mergeProps(this.$attrs, elementProps), this.$slots); + }, +}); diff --git a/packages/chusho/src/components/CSelect/CSelectGroupLabel.spec.js b/packages/chusho/src/components/CSelect/CSelectGroupLabel.spec.js new file mode 100644 index 00000000..90c7e4ac --- /dev/null +++ b/packages/chusho/src/components/CSelect/CSelectGroupLabel.spec.js @@ -0,0 +1,47 @@ +import { mount } from '@vue/test-utils'; +import { h } from 'vue'; + +import CSelectGroup from './CSelectGroup'; +import CSelectGroupLabel from './CSelectGroupLabel'; + +describe('CSelectGroupLabel', () => { + it('renders with the right attributes', () => { + const wrapper = mount(CSelectGroup, { + slots: { + default: h(CSelectGroupLabel, null, { default: () => 'Label' }), + }, + }); + + expect(wrapper.findComponent(CSelectGroupLabel).html()).toBe( + '
Label
' + ); + }); + + it('renders with config class', () => { + const wrapper = mount(CSelectGroup, { + props: { + open: true, + }, + global: { + provide: { + $chusho: { + options: { + components: { + selectGroupLabel: { + class: 'select-group-label', + }, + }, + }, + }, + }, + }, + slots: { + default: h(CSelectGroupLabel), + }, + }); + + expect(wrapper.findComponent(CSelectGroupLabel).classes()).toEqual([ + 'select-group-label', + ]); + }); +}); diff --git a/packages/chusho/src/components/CSelect/CSelectGroupLabel.ts b/packages/chusho/src/components/CSelect/CSelectGroupLabel.ts new file mode 100644 index 00000000..06e1b4a2 --- /dev/null +++ b/packages/chusho/src/components/CSelect/CSelectGroupLabel.ts @@ -0,0 +1,34 @@ +import { defineComponent, h, inject, mergeProps } from 'vue'; + +import { DollarChusho } from '../../types'; +import { generateConfigClass } from '../../utils/components'; +import componentMixin from '../mixins/componentMixin'; + +import { SelectGroupSymbol } from './CSelectGroup'; + +export default defineComponent({ + name: 'CSelectGroupLabel', + + mixins: [componentMixin], + + inheritAttrs: false, + + setup() { + const selectGroup = inject(SelectGroupSymbol); + + return { + selectGroup, + }; + }, + + render() { + const selectGroupLabelConfig = inject('$chusho', null) + ?.options?.components?.selectGroupLabel; + const elementProps: Record = { + id: this.selectGroup?.labelId, + ...generateConfigClass(selectGroupLabelConfig?.class, this.$props), + }; + + return h('div', mergeProps(this.$attrs, elementProps), this.$slots); + }, +}); diff --git a/packages/chusho/src/components/CSelect/CSelectOption.spec.js b/packages/chusho/src/components/CSelect/CSelectOption.spec.js new file mode 100644 index 00000000..2d203793 --- /dev/null +++ b/packages/chusho/src/components/CSelect/CSelectOption.spec.js @@ -0,0 +1,211 @@ +import { mount } from '@vue/test-utils'; +import { h, nextTick } from 'vue'; + +import CSelect from './CSelect'; +import CSelectOptions from './CSelectOptions'; +import CSelectOption from './CSelectOption'; + +describe('CSelectOption', () => { + it('renders with the right attributes when selected', () => { + const wrapper = mount(CSelect, { + props: { + modelValue: 'a', + }, + slots: { + default: h(CSelectOption, { value: 'a' }, { default: () => 'Label' }), + }, + }); + + expect(wrapper.findComponent(CSelectOption).html()).toBe( + '
  • Label
  • ' + ); + }); + + it('renders with the right attributes when not selected', () => { + const wrapper = mount(CSelect, { + slots: { + default: h(CSelectOption, { value: 'a' }, { default: () => 'Label' }), + }, + }); + + expect(wrapper.findComponent(CSelectOption).html()).toBe( + '
  • Label
  • ' + ); + }); + + it('renders with the right attributes when disabled', () => { + const wrapper = mount(CSelect, { + slots: { + default: h( + CSelectOption, + { value: 'a', disabled: true }, + { default: () => 'Label' } + ), + }, + }); + + expect(wrapper.findComponent(CSelectOption).html()).toBe( + '
  • Label
  • ' + ); + }); + + it('renders with config class', () => { + const wrapper = mount(CSelect, { + global: { + provide: { + $chusho: { + options: { + components: { + selectOption: { + class: ({ active, focus }) => [ + 'select-option', + { active, focus }, + ], + }, + }, + }, + }, + }, + }, + props: { + modelValue: 'a', + open: true, + }, + slots: { + default: h(CSelectOptions, () => + h(CSelectOption, { value: 'a', disabled: true }) + ), + }, + }); + + expect(wrapper.findComponent(CSelectOption).classes()).toEqual([ + 'select-option', + 'active', + 'focus', + ]); + }); + + it('registers itself as a select option with id, disabled status and uppercased text content', () => { + const wrapper = mount(CSelect, { + slots: { + default: h(CSelectOption, { value: 'a' }, { default: () => 'Label' }), + }, + }); + + expect(wrapper.vm.select.selected.items.value).toEqual([ + { + data: { disabled: false, text: 'label' }, + id: 'chusho-select-option-2', + }, + ]); + }); + + it('unregisters itself when unmounted', () => { + const wrapper = mount(CSelect, { + slots: { + default: h(CSelectOption, { value: 'a' }, { default: () => 'Label' }), + }, + }); + + expect(wrapper.vm.select.selected.items.value.length).toBe(1); + wrapper.unmount(); + expect(wrapper.vm.select.selected.items.value.length).toBe(0); + }); + + it('sets itself as the focused option if it is the currently selected option', () => { + const wrapper = mount(CSelect, { + props: { + modelValue: 'a', + }, + slots: { + default: h(CSelectOption, { value: 'a' }, { default: () => 'Label' }), + }, + }); + + expect(wrapper.vm.select.selected.selectedItem.value).toEqual({ + data: { disabled: false, text: 'label' }, + id: 'chusho-select-option-2', + }); + }); + + it('update v-model and close on click when not disabled', async () => { + const wrapper = mount(CSelect, { + props: { + open: true, + modelValue: 'a', + }, + slots: { + default: [ + h(CSelectOption, { value: 'a' }, { default: () => 'A' }), + h(CSelectOption, { value: 'b' }, { default: () => 'B' }), + ], + }, + }); + + expect(wrapper.vm.select.toggle.isOpen.value).toBe(true); + await wrapper.findAllComponents(CSelectOption)[1].trigger('click'); + expect(wrapper.emitted('update:modelValue')).toEqual([['b']]); + expect(wrapper.vm.select.toggle.isOpen.value).toBe(false); + }); + + it.each(['Enter', 'Spacebar', ' '])( + 'update v-model and close when pressing "%s" on the focused item', + async (key) => { + const wrapper = mount(CSelect, { + props: { + open: true, + modelValue: 'a', + }, + slots: { + default: [ + h(CSelectOption, { value: 'a' }, { default: () => 'A' }), + h(CSelectOption, { value: 'b' }, { default: () => 'B' }), + ], + }, + }); + + expect(wrapper.vm.select.toggle.isOpen.value).toBe(true); + await wrapper + .findAllComponents(CSelectOption)[1] + .trigger('keydown', { key }); + expect(wrapper.emitted('update:modelValue')).toEqual([['b']]); + expect(wrapper.vm.select.toggle.isOpen.value).toBe(false); + } + ); + + it('focuses itself after being rendered if it’s the currently active option', async () => { + const wrapper = mount(CSelect, { + attachTo: document.body, + props: { + open: true, + modelValue: 'b', + }, + slots: { + default: [ + h(CSelectOption, { value: 'a' }, { default: () => 'A' }), + h(CSelectOption, { value: 'b' }, { default: () => 'B' }), + ], + }, + }); + + await nextTick(); + + expect(wrapper.findAllComponents(CSelectOption)[1].vm.$el).toBe( + document.activeElement + ); + + wrapper.unmount(); + }); + + it('render a non-breakin space if it has no default slots', () => { + const wrapper = mount(CSelect, { + slots: { + default: [h(CSelectOption, { value: 'a' })], + }, + }); + + expect(wrapper.findComponent(CSelectOption).vm.$el.innerHTML).toBe( + ' ' + ); + }); +}); diff --git a/packages/chusho/src/components/CSelect/CSelectOption.ts b/packages/chusho/src/components/CSelect/CSelectOption.ts new file mode 100644 index 00000000..263288eb --- /dev/null +++ b/packages/chusho/src/components/CSelect/CSelectOption.ts @@ -0,0 +1,113 @@ +import { + computed, + defineComponent, + getCurrentInstance, + h, + inject, + mergeProps, + nextTick, + onMounted, + ref, +} from 'vue'; + +import { DollarChusho } from '../../types'; +import { generateConfigClass } from '../../utils/components'; +import uuid from '../../utils/uuid'; +import componentMixin from '../mixins/componentMixin'; +import { SelectSymbol } from './CSelect'; + +export default defineComponent({ + name: 'CSelectOption', + + mixins: [componentMixin], + + inheritAttrs: false, + + props: { + value: { + type: [String, Number, Array, Object], + required: true, + }, + disabled: { + type: Boolean, + default: false, + }, + }, + + setup(props) { + const select = inject(SelectSymbol); + const id = uuid('chusho-select-option'); + const data = ref({ disabled: props.disabled, text: '' }); + + select?.selected.addItem(id, data); + + onMounted(() => { + const vm = getCurrentInstance(); + if (vm?.proxy) { + const text = vm.proxy.$el.textContent.toLowerCase().trim(); + data.value.text = text; + } + }); + + if (props.value === select?.value.value) { + select?.selected.setSelectedItem(id); + } + + return { + select, + id, + isActive: computed(() => props.value === select?.value.value), + isFocused: computed(() => id === select?.selected.selectedItemId.value), + }; + }, + + beforeUnmount() { + this.select?.selected.removeItem(this.id); + }, + + render() { + const selectOptionConfig = inject('$chusho', null) + ?.options?.components?.selectOption; + + const saveAndClose = () => { + this.select?.setValue(this.value); + this.select?.toggle.close(); + }; + + const elementProps: Record = { + id: this.id, + role: 'option', + tabindex: '-1', + ...(this.isActive ? { 'aria-selected': 'true' } : {}), + ...(this.disabled + ? { 'aria-disabled': 'true' } + : { + onClick: saveAndClose, + onKeydown: (e: KeyboardEvent) => { + if (['Enter', 'Spacebar', ' '].includes(e.key)) { + e.preventDefault(); + e.stopPropagation(); + saveAndClose(); + } + }, + }), + ...generateConfigClass(selectOptionConfig?.class, { + ...this.$props, + active: this.isActive, + focus: this.isFocused, + }), + }; + + if (this.isFocused) { + nextTick(() => { + this.$el.focus(); + }); + } + + return h( + 'li', + mergeProps(this.$attrs, elementProps), + this.$slots.default?.() ?? ' ' + ); + }, +}); diff --git a/packages/chusho/src/components/CSelect/CSelectOptions.spec.js b/packages/chusho/src/components/CSelect/CSelectOptions.spec.js new file mode 100644 index 00000000..1b8198c6 --- /dev/null +++ b/packages/chusho/src/components/CSelect/CSelectOptions.spec.js @@ -0,0 +1,207 @@ +import { mount } from '@vue/test-utils'; +import { h, nextTick } from 'vue'; + +import CSelect from './CSelect'; +import CSelectBtn from './CSelectBtn'; +import CSelectOptions from './CSelectOptions'; +import CSelectOption from './CSelectOption'; + +describe('CSelectOption', () => { + it('renders with the right attributes when open', async () => { + const wrapper = mount(CSelect, { + props: { + modelValue: 'a', + open: true, + }, + slots: { + default: h(CSelectOptions, null, () => [ + h(CSelectOption, { value: 'a' }), + ]), + }, + }); + + await nextTick(); + + expect(wrapper.findComponent(CSelectOptions).attributes()).toEqual({ + id: 'chusho-toggle-1', + role: 'listbox', + 'aria-activedescendant': 'chusho-select-option-2', + }); + }); + + it('does not render when closed', () => { + const wrapper = mount(CSelect, { + props: { + modelValue: 'a', + }, + slots: { + default: h(CSelectOptions), + }, + }); + + expect(wrapper.findComponent(CSelectOptions).html()).toBe(undefined); + }); + + it('renders with config class', () => { + const wrapper = mount(CSelect, { + props: { + open: true, + }, + global: { + provide: { + $chusho: { + options: { + components: { + selectOptions: { + class: 'select-options', + }, + }, + }, + }, + }, + }, + slots: { + default: h(CSelectOptions), + }, + }); + + expect(wrapper.findComponent(CSelectOptions).classes()).toEqual([ + 'select-options', + ]); + }); + + it('save focused element before opening and restore it after closing', async () => { + const wrapper = mount(CSelect, { + attachTo: document.body, + props: { + modelValue: 'a', + open: false, + }, + slots: { + default: [ + h(CSelectBtn), + h(CSelectOptions, null, () => [h(CSelectOption, { value: 'a' })]), + ], + }, + }); + const btn = wrapper.findComponent(CSelectBtn); + const options = wrapper.findComponent(CSelectOptions); + + btn.vm.$el.focus(); + expect(document.activeElement).toBe(btn.vm.$el); + + await btn.trigger('click'); + await nextTick(); + expect(options.vm.activeElement.element.value).toBe(btn.vm.$el); + expect(document.activeElement).toBe( + wrapper.findComponent(CSelectOption).vm.$el + ); + document.body.click(); // Close the select + await nextTick(); + expect(options.vm.activeElement.element.value).toBe(btn.vm.$el); + + wrapper.unmount(); + }); + + it('moves focus using keyboard arrows without looping', async () => { + const wrapper = mount(CSelect, { + attachTo: document.body, + props: { + open: true, + modelValue: 'a', + }, + slots: { + default: h( + CSelectOptions, + {}, + { + default: () => [ + h(CSelectOption, { value: 'a' }, { default: () => 'A' }), + h(CSelectOption, { value: 'b' }, { default: () => 'B' }), + h(CSelectOption, { value: 'c' }, { default: () => 'C' }), + ], + } + ), + }, + }); + const options = wrapper.findComponent(CSelectOptions); + + await nextTick(); + expect(document.activeElement).toBe(options.vm.$el.children[0]); + + await options.trigger('keydown', { key: 'ArrowDown' }); + await nextTick(); + expect(document.activeElement).toBe(options.vm.$el.children[1]); + + await options.trigger('keydown', { key: 'ArrowDown' }); + await nextTick(); + expect(document.activeElement).toBe(options.vm.$el.children[2]); + + await options.trigger('keydown', { key: 'ArrowDown' }); + await nextTick(); + expect(document.activeElement).toBe(options.vm.$el.children[2]); + + await options.trigger('keydown', { key: 'Home' }); + await nextTick(); + expect(document.activeElement).toBe(options.vm.$el.children[0]); + + await options.trigger('keydown', { key: 'End' }); + await nextTick(); + expect(document.activeElement).toBe(options.vm.$el.children[2]); + + await options.trigger('keydown', { key: 'ArrowUp' }); + await nextTick(); + expect(document.activeElement).toBe(options.vm.$el.children[1]); + + wrapper.unmount(); + }); + + it('moves focus to the best match when typing', async (done) => { + const wrapper = mount(CSelect, { + attachTo: document.body, + props: { + open: true, + modelValue: 'r', + }, + slots: { + default: h( + CSelectOptions, + {}, + { + default: () => [ + h(CSelectOption, { value: 'r' }, { default: () => 'Red' }), + h(CSelectOption, { value: 'g' }, { default: () => 'Blue' }), + h(CSelectOption, { value: 'b' }, { default: () => 'Green' }), + ], + } + ), + }, + }); + const options = wrapper.findComponent(CSelectOptions); + + await nextTick(); + expect(document.activeElement).toBe(options.vm.$el.children[0]); + + await options + .findAllComponents(CSelectOption)[0] + .trigger('keydown', { key: 'g' }); + await options + .findAllComponents(CSelectOption)[0] + .trigger('keydown', { key: 'r' }); + await nextTick(); + expect(document.activeElement).toBe(options.vm.$el.children[2]); + + // Wait for the previous search to clear itself before beginning a new one + setTimeout(async () => { + await options + .findAllComponents(CSelectOption)[0] + .trigger('keydown', { key: 'r' }); + await nextTick(); + expect(document.activeElement).toBe(options.vm.$el.children[0]); + + done(); + + wrapper.unmount(); + }, 500); + }); +}); diff --git a/packages/chusho/src/components/CSelect/CSelectOptions.ts b/packages/chusho/src/components/CSelect/CSelectOptions.ts new file mode 100644 index 00000000..9cb2f4a6 --- /dev/null +++ b/packages/chusho/src/components/CSelect/CSelectOptions.ts @@ -0,0 +1,185 @@ +import { + defineComponent, + h, + inject, + mergeProps, + nextTick, + ref, + withDirectives, +} from 'vue'; + +import { DollarChusho } from '../../types'; +import clickOutside from '../../directives/clickOutside/clickOutside'; +import { + generateConfigClass, + renderWithTransition, +} from '../../utils/components'; +import { getNextFocusByKey, calculateActiveIndex } from '../../utils/keyboard'; +import componentMixin from '../mixins/componentMixin'; +import transitionMixin from '../mixins/transitionMixin'; +import { SelectedItem } from '../../composables/useSelected'; +import useActiveElement from '../../composables/useActiveElement'; + +import { SelectOption, SelectOptionData, SelectSymbol } from './CSelect'; + +export default defineComponent({ + name: 'CSelectOptions', + + mixins: [componentMixin, transitionMixin], + + inheritAttrs: false, + + setup() { + const chusho = inject('$chusho', null); + const select = inject(SelectSymbol); + const active = ref(select?.toggle.isOpen.value ?? false); + const query = ref(''); + const searchIndex = ref(0); + const clearQueryTimeout = ref | null>(null); + + return { + chusho, + select, + active, + activeElement: useActiveElement(), + query, + searchIndex, + clearQueryTimeout, + }; + }, + + methods: { + activate() { + if (this.active) return; + this.active = true; + this.activeElement.save(); + }, + + deactivate() { + if (!this.active) return; + this.activeElement.restore(); + this.active = false; + }, + + handleKeydown(e: KeyboardEvent) { + if (!this.select?.selected.selectedItemId.value) return; + + const focus = getNextFocusByKey(e.key); + let newIndex = null; + + if (focus === null) { + newIndex = this.findItemToFocus(e.key); + } else { + newIndex = calculateActiveIndex(focus, { + resolveItems: () => this.select?.selected.items.value ?? [], + resolveActiveIndex: () => + this.select?.selected.selectedItemIndex.value ?? -1, + resolveDisabled: (item) => { + return item.data?.disabled ?? false; + }, + }); + } + + if (newIndex === null) return; + + e.preventDefault(); + + this.select.selected.setSelectedItem( + this.select.selected.items.value[newIndex].id + ); + }, + + findItemToFocus(character: string) { + const items = this.select?.selected.items.value ?? []; + const selectedItemIndex = + this.select?.selected.selectedItemIndex.value ?? -1; + + if (!this.query && selectedItemIndex) { + this.searchIndex = selectedItemIndex; + } + this.query += character; + this.prepareToResetQuery(); + + let nextMatch = this.findMatchInRange( + items, + this.searchIndex + 1, + items.length + ); + if (!nextMatch) { + nextMatch = this.findMatchInRange(items, 0, this.searchIndex); + } + return nextMatch; + }, + + prepareToResetQuery() { + if (this.clearQueryTimeout) { + clearTimeout(this.clearQueryTimeout); + this.clearQueryTimeout = null; + } + this.clearQueryTimeout = setTimeout(() => { + this.query = ''; + this.clearQueryTimeout = null; + }, 500); + }, + + findMatchInRange( + items: SelectedItem[], + startIndex: number, + endIndex: number + ) { + for (let index = startIndex; index < endIndex; index++) { + const item = items[index]; + const label = item.data?.text; + if (!item.data?.disabled && label && label.indexOf(this.query) === 0) { + return index; + } + } + return null; + }, + + renderContent() { + if (!this.select) return null; + + const elementProps: Record = { + ...this.select.toggle.attrs.target.value, + role: 'listbox', + 'aria-activedescendant': this.select.selected.selectedItemId.value, + onKeydown: this.handleKeydown, + ...generateConfigClass( + this.chusho?.options?.components?.selectOptions?.class, + this.$props + ), + }; + + return this.select.toggle.renderIfOpen( + () => { + nextTick(this.activate); + return withDirectives( + h('ul', mergeProps(this.$attrs, elementProps), this.$slots), + [ + [ + clickOutside, + () => { + this.select?.toggle.close(); + }, + ], + ] + ); + }, + () => { + nextTick(this.deactivate); + } + ); + }, + }, + + render() { + const selectOptionsConfig = this.chusho?.options?.components?.selectOptions; + + return renderWithTransition( + this.renderContent, + this.transition, + selectOptionsConfig?.transition + ); + }, +}); diff --git a/packages/chusho/src/components/CSelect/index.ts b/packages/chusho/src/components/CSelect/index.ts new file mode 100644 index 00000000..641504c7 --- /dev/null +++ b/packages/chusho/src/components/CSelect/index.ts @@ -0,0 +1,15 @@ +import CSelect from './CSelect'; +import CSelectBtn from './CSelectBtn'; +import CSelectOptions from './CSelectOptions'; +import CSelectOption from './CSelectOption'; +import CSelectGroup from './CSelectGroup'; +import CSelectGroupLabel from './CSelectGroupLabel'; + +export { + CSelect, + CSelectBtn, + CSelectOptions, + CSelectOption, + CSelectGroup, + CSelectGroupLabel, +}; diff --git a/packages/chusho/src/components/index.ts b/packages/chusho/src/components/index.ts index 5d500548..39f30a54 100644 --- a/packages/chusho/src/components/index.ts +++ b/packages/chusho/src/components/index.ts @@ -5,3 +5,4 @@ export * from './CTabs'; export * from './CDialog'; export * from './CAlert'; export * from './CPicture'; +export * from './CSelect'; diff --git a/packages/chusho/src/types/index.ts b/packages/chusho/src/types/index.ts index 27acd423..d6ed12f8 100644 --- a/packages/chusho/src/types/index.ts +++ b/packages/chusho/src/types/index.ts @@ -57,9 +57,17 @@ interface ComponentsOptions { collapseContent?: ComponentCommonOptions & { transition?: BaseTransitionProps; }; - picture?: { - class?: VueClassBinding | ClassGenerator; + picture?: ComponentCommonOptions; + select?: ComponentCommonOptions; + selectBtn?: ComponentCommonOptions & { + inheritBtnClass: boolean; + }; + selectOptions?: ComponentCommonOptions & { + transition?: BaseTransitionProps; }; + selectOption?: ComponentCommonOptions; + selectGroup?: ComponentCommonOptions; + selectGroupLabel?: ComponentCommonOptions; } export interface ChushoOptions { diff --git a/packages/chusho/src/utils/objects.ts b/packages/chusho/src/utils/objects.ts index 9d6ba747..54a44f93 100644 --- a/packages/chusho/src/utils/objects.ts +++ b/packages/chusho/src/utils/objects.ts @@ -8,6 +8,19 @@ export function isObject(obj: unknown): obj is Record { return obj !== null && typeof obj === 'object'; } +export function isPrimitive( + value: unknown +): value is undefined | boolean | number | string | bigint | symbol { + return [ + 'undefined', + 'boolean', + 'number', + 'string', + 'bigint', + 'symbol', + ].includes(typeof value); +} + export function mergeDeep( /* eslint-disable @typescript-eslint/no-explicit-any */ source: Record = {}, diff --git a/packages/docs/.vuepress/components/Example/Select.vue b/packages/docs/.vuepress/components/Example/Select.vue new file mode 100644 index 00000000..b48d04a7 --- /dev/null +++ b/packages/docs/.vuepress/components/Example/Select.vue @@ -0,0 +1,628 @@ + + + diff --git a/packages/docs/.vuepress/config.js b/packages/docs/.vuepress/config.js index c2331a1c..0e5d04e6 100644 --- a/packages/docs/.vuepress/config.js +++ b/packages/docs/.vuepress/config.js @@ -62,6 +62,7 @@ module.exports = { '/guide/components/dialog.md', '/guide/components/icon.md', '/guide/components/picture.md', + '/guide/components/select.md', '/guide/components/tabs.md', ], }, diff --git a/packages/docs/.vuepress/public/icons.svg b/packages/docs/.vuepress/public/icons.svg index 276b9439..a5013f43 100644 --- a/packages/docs/.vuepress/public/icons.svg +++ b/packages/docs/.vuepress/public/icons.svg @@ -6,6 +6,12 @@ + + + + + + diff --git a/packages/docs/chusho.config.js b/packages/docs/chusho.config.js index a104e02d..92e96d66 100644 --- a/packages/docs/chusho.config.js +++ b/packages/docs/chusho.config.js @@ -91,6 +91,37 @@ export default { class: 'block h-auto rounded-2xl', }, + select: { + class: 'inline-block relative', + }, + + selectBtn: { + class: + 'inline-block w-56 flex justify-between py-2 px-4 border border-gray-400 bg-gray-100 rounded', + }, + + selectOptions: { + class: + 'absolute top-full left-0 z-20 min-w-full max-h-56 overflow-y-auto pl-0 mt-1 bg-gray-50 border border-gray-300 rounded shadow-md', + }, + + selectOption: { + class({ active, disabled }) { + return [ + 'list-none relative pl-8 pr-4 leading-loose outline-none', + { + 'hover:text-blue-700 focus:text-blue-700 hover:bg-blue-100 focus:bg-blue-100 cursor-pointer': !disabled, + 'text-gray-400': disabled, + 'text-blue-800': active, + }, + ]; + }, + }, + + selectGroupLabel: { + class: 'relative px-4 leading-loose font-bold', + }, + tabList: { class: 'flex max-w-full overflow-x-auto overflow-y-hidden mb-6 space-x-6 border-0 border-b border-gray-200', diff --git a/packages/docs/guide/components/select.md b/packages/docs/guide/components/select.md new file mode 100644 index 00000000..6bca7a98 --- /dev/null +++ b/packages/docs/guide/components/select.md @@ -0,0 +1,99 @@ +# Select + +Implements the [combobox design pattern](https://w3c.github.io/aria-practices/#combobox), allowing the user to select an option from a collection of predefined values. + + + + + +## Config + +The options below are to be set in the [global configuration](/guide/config.html) at the following location: + +```js +{ + components: { + select: { ... }, + selectBtn: { ... }, + selectGroup: { ... }, + selectGroupLabel: { ... }, + selectOption: { ... }, + selectOptions: { ... }, + }, +} +``` + +### All components + +### class + +Classes applied to the component root element, except when the prop `bare` is set to `true`. See [styling components](/guide/styling-components/). + +- **type:** `Array | Object | String | (props: Object) => {}` +- **default:** `null` + +#### Example + +Using the `CSelect` component: + +```js +class({ open }) { + return ['select', { + 'select--open': open, + }] +} +``` + +## API + + + +## Examples + +### Simplest + +```vue + + + +``` diff --git a/packages/docs/guide/config.md b/packages/docs/guide/config.md index d4e08ea0..a0289480 100644 --- a/packages/docs/guide/config.md +++ b/packages/docs/guide/config.md @@ -40,6 +40,12 @@ This object accept the following properties, one for each component: dialog: {}, icon: {}, picture: {}, + select: {}, + selectBtn: {}, + selectGroup: {}, + selectGroupLabel: {}, + selectOption: {}, + selectOptions: {}, tabs: {}, tabList: {}, tab: {}, diff --git a/packages/playground/chusho.config.js b/packages/playground/chusho.config.js index 222a936f..4291900f 100644 --- a/packages/playground/chusho.config.js +++ b/packages/playground/chusho.config.js @@ -77,5 +77,35 @@ export default { }, class: 'p-6 bg-gray-100 rounded mt-2', }, + select: { + class: 'inline-block relative', + }, + selectBtn: { + class: ({ disabled }) => { + return [ + 'w-56 flex justify-between py-2 px-4 border border-gray-400 bg-gray-100 rounded', + { 'cursor-not-allowed opacity-50': disabled }, + ]; + }, + }, + selectOptions: { + class: + 'absolute top-full left-0 min-w-full max-h-56 overflow-y-auto mt-1 bg-gray-50 border border-gray-300 rounded shadow-md', + }, + selectOption: { + class({ active, disabled }) { + return [ + 'relative pl-8 pr-4 leading-loose outline-none', + { + 'hover:text-blue-700 focus:text-blue-700 hover:bg-blue-100 focus:bg-blue-100 cursor-pointer': !disabled, + 'text-gray-400': disabled, + 'text-blue-800': active, + }, + ]; + }, + }, + selectGroupLabel: { + class: 'relative px-4 leading-loose font-bold', + }, }, }; diff --git a/packages/playground/package-lock.json b/packages/playground/package-lock.json index fe9f9be7..513b50b8 100644 --- a/packages/playground/package-lock.json +++ b/packages/playground/package-lock.json @@ -2713,6 +2713,12 @@ "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", "dev": true }, + "vee-validate": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/vee-validate/-/vee-validate-4.2.2.tgz", + "integrity": "sha512-YSs9YtmrdeipBLfM0xt/rvWHWqAbFe5L//baFFPPJbeMTBYBnV+YV77l8gG74xSZtpCu25o4Loyszntd6R17/w==", + "dev": true + }, "verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", diff --git a/packages/playground/package.json b/packages/playground/package.json index ac7d1df9..eefcaec2 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -39,6 +39,7 @@ "cypress": "^6.6.0", "cypress-plugin-tab": "^1.0.5", "start-server-and-test": "^1.12.0", + "vee-validate": "^4.2.2", "vite": "^2.0.5" } } diff --git a/packages/playground/public/icons.svg b/packages/playground/public/icons.svg index ee006298..2d2214c4 100644 --- a/packages/playground/public/icons.svg +++ b/packages/playground/public/icons.svg @@ -1,6 +1,20 @@ - - + + + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/packages/playground/src/components/examples/components/select/Default.vue b/packages/playground/src/components/examples/components/select/Default.vue new file mode 100644 index 00000000..3e118c80 --- /dev/null +++ b/packages/playground/src/components/examples/components/select/Default.vue @@ -0,0 +1,627 @@ + + + diff --git a/packages/playground/src/components/examples/components/select/Disabled.vue b/packages/playground/src/components/examples/components/select/Disabled.vue new file mode 100644 index 00000000..4a295d7f --- /dev/null +++ b/packages/playground/src/components/examples/components/select/Disabled.vue @@ -0,0 +1,45 @@ + + + diff --git a/packages/playground/src/components/examples/components/select/DisabledOptions.vue b/packages/playground/src/components/examples/components/select/DisabledOptions.vue new file mode 100644 index 00000000..6b37680a --- /dev/null +++ b/packages/playground/src/components/examples/components/select/DisabledOptions.vue @@ -0,0 +1,66 @@ + + + diff --git a/packages/playground/src/components/examples/components/select/OptionsGroup.vue b/packages/playground/src/components/examples/components/select/OptionsGroup.vue new file mode 100644 index 00000000..0f637ade --- /dev/null +++ b/packages/playground/src/components/examples/components/select/OptionsGroup.vue @@ -0,0 +1,82 @@ + + + diff --git a/packages/playground/src/components/examples/components/select/WithValidation.vue b/packages/playground/src/components/examples/components/select/WithValidation.vue new file mode 100644 index 00000000..a14c7a77 --- /dev/null +++ b/packages/playground/src/components/examples/components/select/WithValidation.vue @@ -0,0 +1,50 @@ + + + diff --git a/packages/playground/src/components/examples/routes.json b/packages/playground/src/components/examples/routes.json index 79e41b15..4437c6ba 100644 --- a/packages/playground/src/components/examples/routes.json +++ b/packages/playground/src/components/examples/routes.json @@ -144,6 +144,31 @@ "component": "WithSources" } ] + }, + "select": { + "label": "Select", + "variants": [ + { + "label": "Default", + "component": "Default" + }, + { + "label": "Disabled Options", + "component": "DisabledOptions" + }, + { + "label": "Options Group", + "component": "OptionsGroup" + }, + { + "label": "With Validation", + "component": "WithValidation" + }, + { + "label": "Disabled", + "component": "Disabled" + } + ] } } },