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 @@
+
+
+
+ {{ value.label }}
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
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
+
+
+
+ {{ value.label }}
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+```
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 @@
+
+
+
+ {{ value.label }}
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
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 @@
+
+
+
+ {{ value.label }}
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
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 @@
+
+
+
+ {{ value.label }}
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
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 @@
+
+
+
+ {{ value.label }}
+ Select a value…
+
+
+
+
+
+ {{ group.label }}
+
+
+ {{ option.label }}
+
+
+
+
+
+
+
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 @@
+
+
+
+ {{ value.label }}
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
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"
+ }
+ ]
}
}
},