-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(components): introduce select/listbox component
feat(components): introduce select/listbox component
- Loading branch information
1 parent
f38b759
commit dcda232
Showing
32 changed files
with
3,091 additions
and
5 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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">` | ||
); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
112
packages/chusho/src/components/CSelect/CSelectBtn.spec.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(''); | ||
}); | ||
}); |
Oops, something went wrong.