Skip to content

Commit

Permalink
feat(components): introduce select/listbox component
Browse files Browse the repository at this point in the history
feat(components): introduce select/listbox component
  • Loading branch information
LeBenLeBen committed Jan 26, 2022
1 parent f38b759 commit dcda232
Show file tree
Hide file tree
Showing 32 changed files with 3,091 additions and 5 deletions.
95 changes: 95 additions & 0 deletions packages/chusho/src/components/CSelect/CSelect.spec.js
Original file line number Diff line number Diff line change
@@ -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(
`<input type="hidden" value="${actualValue}">`
);
}
);

it('forwards the `name` prop to the underlying input', () => {
const wrapper = mount(CSelect, {
props: {
name: 'field-name',
},
});

expect(wrapper.find('input').html()).toEqual(
`<input type="hidden" name="field-name">`
);
});

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(
`<input data-test="my-input" type="hidden">`
);
});
});
170 changes: 170 additions & 0 deletions packages/chusho/src/components/CSelect/CSelect.ts
Original file line number Diff line number Diff line change
@@ -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<UseSelect> = Symbol('CSelect');

type SelectValue = unknown;

export interface SelectOptionData {
disabled: boolean;
text: string;
}

export type SelectOption = SelectedItem<SelectOptionData>;

export interface UseSelect {
uuid: string;
value: ComputedRef<SelectValue>;
setValue: (value: SelectValue) => void;
disabled: ComputedRef<boolean>;
toggle: ReturnType<typeof useToggle>;
selected: UseSelected<SelectOptionData>;
}

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<SelectOptionData>(),
=======
disabled: computed(() => props.disabled),
togglable: useTogglable(props.open, 'open'),
selectable: useSelectable<SelectOptionData>(),
>>>>>>> 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<DollarChusho | null>('$chusho', null)?.options
?.components?.select;
const elementProps: Record<string, unknown> = {
...generateConfigClass(selectConfig?.class, this.$props),
onKeydown: this.handleKeydown,
};
const inputProps: Record<string, unknown> = {
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;
},
});
},
});
112 changes: 112 additions & 0 deletions packages/chusho/src/components/CSelect/CSelectBtn.spec.js
Original file line number Diff line number Diff line change
@@ -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(
'<button aria-expanded="false" aria-controls="chusho-toggle-1" aria-haspopup="listbox" type="button">Label</button>'
);
});

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(
'<button aria-expanded="true" aria-controls="chusho-toggle-1" aria-haspopup="listbox" type="button">Label</button>'
);
});

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('');
});
});

0 comments on commit dcda232

Please sign in to comment.