From 46812e703a00b3022db4fb92b7fe688d78056de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benoi=CC=82t=20Burgener?= Date: Wed, 2 Nov 2022 15:09:02 +0100 Subject: [PATCH] refactor(components): Tabs now use interactiveList compsable under the hood BREAKING CHANGE: CTabs now require a `v-model` or the `default-tab` prop to be set. --- packages/chusho/cypress/e2e/CSelect.cy.js | 2 +- packages/chusho/cypress/e2e/CTabs.cy.js | 112 ----- .../chusho/lib/components/CTabs/CTab.spec.js | 155 ------- packages/chusho/lib/components/CTabs/CTab.ts | 28 +- .../lib/components/CTabs/CTabList.spec.js | 89 ---- .../chusho/lib/components/CTabs/CTabList.ts | 55 +-- .../lib/components/CTabs/CTabPanel.spec.js | 102 ----- .../chusho/lib/components/CTabs/CTabPanel.ts | 26 +- .../lib/components/CTabs/CTabPanels.spec.js | 30 -- .../chusho/lib/components/CTabs/CTabs.cy.tsx | 418 ++++++++++++++++++ .../chusho/lib/components/CTabs/CTabs.spec.js | 37 -- packages/chusho/lib/components/CTabs/CTabs.ts | 50 ++- .../__snapshots__/useSelectable.spec.js.snap | 50 --- .../composables/useInteractiveList.spec.js | 10 +- .../lib/composables/useInteractiveList.ts | 45 +- .../lib/composables/useInteractiveListItem.ts | 65 ++- .../lib/composables/useSelectable.spec.js | 101 ----- .../chusho/lib/composables/useSelectable.ts | 94 ---- packages/chusho/package.json | 2 +- packages/chusho/src/chusho.config.ts | 17 +- .../examples/components/tabs/Default.vue | 2 +- .../examples/components/tabs/Dynamic.vue | 2 +- packages/docs/.vuepress/components/Docgen.vue | 4 +- packages/docs/guide/components/tabs.md | 4 +- 24 files changed, 583 insertions(+), 917 deletions(-) delete mode 100644 packages/chusho/cypress/e2e/CTabs.cy.js delete mode 100644 packages/chusho/lib/components/CTabs/CTab.spec.js delete mode 100644 packages/chusho/lib/components/CTabs/CTabList.spec.js delete mode 100644 packages/chusho/lib/components/CTabs/CTabPanel.spec.js delete mode 100644 packages/chusho/lib/components/CTabs/CTabPanels.spec.js create mode 100644 packages/chusho/lib/components/CTabs/CTabs.cy.tsx delete mode 100644 packages/chusho/lib/components/CTabs/CTabs.spec.js delete mode 100644 packages/chusho/lib/composables/__snapshots__/useSelectable.spec.js.snap delete mode 100644 packages/chusho/lib/composables/useSelectable.spec.js delete mode 100644 packages/chusho/lib/composables/useSelectable.ts diff --git a/packages/chusho/cypress/e2e/CSelect.cy.js b/packages/chusho/cypress/e2e/CSelect.cy.js index 4fe0f5ac..31d98a79 100644 --- a/packages/chusho/cypress/e2e/CSelect.cy.js +++ b/packages/chusho/cypress/e2e/CSelect.cy.js @@ -5,7 +5,7 @@ describe('Select', () => { }); it('links SelectBtn with SelectOptions', () => { - cy.get('[data-test="select-button"]').trigger('click'); + cy.get('[data-test="select-button"]').click(); cy.get('[data-test="select-button"]').then(($el) => { cy.wrap($el).should( 'have.attr', diff --git a/packages/chusho/cypress/e2e/CTabs.cy.js b/packages/chusho/cypress/e2e/CTabs.cy.js deleted file mode 100644 index 6f837b9b..00000000 --- a/packages/chusho/cypress/e2e/CTabs.cy.js +++ /dev/null @@ -1,112 +0,0 @@ -describe('Tabs', () => { - describe('default', () => { - beforeEach(() => { - cy.visitComponent('tabs/default'); - }); - - it('renders only the first tab panel by default', () => { - cy.get('[data-test="tabpanel-1"]').should('be.visible'); - cy.get('[data-test="tabpanel-2"]').should('not.exist'); - cy.get('[data-test="tabpanel-3"]').should('not.exist'); - }); - - it('handle keyboard navigation properly', () => { - cy.get('[data-test="tab-1"]') - .focus() - .trigger('keydown', { key: 'Right' }); - cy.get('[data-test="tab-2"]') - .should('have.focus') - .trigger('keydown', { key: 'Right' }); - cy.get('[data-test="tab-3"]') - .should('have.focus') - .trigger('keydown', { key: 'Right' }); - cy.get('[data-test="tab-1"]') - .should('have.focus') - .trigger('keydown', { key: 'Left' }); - cy.get('[data-test="tab-3"]') - .should('have.focus') - .trigger('keydown', { key: 'Home' }); - cy.get('[data-test="tab-1"]') - .should('have.focus') - .trigger('keydown', { key: 'End' }); - cy.get('[data-test="tab-3"]').should('have.focus'); - }); - - it('links Tab with TabPanel', () => { - cy.get('[data-test="tab-2"]').trigger('click'); - cy.get('[data-test="tab-2"]').then(($el) => { - cy.wrap($el).should( - 'have.attr', - 'aria-controls', - Cypress.$(`[data-test="tabpanel-2"]`).attr('id') - ); - }); - }); - }); - - describe('controlled', () => { - beforeEach(() => { - cy.visitComponent('tabs/controlled'); - }); - - it('display the v-model tab', () => { - cy.get('[data-test="tab-2"]').should( - 'have.attr', - 'aria-selected', - 'true' - ); - cy.get('[data-test="tabpanel-2"]').should('be.visible'); - }); - }); - - describe('override style', () => { - beforeEach(() => { - cy.visitComponent('tabs/override-style'); - }); - - it('display the tab based on "defaultTab" prop', () => { - cy.get('[data-test="tab-3"]').should( - 'have.attr', - 'aria-selected', - 'true' - ); - cy.get('[data-test="tabpanel-3"]').should('be.visible'); - }); - }); - - describe('dynamic', () => { - beforeEach(() => { - cy.visitComponent('tabs/dynamic'); - }); - - it('handles dynamically added tabs', () => { - cy.contains('Tab 4').should('not.exist'); - cy.contains('Add tab').click(); - cy.contains('Tab 4').click(); - cy.get('[data-test="tabpanels"]').should('contain', 'Tab 4 content'); - cy.focused() - .trigger('keydown', { key: 'Right' }) - .trigger('keydown', { key: 'Right' }) - .trigger('keydown', { key: 'Right' }) - .trigger('keydown', { key: 'Right' }); - cy.focused().should('have.attr', 'data-test', 'tab-4'); - }); - - it('handles dynamically removed tabs', () => { - cy.contains('Tab 2').click(); - cy.contains('Remove tab').click(); - cy.get('[data-test="tablist"] [data-test^="tab"]').should( - 'have.length', - 2 - ); - cy.contains('Tab 1').click().trigger('keydown', { key: 'Right' }); - cy.focused().should('have.attr', 'data-test', 'tab-2'); - }); - - it('activates first tab when currently active tab is removed', () => { - cy.contains('Tab 3').click(); - cy.contains('Remove tab').click(); - cy.contains('Tab 1').should('have.attr', 'aria-selected', 'true'); - }); - }); -}); diff --git a/packages/chusho/lib/components/CTabs/CTab.spec.js b/packages/chusho/lib/components/CTabs/CTab.spec.js deleted file mode 100644 index 45cb0dc6..00000000 --- a/packages/chusho/lib/components/CTabs/CTab.spec.js +++ /dev/null @@ -1,155 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { h, nextTick } from 'vue'; - -import CTab from './CTab'; -import CTabList from './CTabList'; -import CTabPanel from './CTabPanel'; -import CTabPanels from './CTabPanels'; -import CTabs from './CTabs'; - -describe('CTab', () => { - it('renders with the right attributes', async () => { - const wrapper = mount(CTabs, { - slots: { - default: () => [ - h(CTabList, null, { - default: () => [ - h(CTab, { - target: '1', - }), - h(CTab, { - target: '2', - }), - ], - }), - h(CTabPanels, null, { - default: () => [ - h(CTabPanel, { - id: '1', - }), - h(CTabPanel, { - id: '2', - }), - ], - }), - ], - }, - }); - - // Let it set the first tab as default - await nextTick(); - - const tabs = wrapper.findAllComponents(CTab); - expect(tabs[0].html()).toEqual( - '' - ); - expect(tabs[1].html()).toEqual( - '' - ); - }); - - it('renders with config class', () => { - const wrapper = mount(CTabs, { - global: { - provide: { - $chusho: { - options: { - components: { - tab: { - class: ({ active }) => { - return ['tab', { active }]; - }, - }, - }, - }, - }, - }, - }, - props: { - defaultTab: '1', - }, - slots: { - default: h(CTab, { target: '1' }), - }, - }); - - expect(wrapper.findComponent(CTab).classes()).toEqual(['tab', 'active']); - }); - - it('handles click to set active tab', async () => { - const wrapper = mount(CTabs, { - slots: { - default: () => [ - h(CTabList, null, { - default: () => [ - h(CTab, { - target: '1', - }), - h(CTab, { - target: '2', - }), - ], - }), - h(CTabPanels, null, { - default: () => [ - h(CTabPanel, { - id: '1', - }), - h(CTabPanel, { - id: '2', - }), - ], - }), - ], - }, - }); - - expect(wrapper.vm.tabs.selectedItemId.value).toBe('1'); - - // Let it set the first tab as default - await nextTick(); - await wrapper.find('[role="tab"][aria-selected="false"]').trigger('click'); - - expect(wrapper.vm.tabs.selectedItemId.value).toBe('2'); - }); - - it('accepts 0 as a valid target value', async () => { - const wrapper = mount(CTabs, { - props: { - defaultTab: 1, - }, - slots: { - default: () => [ - h(CTabList, null, { - default: () => [ - h(CTab, { - target: 0, - }), - h(CTab, { - target: 1, - }), - ], - }), - h(CTabPanels, null, { - default: () => [ - h(CTabPanel, { - id: 0, - }), - h(CTabPanel, { - id: 1, - }), - ], - }), - ], - }, - }); - - expect(wrapper.vm.tabs.selectedItemId.value).toBe(1); - - // Let it set the first tab as default - await nextTick(); - await wrapper.find('[role="tab"][aria-selected="false"]').trigger('click'); - - expect(wrapper.vm.tabs.selectedItemId.value).toBe(0); - }); -}); diff --git a/packages/chusho/lib/components/CTabs/CTab.ts b/packages/chusho/lib/components/CTabs/CTab.ts index 9dd1d2a4..7447440c 100644 --- a/packages/chusho/lib/components/CTabs/CTab.ts +++ b/packages/chusho/lib/components/CTabs/CTab.ts @@ -1,9 +1,10 @@ -import { PropType, defineComponent, h, inject, mergeProps } from 'vue'; +import { PropType, defineComponent, h, inject, mergeProps, toRef } from 'vue'; import componentMixin from '../mixins/componentMixin'; import useComponentConfig from '../../composables/useComponentConfig'; -import { SelectedItemId } from '../../composables/useSelectable'; +import { InteractiveItemId } from '../../composables/useInteractiveList'; +import useInteractiveListItem from '../../composables/useInteractiveListItem'; import { generateConfigClass } from '../../utils/components'; @@ -19,37 +20,40 @@ export default defineComponent({ props: { /** * The id of the Tab this button should control. + * + * @type {string|number} */ target: { - type: [String, Number] as PropType, + type: [String, Number] as PropType, required: true, }, }, - setup() { + setup(props) { const tabs = inject(TabsSymbol); + const interactiveListItem = useInteractiveListItem({ + id: props.target, + value: toRef(props, 'target'), + }); return { config: useComponentConfig('tab'), tabs, + interactiveListItem, }; }, render() { if (!this.tabs) return null; - const isActive = this.target === this.tabs.selectedItemId.value; + const isActive = this.interactiveListItem.selected.value; const elementProps = { + ref: this.interactiveListItem.itemRef, + ...this.interactiveListItem.attrs, + ...this.interactiveListItem.events, type: 'button', id: `${this.tabs.uid.id.value}-tab-${this.target}`, - role: 'tab', - 'aria-selected': `${isActive}`, 'aria-controls': `${this.tabs.uid.id.value}-tabpanel-${this.target}`, - tabindex: isActive ? '0' : '-1', - onClick: () => { - if (!['string', 'number'].includes(typeof this.target)) return; - this.tabs?.setSelectedItem(this.target); - }, ...generateConfigClass(this.config?.class, { ...this.$props, active: isActive, diff --git a/packages/chusho/lib/components/CTabs/CTabList.spec.js b/packages/chusho/lib/components/CTabs/CTabList.spec.js deleted file mode 100644 index dcb948e3..00000000 --- a/packages/chusho/lib/components/CTabs/CTabList.spec.js +++ /dev/null @@ -1,89 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { h, nextTick } from 'vue'; - -import CTab from './CTab'; -import CTabList from './CTabList'; -import CTabPanel from './CTabPanel'; -import CTabPanels from './CTabPanels'; -import CTabs from './CTabs'; - -describe('CTabList', () => { - it('renders with the right attributes', () => { - const wrapper = mount(CTabs, { - slots: { - default: h(CTabList), - }, - }); - - expect(wrapper.findComponent(CTabList).html()).toEqual( - '
' - ); - }); - - it('renders with config class', () => { - const wrapper = mount(CTabs, { - global: { - provide: { - $chusho: { - options: { - components: { - tabList: { - class: 'tab-list', - }, - }, - }, - }, - }, - }, - slots: { - default: h(CTabList), - }, - }); - - expect(wrapper.findComponent(CTabList).classes()).toEqual(['tab-list']); - }); - - it('handles keypress to change active tab', async () => { - const wrapper = mount(CTabs, { - attachTo: document.body, - slots: { - default: () => [ - h(CTabList, null, { - default: () => [ - h(CTab, { - target: '1', - }), - h(CTab, { - target: '2', - }), - ], - }), - h(CTabPanels, null, { - default: () => [ - h(CTabPanel, { - id: '1', - }), - h(CTabPanel, { - id: '2', - }), - ], - }), - ], - }, - }); - - // Let it set the first tab as default - await nextTick(); - await wrapper - .find('[role="tab"][aria-selected="true"]') - .trigger('keydown', { key: 'ArrowRight' }); - await nextTick(); - - expect(wrapper.vm.tabs.selectedItemId.value).toBe('2'); - expect(wrapper.findAllComponents(CTab)[1].vm.$el).toBe( - document.activeElement - ); - - wrapper.unmount(); - }); -}); diff --git a/packages/chusho/lib/components/CTabs/CTabList.ts b/packages/chusho/lib/components/CTabs/CTabList.ts index 03ac0392..d3aa87a7 100644 --- a/packages/chusho/lib/components/CTabs/CTabList.ts +++ b/packages/chusho/lib/components/CTabs/CTabList.ts @@ -1,14 +1,13 @@ -import { defineComponent, h, inject, mergeProps, nextTick } from 'vue'; +import { defineComponent, h, inject, mergeProps } from 'vue'; import { DollarChusho } from '../../types'; import componentMixin from '../mixins/componentMixin'; import useComponentConfig from '../../composables/useComponentConfig'; -import { SelectedItem } from '../../composables/useSelectable'; +import { UseInteractiveListSymbol } from '../../composables/useInteractiveList'; import { generateConfigClass } from '../../utils/components'; -import { calculateActiveIndex, getNextFocusByKey } from '../../utils/keyboard'; import { TabsSymbol } from './CTabs'; @@ -22,59 +21,25 @@ export default defineComponent({ setup() { const chusho = inject('$chusho', null); const tabs = inject(TabsSymbol); + const interactiveList = inject(UseInteractiveListSymbol); + + if (!interactiveList) { + throw new Error('CTabList must be used inside CTabs'); + } return { config: useComponentConfig('tabList'), chusho, tabs, + interactiveList, }; }, - methods: { - handleNavigation(e: KeyboardEvent): void { - if (!this.tabs?.selectedItem.value) return; - - const rtl = this.chusho?.options?.rtl; - const focus = getNextFocusByKey(e.key, rtl && rtl()); - - if (focus === null) return; - - const newIndex = calculateActiveIndex( - focus, - { - resolveItems: () => this.tabs?.items.value ?? [], - resolveDisabled: () => false, - resolveActiveIndex: () => this.tabs?.selectedItemIndex.value ?? null, - }, - true - ); - - if (newIndex === null) return; - - e.preventDefault(); - - this.tabs.setSelectedItem(this.tabs.items.value[newIndex].id); - - nextTick(() => { - const tabList = this.$refs.tabList as HTMLElement | null; - - if (tabList) { - const activeTab: HTMLElement | null = tabList.querySelector( - '[aria-selected="true"]' - ); - if (activeTab) { - activeTab.focus(); - } - } - }); - }, - }, - render() { const elementProps: Record = { - role: 'tablist', - onKeydown: this.handleNavigation, ref: 'tabList', + ...this.interactiveList.attrs, + ...this.interactiveList.events, ...generateConfigClass(this.config?.class, this.$props), }; diff --git a/packages/chusho/lib/components/CTabs/CTabPanel.spec.js b/packages/chusho/lib/components/CTabs/CTabPanel.spec.js deleted file mode 100644 index 357d2762..00000000 --- a/packages/chusho/lib/components/CTabs/CTabPanel.spec.js +++ /dev/null @@ -1,102 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { h } from 'vue'; - -import CTab from './CTab'; -import CTabList from './CTabList'; -import CTabPanel from './CTabPanel'; -import CTabPanels from './CTabPanels'; -import CTabs from './CTabs'; - -describe('CTabPanel', () => { - it('renders only the currently active panel with the right attributes', async () => { - const wrapper = mount(CTabs, { - props: { - defaultTab: '2', - }, - slots: { - default: () => [ - h(CTabList, null, { - default: () => [ - h(CTab, { - target: '1', - }), - h(CTab, { - target: '2', - }), - ], - }), - h(CTabPanels, null, { - default: () => [ - h(CTabPanel, { - id: '1', - }), - h(CTabPanel, { - id: '2', - }), - ], - }), - ], - }, - }); - - const panels = wrapper.findAllComponents(CTabPanel); - expect(panels[0].html()).toEqual(''); - expect(panels[1].html()).toEqual( - '
' - ); - }); - - it('renders with config class', () => { - const wrapper = mount(CTabs, { - global: { - provide: { - $chusho: { - options: { - components: { - tabPanel: { - class: ({ active }) => { - return ['tab-panel', { active }]; - }, - }, - }, - }, - }, - }, - }, - slots: { - default: h(CTabPanel, { id: '1' }), - }, - }); - - expect(wrapper.findComponent(CTabPanel).classes()).toEqual([ - 'tab-panel', - 'active', - ]); - }); - - it('registers itself as a tab with id', () => { - const wrapper = mount(CTabs, { - slots: { - default: h(CTabPanel, { id: '1' }), - }, - }); - - expect(wrapper.vm.tabs.items.value).toEqual([ - { - id: '1', - }, - ]); - }); - - it('unregisters itself when unmounted', () => { - const wrapper = mount(CTabs, { - slots: { - default: h(CTabPanel, { id: '1' }), - }, - }); - - expect(wrapper.vm.tabs.items.value.length).toBe(1); - wrapper.unmount(); - expect(wrapper.vm.tabs.items.value.length).toBe(0); - }); -}); diff --git a/packages/chusho/lib/components/CTabs/CTabPanel.ts b/packages/chusho/lib/components/CTabs/CTabPanel.ts index ae136f38..6ec96c49 100644 --- a/packages/chusho/lib/components/CTabs/CTabPanel.ts +++ b/packages/chusho/lib/components/CTabs/CTabPanel.ts @@ -3,7 +3,10 @@ import { PropType, defineComponent, h, inject, mergeProps } from 'vue'; import componentMixin from '../mixins/componentMixin'; import useComponentConfig from '../../composables/useComponentConfig'; -import { SelectedItemId } from '../../composables/useSelectable'; +import { + InteractiveItemId, + UseInteractiveListSymbol, +} from '../../composables/useInteractiveList'; import { generateConfigClass } from '../../utils/components'; @@ -19,37 +22,34 @@ export default defineComponent({ props: { /** * A unique ID to target the panel with CTab. + * + * @type {string|number} */ id: { - type: [String, Number] as PropType, + type: [String, Number] as PropType, required: true, }, }, - setup(props) { + setup() { const tabs = inject(TabsSymbol); + const interactiveList = inject(UseInteractiveListSymbol); - tabs?.addItem(props.id); - - // Set the first tab as active if none has been defined - if (tabs?.items.value.length === 1 && !tabs?.selectedItemId.value) { - tabs.setSelectedItem(props.id); + if (!interactiveList) { + throw new Error('CTabList must be used inside CTabs'); } return { config: useComponentConfig('tabPanel'), tabs, + interactiveList, }; }, - beforeUnmount() { - this.tabs?.removeItem(this.id); - }, - render() { if (!this.tabs) return; - const isActive = this.id === this.tabs.selectedItemId.value; + const isActive = this.id === this.interactiveList.selection.value; if (!isActive) return null; diff --git a/packages/chusho/lib/components/CTabs/CTabPanels.spec.js b/packages/chusho/lib/components/CTabs/CTabPanels.spec.js deleted file mode 100644 index 012d7944..00000000 --- a/packages/chusho/lib/components/CTabs/CTabPanels.spec.js +++ /dev/null @@ -1,30 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { h } from 'vue'; - -import CTabPanels from './CTabPanels'; -import CTabs from './CTabs'; - -describe('CTabPanels', () => { - it('renders with config class', () => { - const wrapper = mount(CTabs, { - global: { - provide: { - $chusho: { - options: { - components: { - tabPanels: { - class: 'tab-panels', - }, - }, - }, - }, - }, - }, - slots: { - default: h(CTabPanels), - }, - }); - - expect(wrapper.findComponent(CTabPanels).classes()).toEqual(['tab-panels']); - }); -}); diff --git a/packages/chusho/lib/components/CTabs/CTabs.cy.tsx b/packages/chusho/lib/components/CTabs/CTabs.cy.tsx new file mode 100644 index 00000000..0a2510e0 --- /dev/null +++ b/packages/chusho/lib/components/CTabs/CTabs.cy.tsx @@ -0,0 +1,418 @@ +import { ref } from 'vue'; + +import { ChushoUserOptions } from '../../types'; + +import { CTab, CTabList, CTabPanel, CTabPanels, CTabs } from '.'; + +describe('CTabs', () => { + it('should apply local and global classes', () => { + const value = '1'; + + cy.mount( + + + + Tab 1 + + + Tab 2 + + + + + + Panel 1 + + + Panel 2 + + + , + { + global: { + provide: { + $chusho: { + options: { + components: { + tabs: { + class: 'config-tabs', + }, + tabList: { + class: 'config-tablist', + }, + tab: { + class({ active }) { + return { + 'config-tab': true, + active, + }; + }, + }, + tabPanels: { + class: 'config-tabpanels', + }, + tabPanel: { + class({ active }) { + return { + 'config-tabpanel': true, + active, + }; + }, + }, + }, + } as ChushoUserOptions, + }, + }, + }, + } + ); + + cy.get('[data-test="tabs"]').should( + 'have.attr', + 'class', + 'tabs config-tabs' + ); + cy.get('[data-test="tablist"]').should( + 'have.attr', + 'class', + 'tablist config-tablist' + ); + cy.get('[data-test="tab-1"]').should( + 'have.attr', + 'class', + 'tab config-tab active' + ); + cy.get('[data-test="tab-2"]').should( + 'have.attr', + 'class', + 'tab config-tab' + ); + cy.get('[data-test="tabpanels"]').should( + 'have.attr', + 'class', + 'tabpanels config-tabpanels' + ); + cy.get('[data-test="tabpanel-1"]').should( + 'have.attr', + 'class', + 'tabpanel config-tabpanel active' + ); + }); + + it('should apply the right attributes', () => { + cy.mount( + + + + Tab 1 + + + Tab 2 + + + + + + Panel 1 + + + Panel 2 + + + + ); + + cy.get('[data-test="tablist"]').should('have.attr', 'role', 'tablist'); + + cy.get('button[data-test="tab-1"]') + .should('have.attr', 'role', 'tab') + .and('have.attr', 'type', 'button') + .and('have.attr', 'aria-selected', 'true') + .and('have.attr', 'tabindex', '0') + .then(([tab]) => { + cy.get('[data-test="tabpanel-1"]') + .should('have.attr', 'role', 'tabpanel') + .should('have.attr', 'tabindex', '0') + .and('have.attr', 'id', tab.getAttribute('aria-controls')) + .and('have.attr', 'aria-labelledby', tab.getAttribute('id')); + }); + + cy.get('button[data-test="tab-2"]') + .should('have.attr', 'role', 'tab') + .and('have.attr', 'type', 'button') + .and('have.attr', 'aria-selected', 'false') + .and('have.attr', 'tabindex', '-1'); + + cy.get('[data-test="tabpanel-2"]').should('not.exist'); + }); + + it('activates the default tab', () => { + cy.mount( + + + + Tab 1 + + + Tab 2 + + + Tab 3 + + + + + + Panel 1 + + + Panel 2 + + + Panel 3 + + + + ); + + cy.get('[data-test="tab-1"]') + .should('have.attr', 'aria-selected', 'true') + .and('have.attr', 'tabindex', '0'); + cy.get('[data-test="tabpanel-1"]').should('be.visible'); + }); + + it('activates the v-model tab, updates it and watch it', () => { + const activeTab = ref('2'); + + const wrapper = cy.mount( + { + wrapper.then((wrapper) => wrapper.setProps({ modelValue })); + }} + data-test="tabs" + > + + + Tab 1 + + + Tab 2 + + + Tab 3 + + + + + + Panel 1 + + + Panel 2 + + + Panel 3 + + + + ); + + cy.get('[data-test="tab-2"]') + .should('have.attr', 'aria-selected', 'true') + .and('have.attr', 'tabindex', '0'); + cy.get('[data-test="tabpanel-2"]').should('be.visible'); + + cy.get('[data-test="tab-3"]').click(); + cy.get('[data-test="tab-1"]').click(); + + cy.getWrapper().then((wrapper) => { + expect(wrapper.emitted('update:modelValue')).to.deep.eq([['3'], ['1']]); + + wrapper.setProps({ modelValue: '2' }); + + cy.get('[data-test="tab-2"]') + .should('have.attr', 'aria-selected', 'true') + .and('have.attr', 'tabindex', '0'); + }); + }); + + it('v-model works with numbers, including 0', () => { + const activeTab = ref(0); + + const wrapper = cy.mount( + { + wrapper.then((wrapper) => wrapper.setProps({ modelValue })); + }} + data-test="tabs" + > + + + Tab 1 + + + Tab 2 + + + Tab 3 + + + + + + Panel 1 + + + Panel 2 + + + Panel 3 + + + + ); + + cy.get('[data-test="tab-0"]') + .should('have.attr', 'aria-selected', 'true') + .and('have.attr', 'tabindex', '0'); + cy.get('[data-test="tabpanel-0"]').should('be.visible'); + + cy.get('[data-test="tab-2"]').click(); + cy.get('[data-test="tab-1"]').click(); + + cy.getWrapper().then((wrapper) => { + expect(wrapper.emitted('update:modelValue')).to.deep.eq([[2], [1]]); + + wrapper.setProps({ modelValue: 0 }); + + cy.get('[data-test="tab-0"]') + .should('have.attr', 'aria-selected', 'true') + .and('have.attr', 'tabindex', '0'); + }); + }); + + it('can be navigated with a keyboard', () => { + cy.mount( + + + + Tab 1 + + + Tab 2 + + + Tab 3 + + + + + + Panel 1 + + + Panel 2 + + + Panel 3 + + + + ); + + cy.get('[data-test="tab-1"]') + .should('not.be.focused') + .click() + .trigger('keydown', { key: 'ArrowRight' }); + cy.get('[data-test="tab-2"]') + .should('be.focused') + .trigger('keydown', { key: 'ArrowDown' }); + cy.get('[data-test="tab-3"]') + .should('be.focused') + .trigger('keydown', { key: 'ArrowRight' }); + // Should loop back to the first item + cy.get('[data-test="tab-1"]') + .should('be.focused') + .trigger('keydown', { key: 'End' }); + cy.get('[data-test="tab-3"]') + .should('be.focused') + .trigger('keydown', { key: 'Home' }); + // Should loop back to the last item + cy.get('[data-test="tab-1"]') + .should('be.focused') + .trigger('keydown', { key: 'ArrowLeft' }); + cy.get('[data-test="tab-3"]') + .should('be.focused') + .trigger('keydown', { key: 'ArrowUp' }); + cy.get('[data-test="tab-2"]').should('be.focused'); + }); + + describe('dynamic', () => { + const tabs = ref([]); + + beforeEach(() => { + tabs.value = [1, 2, 3]; + + cy.mount( + + + {tabs.value.map((tab) => ( + + Tab {tab} + + ))} + + + + {tabs.value.map((tab) => ( + + Panel {tab} + + ))} + + + ); + }); + + it('handles dynamically added tabs', () => { + cy.contains('Tab 4').should('not.exist'); + + cy.getWrapper().then(() => { + tabs.value.push(4); + }); + + cy.contains('Tab 4').click(); + cy.get('[data-test="tabpanels"]').should('contain', 'Panel 4'); + cy.focused() + .trigger('keydown', { key: 'Right' }) + .trigger('keydown', { key: 'Right' }) + .trigger('keydown', { key: 'Right' }) + .trigger('keydown', { key: 'Right' }); + cy.focused().should('have.attr', 'data-test', 'tab-4'); + }); + + it('handles dynamically removed tabs', () => { + cy.contains('Tab 2').click(); + + cy.getWrapper().then(() => { + tabs.value.pop(); + }); + + cy.get('[data-test="tablist"] [role="tab"]').should('have.length', 2); + cy.contains('Tab 1').click().trigger('keydown', { key: 'Right' }); + cy.focused().should('have.attr', 'data-test', 'tab-2'); + }); + + it('activates first tab when currently active tab is removed', () => { + cy.contains('Tab 3').click(); + + cy.getWrapper().then(() => { + tabs.value.pop(); + }); + + cy.contains('Tab 1').should('have.attr', 'aria-selected', 'true'); + }); + }); +}); diff --git a/packages/chusho/lib/components/CTabs/CTabs.spec.js b/packages/chusho/lib/components/CTabs/CTabs.spec.js deleted file mode 100644 index 815cd626..00000000 --- a/packages/chusho/lib/components/CTabs/CTabs.spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { h } from 'vue'; - -import CTabList from './CTabList'; -import CTabs from './CTabs'; - -describe('CTabs', () => { - it('provides tabs API', () => { - const wrapper = mount(CTabs, { - slots: { - default: h(CTabList), - }, - }); - - expect(wrapper.findComponent(CTabList).vm.tabs).toEqual(wrapper.vm.tabs); - }); - - it('renders with config class', () => { - const wrapper = mount(CTabs, { - global: { - provide: { - $chusho: { - options: { - components: { - tabs: { - class: 'tabs', - }, - }, - }, - }, - }, - }, - }); - - expect(wrapper.classes()).toEqual(['tabs']); - }); -}); diff --git a/packages/chusho/lib/components/CTabs/CTabs.ts b/packages/chusho/lib/components/CTabs/CTabs.ts index 4d0b77fe..ec9956e7 100644 --- a/packages/chusho/lib/components/CTabs/CTabs.ts +++ b/packages/chusho/lib/components/CTabs/CTabs.ts @@ -5,22 +5,23 @@ import { h, mergeProps, provide, + watch, } from 'vue'; import componentMixin from '../mixins/componentMixin'; import useCachedUid, { UseCachedUid } from '../../composables/useCachedUid'; import useComponentConfig from '../../composables/useComponentConfig'; -import useSelectable, { - SelectedItemId, - UseSelectable, -} from '../../composables/useSelectable'; +import useInteractiveList, { + InteractiveItemId, + InteractiveListRoles, +} from '../../composables/useInteractiveList'; import { generateConfigClass } from '../../utils/components'; export const TabsSymbol: InjectionKey = Symbol('CTabs'); -export interface Tabs extends UseSelectable { +export interface Tabs { uid: UseCachedUid; } @@ -34,18 +35,20 @@ export default defineComponent({ props: { /** * Optionally bind the Tabs state with the parent component. + * + * @type {string|number} */ modelValue: { - type: [String, Number] as PropType, + type: [String, Number] as PropType, default: null, }, /** - * The id of the Tab to display by default. This is ignored if `v-model` is used. + * The id of the Tab to display by default. This value is ignored if `v-model` is used and **required** otherwise. * - * Defaults to the first tab. + * @type {string|number} */ defaultTab: { - type: [String, Number] as PropType, + type: [String, Number] as PropType, default: null, }, }, @@ -53,13 +56,33 @@ export default defineComponent({ emits: ['update:modelValue'], setup(props) { - const selected = useSelectable( - props.modelValue ?? props.defaultTab ?? null, - 'modelValue' + if (props.modelValue === null && props.defaultTab === null) { + throw new Error( + 'CTabs requires either a `v-model` or the `defaultTab` prop to be set.' + ); + } + + const interactiveList = useInteractiveList({ + role: InteractiveListRoles.tablist, + initialValue: props.modelValue ?? props.defaultTab ?? null, + initialActiveItem: props.modelValue ?? props.defaultTab ?? null, + loop: true, + skipDisabled: true, + autoSelect: true, + }); + + // Update selected tab when `v-model` changes + watch( + () => props.modelValue, + (val, oldVal) => { + if (val !== oldVal && ['string', 'number'].includes(typeof val)) { + interactiveList.selectItem(val); + } + } ); + const tabs: Tabs = { uid: useCachedUid('chusho-tabs'), - ...selected, }; provide(TabsSymbol, tabs); @@ -67,6 +90,7 @@ export default defineComponent({ return { config: useComponentConfig('tabs'), tabs, + interactiveList, }; }, diff --git a/packages/chusho/lib/composables/__snapshots__/useSelectable.spec.js.snap b/packages/chusho/lib/composables/__snapshots__/useSelectable.spec.js.snap deleted file mode 100644 index c0092bf5..00000000 --- a/packages/chusho/lib/composables/__snapshots__/useSelectable.spec.js.snap +++ /dev/null @@ -1,50 +0,0 @@ -// Vitest Snapshot v1 - -exports[`useSelectable > addItem adds item to the list of items 1`] = ` -[ - { - "id": "1", - }, - { - "id": "2", - }, - { - "id": "3", - }, -] -`; - -exports[`useSelectable > removeItem removes item from the list of items 1`] = ` -[ - { - "id": "1", - }, - { - "id": "2", - }, - { - "id": "3", - }, -] -`; - -exports[`useSelectable > removeItem removes item from the list of items 2`] = ` -[ - { - "id": "1", - }, - { - "id": "3", - }, -] -`; - -exports[`useSelectable > removeItem removes item from the list of items 3`] = ` -[ - { - "id": "1", - }, -] -`; - -exports[`useSelectable > removeItem removes item from the list of items 4`] = `[]`; diff --git a/packages/chusho/lib/composables/useInteractiveList.spec.js b/packages/chusho/lib/composables/useInteractiveList.spec.js index 0ee426bc..0984d46c 100644 --- a/packages/chusho/lib/composables/useInteractiveList.spec.js +++ b/packages/chusho/lib/composables/useInteractiveList.spec.js @@ -170,7 +170,7 @@ describe('Item manipulation', () => { it('set and replace selected item by id', () => { wrapper = mount(TestList); - expect(wrapper.vm.context.selection).toBe(null); + expect(wrapper.vm.context.selection).toBe(undefined); wrapper.vm.context.selectItem(fixture[0].id); @@ -242,7 +242,7 @@ describe('Item manipulation', () => { wrapper.vm.context.toggleItem(fixture[0].id); - expect(wrapper.vm.context.selection).toBe(null); + expect(wrapper.vm.context.selection).toBe(undefined); wrapper.vm.context.toggleItem(fixture[0].id); @@ -315,7 +315,7 @@ describe('Item manipulation', () => { wrapper.vm.context.clearSelection(); - expect(wrapper.vm.context.selection).toBe(null); + expect(wrapper.vm.context.selection).toBe(undefined); }); it('clear selected items on multiple selection', () => { @@ -339,7 +339,7 @@ describe('Item manipulation', () => { it('emit event on single selection change', async () => { wrapper = mount(TestList); - expect(wrapper.vm.context.selection).toBe(null); + expect(wrapper.vm.context.selection).toBe(undefined); await wrapper.vm.context.selectItem(fixture[0].id); @@ -356,7 +356,7 @@ describe('Item manipulation', () => { await wrapper.vm.context.deselectItem(fixture[1].id); expect(changeEvent).toHaveLength(3); - expect(changeEvent[2]).toEqual([null]); + expect(changeEvent[2]).toEqual([undefined]); }); it('emit event on multiple selection change', async () => { diff --git a/packages/chusho/lib/composables/useInteractiveList.ts b/packages/chusho/lib/composables/useInteractiveList.ts index f8bdfaed..70fdf735 100644 --- a/packages/chusho/lib/composables/useInteractiveList.ts +++ b/packages/chusho/lib/composables/useInteractiveList.ts @@ -11,7 +11,6 @@ import { import { ensureArray, getAtIndex } from '../utils/arrays'; import { warn } from '../utils/debug'; -import { isNil } from '../utils/objects'; import useKeyboardListNavigation from './useKeyboardListNavigation'; @@ -36,16 +35,19 @@ export enum InteractiveListRoles { combobox = 'combobox', listbox = 'listbox', list = 'list', + tablist = 'tablist', } type UseInteractiveListOptions = { role: InteractiveListRoles; initialValue?: unknown; + initialActiveItem?: InteractiveItemId; valuePropName?: string; propName?: string | null; multiple?: boolean; loop?: boolean; skipDisabled?: boolean; + autoSelect?: boolean; onKeyDown?: (e: KeyboardEvent) => void; }; @@ -59,9 +61,9 @@ export interface UseInteractiveList { items: ComputedRef; multiple: boolean; + autoSelect: boolean; role: InteractiveListRoles; selection: ComputedRef; - selectedItems: ComputedRef; activeItem: ActiveItem; registerItem: (id: InteractiveItemId, data?: InteractiveItemData) => void; @@ -74,6 +76,7 @@ export interface UseInteractiveList { resetSelection: () => void; clearSelection: () => void; + activateItem: (id: InteractiveItemId) => void; activateItemAt: (index: number) => void; clearActiveItem: () => void; } @@ -82,12 +85,14 @@ export const UseInteractiveListSymbol: InjectionKey = Symbol('UseInteractiveList'); export default function useInteractiveList({ - role, - initialValue, - valuePropName = 'modelValue', - multiple = false, - loop = false, - skipDisabled = false, + role, // The role of the list + initialValue, // Initially selected items + initialActiveItem, // Initially active item (so it can be focused, but isn’t autofocused) + valuePropName = 'modelValue', // The prop to update hwen selection changes + multiple = false, // Whether multiple items can be selected + loop = false, // Whether to loop around when navigating with the keyboard + skipDisabled = false, // Whether to skip disabled items when navigating with the keyboard + autoSelect = false, // Keep the selection in sync with the active item }: UseInteractiveListOptions): UseInteractiveList { const vm = getCurrentInstance(); @@ -99,12 +104,7 @@ export default function useInteractiveList({ const initialValueAsArray: unknown[] = initialValue !== undefined ? ensureArray(initialValue) : []; const selectedValues = ref(new Set(initialValueAsArray)); - const selectedItems = computed(() => - itemsAsArray.value.filter( - (item) => item.value && selectedValues.value.has(item.value) - ) - ); - const activeItem: ActiveItem = ref(null); + const activeItem: ActiveItem = ref(initialActiveItem || null); const selection = computed(() => { const selected = [...selectedValues.value]; @@ -162,6 +162,12 @@ export default function useInteractiveList({ } function unregisterItem(id: InteractiveItemId): boolean { + // If the currently selected item gets removed from the list in autoSelect + // mode, we reset the selection to the first item in the list. + if (autoSelect && selectedValues.value.has(id)) { + activateItemAt(0); + } + return items.value.delete(id); } @@ -193,8 +199,16 @@ export default function useInteractiveList({ selectedValues.value.clear(); } + function activateItem(id: InteractiveItemId): void { + activeItem.value = id; + } + function activateItemAt(index: number): void { activeItem.value = getAtIndex([...items.value.keys()], index) ?? null; + + if (autoSelect) { + selectItem(activeItem.value); + } } function clearActiveItem(): void { @@ -211,9 +225,9 @@ export default function useInteractiveList({ items: itemsAsArray, multiple, + autoSelect, role, selection, - selectedItems, activeItem, registerItem, @@ -226,6 +240,7 @@ export default function useInteractiveList({ resetSelection, clearSelection, + activateItem, activateItemAt, clearActiveItem, }; diff --git a/packages/chusho/lib/composables/useInteractiveListItem.ts b/packages/chusho/lib/composables/useInteractiveListItem.ts index 2f161fc0..08608315 100644 --- a/packages/chusho/lib/composables/useInteractiveListItem.ts +++ b/packages/chusho/lib/composables/useInteractiveListItem.ts @@ -11,8 +11,6 @@ import { toRaw, unref, watch, - watchEffect, - watchPostEffect, } from 'vue'; import { MaybeRef } from '../types/utils'; @@ -22,6 +20,7 @@ import uid from '../utils/uid'; import { InteractiveItemData, + InteractiveItemId, InteractiveListRoles, UseInteractiveListSymbol, } from './useInteractiveList'; @@ -32,16 +31,18 @@ export enum InteractiveListItemRoles { menuitemradio = 'menuitemradio', option = 'option', listitem = 'listitem', + tab = 'tab', } interface UseInteractiveListItemOptions { - disabled: MaybeRef; + id?: InteractiveItemId; + disabled?: MaybeRef; value?: Ref; onSelect?: ({ role }: { role: InteractiveListItemRoles }) => void; } export interface UseInteractiveListItem { - id: string; + id: InteractiveItemId; itemRef: Ref; attrs: { role: InteractiveListItemRoles; @@ -53,12 +54,14 @@ export interface UseInteractiveListItem { onClick: (e: MouseEvent) => void; onKeydown: (e: KeyboardEvent) => void; }; + active: Ref; selected: Ref; } export default function useInteractiveListItem({ + id = uid('chusho-interactive-list-item'), value, - disabled, + disabled = ref(false), onSelect, }: UseInteractiveListItemOptions): UseInteractiveListItem { const interactiveList = inject(UseInteractiveListSymbol); @@ -71,17 +74,18 @@ export default function useInteractiveListItem({ const { multiple, + autoSelect, registerItem, unregisterItem, updateItem, + activateItem, activeItem, toggleItem, selection, role: listRole, } = interactiveList; - const id = uid('chusho-interactive-list-item'); - const isActive = ref(false); + const isActive = computed(() => activeItem.value === id); const itemRef = ref(); const vm = getCurrentInstance(); @@ -108,26 +112,24 @@ export default function useInteractiveListItem({ } ); - watchEffect(() => { - if (activeItem.value === id) { - isActive.value = true; - } else { - isActive.value = false; - } - }); - - watchPostEffect(() => { - if (isActive.value && itemElement.value) { - itemElement.value.focus(); - } - }); + watch( + isActive, + () => { + if (isActive.value) { + itemElement.value?.focus(); + } + }, + { flush: 'post' } + ); onBeforeUnmount(() => { unregisterItem(id); }); const itemRole = computed(() => { - if (listRole === InteractiveListRoles.menu) { + if (listRole === InteractiveListRoles.tablist) { + return InteractiveListItemRoles.tab; + } else if (listRole === InteractiveListRoles.menu) { if (value?.value !== undefined) { return multiple ? InteractiveListItemRoles.menuitemcheckbox @@ -155,6 +157,7 @@ export default function useInteractiveListItem({ InteractiveListItemRoles.menuitemcheckbox, InteractiveListItemRoles.menuitemradio, InteractiveListItemRoles.option, + InteractiveListItemRoles.tab, ].includes(itemRole.value); }); @@ -174,6 +177,20 @@ export default function useInteractiveListItem({ return toRaw(selection.value) === value.value; }); + if (autoSelect) { + watch( + selected, + () => { + if (selected.value) { + activateItem(id); + } + }, + { + immediate: true, + } + ); + } + const itemElement = computed(() => { if (itemRef?.value instanceof HTMLElement) { return itemRef?.value; @@ -227,10 +244,13 @@ export default function useInteractiveListItem({ id, itemRef, attrs: reactive({ + id, role: itemRole, tabindex: computed(() => (isActive.value ? '0' : '-1')), 'aria-disabled': computed(() => (unref(disabled) ? 'true' : undefined)), - [itemRole.value === InteractiveListItemRoles.option + [[InteractiveListItemRoles.option, InteractiveListItemRoles.tab].includes( + itemRole.value + ) ? 'aria-selected' : 'aria-checked']: checked, }), @@ -238,6 +258,7 @@ export default function useInteractiveListItem({ onClick: handleAction, onKeydown: handleKeydown, }, + active: isActive, selected, }; diff --git a/packages/chusho/lib/composables/useSelectable.spec.js b/packages/chusho/lib/composables/useSelectable.spec.js deleted file mode 100644 index d0f76060..00000000 --- a/packages/chusho/lib/composables/useSelectable.spec.js +++ /dev/null @@ -1,101 +0,0 @@ -import { mount } from '@vue/test-utils'; - -import useSelectable from './useSelectable'; - -describe('useSelectable', () => { - it('initialize with default value', () => { - const selectable = useSelectable('1'); - - expect(selectable.selectedItemId.value).toBe('1'); - }); - - it('emits when there’s a component instance, a prop name and the value changes', () => { - const wrapper = mount({ - emits: ['update:selectable'], - setup() { - return { - selectable: useSelectable('1', 'selectable'), - }; - }, - mounted() { - this.selectable.setSelectedItem('2'); - }, - template: '
', - }); - - expect(wrapper.emitted()).toEqual({ 'update:selectable': [['2']] }); - }); - - it('updates the selectedItem when there’s a component instance and the prop value changes', async () => { - const wrapper = mount( - { - props: ['modelValue'], - setup(props) { - return { - selectable: useSelectable(props.modelValue, 'modelValue'), - }; - }, - template: '
', - }, - { - props: { - modelValue: '1', - }, - } - ); - - expect(wrapper.vm.selectable.selectedItemId.value).toEqual('1'); - await wrapper.setProps({ modelValue: '2' }); - expect(wrapper.vm.selectable.selectedItemId.value).toEqual('2'); - // It should do nothing if the value didn’t actually change - await wrapper.setProps({ modelValue: '2' }); - expect(wrapper.vm.selectable.selectedItemId.value).toEqual('2'); - }); - - it('setSelectedItem changes selectedItem', () => { - const selectable = useSelectable('1'); - - selectable.setSelectedItem('2'); - - expect(selectable.selectedItemId.value).toBe('2'); - }); - - it('addItem adds item to the list of items', () => { - const selectable = useSelectable(); - - selectable.addItem('1'); - selectable.addItem('2'); - selectable.addItem('3'); - - expect(selectable.items.value).toMatchSnapshot(); - }); - - it('removeItem removes item from the list of items', () => { - const selectable = useSelectable(); - - selectable.addItem('1'); - selectable.addItem('2'); - selectable.addItem('3'); - - expect(selectable.items.value).toMatchSnapshot(); - - selectable.removeItem('2'); - expect(selectable.items.value).toMatchSnapshot(); - - selectable.removeItem('3'); - expect(selectable.items.value).toMatchSnapshot(); - - selectable.removeItem('1'); - expect(selectable.items.value).toMatchSnapshot(); - }); - - it('provides the selected item, its ID and index', () => { - const selectable = useSelectable('1'); - - selectable.addItem('1'); - - expect(selectable.selectedItem.value).toEqual({ id: '1' }); - expect(selectable.selectedItemId.value).toBe('1'); - expect(selectable.selectedItemIndex.value).toBe(0); - }); -}); diff --git a/packages/chusho/lib/composables/useSelectable.ts b/packages/chusho/lib/composables/useSelectable.ts deleted file mode 100644 index bca4b74f..00000000 --- a/packages/chusho/lib/composables/useSelectable.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { - ComputedRef, - Ref, - computed, - getCurrentInstance, - readonly, - ref, - watch, -} from 'vue'; - -export type SelectedItemId = string | number; - -export interface SelectedItem { - id: SelectedItemId; - data?: DataT; -} - -export interface UseSelectable { - selectedItemId: Readonly>; - selectedItemIndex: ComputedRef; - selectedItem: ComputedRef | null>; - items: ComputedRef[]>; - setSelectedItem: (id: SelectedItemId) => void; - addItem: (id: SelectedItemId, data?: Ref) => void; - removeItem: (id: SelectedItemId) => void; -} - -/** - * Generic logic for a single selected item in a list of items, optionally binded to a prop on the parent component. - */ -export default function useSelectable( - initialValue: SelectedItemId | null = null, - propName: string | null = null -): UseSelectable { - const vm = getCurrentInstance(); - const selectedItemId = ref(initialValue); - const items = ref([]) as Ref[]>; - - function setSelectedItem(id: SelectedItemId) { - selectedItemId.value = id; - if (propName && vm) { - vm.emit(`update:${propName}`, selectedItemId.value); - } - } - - function addItem(id: SelectedItemId, data?: Ref) { - const item: SelectedItem = { - id, - }; - if (data) { - item.data = data.value; - } - items.value.push(item); - } - - function removeItem(id: SelectedItemId) { - if (selectedItemId.value === id) { - selectedItemId.value = items.value[0].id; - } - - items.value.splice( - items.value.findIndex((item) => item.id === id), - 1 - ); - } - - if (propName) { - watch( - () => vm?.props?.[propName], - (val, oldVal) => { - if ( - val !== oldVal && - (typeof val === 'string' || typeof val === 'number') - ) { - selectedItemId.value = val; - } - } - ); - } - - return { - selectedItemId: readonly(selectedItemId), - selectedItemIndex: computed(() => - items.value.findIndex((item) => item.id === selectedItemId.value) - ), - selectedItem: computed( - () => items.value.find((item) => item.id === selectedItemId.value) ?? null - ), - items: computed(() => items.value), - setSelectedItem, - addItem, - removeItem, - }; -} diff --git a/packages/chusho/package.json b/packages/chusho/package.json index 38a93a67..ca1f194b 100644 --- a/packages/chusho/package.json +++ b/packages/chusho/package.json @@ -12,7 +12,7 @@ "build:lib": "vite build", "build:types": "tsc -p tsconfig.json --declaration --emitDeclarationOnly", "prepublishOnly": "npm run build", - "test": "npm run test:unit -- --coverage && npm run test:e2e", + "test": "npm run test:unit -- --run --coverage && npm run test:e2e && npm run test:e2e:headless -- --component", "test:unit": "vitest --config ./vitest.config.js", "test:e2e": "start-server-and-test start http-get://localhost:3000 test:e2e:headless", "test:e2e:dev": "cypress open", diff --git a/packages/chusho/src/chusho.config.ts b/packages/chusho/src/chusho.config.ts index 88f210a5..2bdc0404 100644 --- a/packages/chusho/src/chusho.config.ts +++ b/packages/chusho/src/chusho.config.ts @@ -1,4 +1,4 @@ -import { mergeProps } from 'vue'; +import { normalizeClass } from 'vue'; import { VueClassBinding } from '../lib/types'; @@ -237,22 +237,11 @@ export default defineConfig({ textarea: { class: (props) => - mergeProps( - { class: getFieldClass(props) }, - { - class: 'h-48 leading-6', - } - ), + normalizeClass([getFieldClass(props), 'h-48 leading-6']), }, textField: { - class: (props) => - mergeProps( - { class: getFieldClass(props) }, - { - class: 'leading-4', - } - ), + class: (props) => normalizeClass([getFieldClass(props), 'leading-4']), }, }, }); diff --git a/packages/chusho/src/components/examples/components/tabs/Default.vue b/packages/chusho/src/components/examples/components/tabs/Default.vue index a2304707..a3b2325a 100644 --- a/packages/chusho/src/components/examples/components/tabs/Default.vue +++ b/packages/chusho/src/components/examples/components/tabs/Default.vue @@ -1,5 +1,5 @@