fds-vue-core 8.3.2 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fds-vue-core",
3
- "version": "8.3.2",
3
+ "version": "8.4.0",
4
4
  "description": "FDS Vue Core Component Library",
5
5
  "type": "module",
6
6
  "main": "./dist/fds-vue-core.cjs.js",
@@ -45,7 +45,8 @@
45
45
  }
46
46
  },
47
47
  "peerDependencies": {
48
- "vue": "^3.5.25"
48
+ "vue": "^3.5.25",
49
+ "vue-router": "^4.0.0"
49
50
  },
50
51
  "dependencies": {
51
52
  "axios": "1.18.1",
@@ -68,6 +69,7 @@
68
69
  "@vitejs/plugin-vue": "6.0.8",
69
70
  "@vitest/browser": "3.2.7",
70
71
  "fg-devkit": "1.12.7",
72
+ "happy-dom": "^20.11.1",
71
73
  "storybook": "10.5.4",
72
74
  "tsx": "4.23.1",
73
75
  "vite": "7.3.5",
@@ -75,7 +77,8 @@
75
77
  "vite-plugin-vue-devtools": "8.2.1",
76
78
  "vitest": "4.1.10",
77
79
  "vue": "3.5.40",
78
- "vue-i18n": "11.4.6"
80
+ "vue-i18n": "11.4.6",
81
+ "vue-router": "^4.6.3"
79
82
  },
80
83
  "knip": {
81
84
  "ignoreBinaries": [
@@ -102,6 +105,7 @@
102
105
  "yalc:publish": "pnpm build && yalc publish",
103
106
  "yalc:push": "pnpm build && yalc push",
104
107
  "serve:playground": "pnpm --dir playground-consumer dev",
105
- "lint-check": "pnpm exec fgd lint ."
108
+ "lint-check": "pnpm exec fgd lint .",
109
+ "test:unit": "vitest run --project unit"
106
110
  }
107
111
  }
@@ -0,0 +1,155 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3'
2
+ import { ref } from 'vue'
3
+ import FdsButtonPrimary from '../Buttons/FdsButtonPrimary/FdsButtonPrimary.vue'
4
+ import FdsDrawer from './FdsDrawer.vue'
5
+
6
+ const meta: Meta<typeof FdsDrawer> = {
7
+ title: 'FDS/FdsDrawer',
8
+ component: FdsDrawer,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ heading: { control: { type: 'text' } },
12
+ size: { control: { type: 'select' }, options: ['sm', 'md', 'lg', 'xl'] },
13
+ strict: { control: { type: 'boolean' } },
14
+ lockScroll: { control: { type: 'boolean' } },
15
+ info: { control: { type: 'select' }, options: ['valid', 'invalid', 'warning'] },
16
+ open: { control: { type: 'boolean' } },
17
+ },
18
+ args: {
19
+ heading: 'Drawer heading',
20
+ size: 'md',
21
+ strict: false,
22
+ lockScroll: false,
23
+ info: undefined,
24
+ open: false,
25
+ },
26
+ }
27
+
28
+ export default meta
29
+ type Story = StoryObj<typeof meta>
30
+
31
+ const createDrawerStory = (args: any) => {
32
+ const open = ref(args.open ?? false)
33
+ const handleClose = () => {
34
+ open.value = false
35
+ }
36
+ const handleOpen = () => {
37
+ open.value = true
38
+ }
39
+ return { args, open, handleClose, handleOpen }
40
+ }
41
+
42
+ const drawerTransform = (storyContext: {
43
+ args?: {
44
+ heading?: string
45
+ size?: string
46
+ open?: boolean
47
+ lockScroll?: boolean
48
+ info?: string
49
+ strict?: boolean
50
+ }
51
+ }) => {
52
+ const args = storyContext?.args || {}
53
+ const attrs = []
54
+
55
+ if (args.heading) attrs.push(`heading="${args.heading}"`)
56
+ if (args.size && args.size !== 'md') attrs.push(`size="${args.size}"`)
57
+ if (args.open) attrs.push(':open="true"')
58
+ if (args.lockScroll) attrs.push('lockScroll')
59
+ if (args.info) attrs.push(`info="${args.info}"`)
60
+ if (args.strict) attrs.push('strict')
61
+ const attrsStr = attrs.length ? ` ${attrs.join(' ')}` : ''
62
+
63
+ return `<FdsDrawer${attrsStr} @close="handleClose">
64
+ <p>This is the drawer content. You can add any content here.</p>
65
+ </FdsDrawer>`
66
+ }
67
+
68
+ export const Default: Story = {
69
+ render: (args: Story['args']) => ({
70
+ components: { FdsDrawer, FdsButtonPrimary },
71
+ setup: () => createDrawerStory(args),
72
+ template: `
73
+ <div>
74
+ <FdsButtonPrimary text="Open Drawer" @click="handleOpen" />
75
+ <FdsDrawer v-bind="args" :open="open" @close="handleClose">
76
+ <p>This is the drawer content. You can add any content here.</p>
77
+ </FdsDrawer>
78
+ </div>
79
+ `,
80
+ }),
81
+ parameters: {
82
+ docs: {
83
+ source: {
84
+ transform: drawerTransform,
85
+ },
86
+ },
87
+ },
88
+ }
89
+
90
+ export const Strict: Story = {
91
+ render: (args: Story['args']) => ({
92
+ components: { FdsDrawer, FdsButtonPrimary },
93
+ setup: () => createDrawerStory(args),
94
+ template: `
95
+ <div>
96
+ <FdsButtonPrimary text="Open Drawer" @click="handleOpen" />
97
+ <FdsDrawer v-bind="args" :open="open" @close="handleClose">
98
+ <p>This is a strict drawer. You cannot close it by clicking the backdrop or pressing Escape.</p>
99
+ <p>Use lockScroll and programmatic close from your own actions when needed.</p>
100
+ </FdsDrawer>
101
+ </div>
102
+ `,
103
+ }),
104
+ args: {
105
+ strict: true,
106
+ },
107
+ }
108
+
109
+ export const LargeSize: Story = {
110
+ render: (args: Story['args']) => ({
111
+ components: { FdsDrawer, FdsButtonPrimary },
112
+ setup: () => createDrawerStory(args),
113
+ template: `
114
+ <div>
115
+ <FdsButtonPrimary text="Open Drawer" @click="handleOpen" />
116
+ <FdsDrawer v-bind="args" :open="open" @close="handleClose">
117
+ <p>This is a large drawer with more space for content.</p>
118
+ <p>You can add multiple paragraphs and other content here.</p>
119
+ </FdsDrawer>
120
+ </div>
121
+ `,
122
+ }),
123
+ args: {
124
+ size: 'xl',
125
+ },
126
+ }
127
+
128
+ export const LongContent: Story = {
129
+ render: (args: Story['args']) => ({
130
+ components: { FdsDrawer, FdsButtonPrimary },
131
+ setup: () => createDrawerStory(args),
132
+ template: `
133
+ <div>
134
+ <FdsButtonPrimary text="Open Drawer" @click="handleOpen" />
135
+ <FdsDrawer v-bind="args" :open="open" @close="handleClose">
136
+ <p>This is the first paragraph of content.</p>
137
+ <p>This is the second paragraph with more information about the topic at hand.</p>
138
+ <p>Here is a third paragraph that provides additional context and details.</p>
139
+ <p>And yet another paragraph to demonstrate the scrolling behavior.</p>
140
+ <p>This paragraph continues to show how the content flows in the drawer.</p>
141
+ <p>More content is added here to make the drawer longer and more scrollable.</p>
142
+ <p>Another paragraph to show the sticky close button in action.</p>
143
+ <p>This content should be long enough to require scrolling.</p>
144
+ <p>The header with the close button should remain sticky at the top as you scroll.</p>
145
+ <p>This is the final paragraph to complete the long content example.</p>
146
+ </FdsDrawer>
147
+ </div>
148
+ `,
149
+ }),
150
+ args: {
151
+ heading: 'Long Content Drawer',
152
+ size: 'md',
153
+ lockScroll: true,
154
+ },
155
+ }
@@ -0,0 +1,283 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, type Slot } from 'vue'
3
+ import { useFdsI18n } from '../../plugin/useFdsI18n'
4
+ import FdsButtonIcon from '../Buttons/FdsButtonIcon/FdsButtonIcon.vue'
5
+ import FdsIcon from '../FdsIcon/FdsIcon.vue'
6
+ import type { FdsDrawerProps } from './types'
7
+
8
+ const props = withDefaults(defineProps<FdsDrawerProps>(), {
9
+ open: false,
10
+ heading: '',
11
+ size: 'md',
12
+ strict: false,
13
+ lockScroll: false,
14
+ info: undefined,
15
+ dataTestid: undefined,
16
+ })
17
+
18
+ const emit = defineEmits<{
19
+ (e: 'close'): void
20
+ (e: 'update:open', value: boolean): void
21
+ }>()
22
+
23
+ const isOpen = ref(props.open)
24
+ let cleanupFocusTrap: (() => void) | null = null
25
+ const previouslyFocusedElement = ref<HTMLElement | null>(null)
26
+
27
+ const { t } = useFdsI18n()
28
+
29
+ const drawerOuterClasses = computed(() => ['fixed top-0 left-0 w-full h-full z-100'])
30
+
31
+ const drawerInnerClasses = computed(() => [
32
+ 'bg-white shadow-lg z-[99999] overflow-hidden flex flex-col',
33
+ // Mobile: bottom sheet
34
+ 'fixed bottom-0 left-0 right-0 max-h-[90vh] rounded-t-lg',
35
+ // Desktop: right panel
36
+ 'md:top-0 md:right-0 md:bottom-auto md:left-auto md:h-full md:max-h-none md:rounded-none md:w-full',
37
+ {
38
+ 'md:max-w-[320px]': props.size === 'sm',
39
+ 'md:max-w-[400px]': props.size === 'md',
40
+ 'md:max-w-[480px]': props.size === 'lg',
41
+ 'md:max-w-[588px]': props.size === 'xl',
42
+ },
43
+ ])
44
+
45
+ const headerClasses = computed(() => ['shrink-0 flex justify-between bg-white p-6', 'rounded-t-lg md:rounded-none'])
46
+
47
+ const contentClasses = computed(() => ['flex-1 min-h-0 overflow-auto px-6 pb-6 mb-0-last-child'])
48
+
49
+ const headerTitleClasses = computed(() => ['m-0'])
50
+
51
+ const iconName = computed(() => {
52
+ if (props.info === 'valid') return 'check'
53
+ if (props.info === 'invalid' || props.info === 'warning') return 'alert'
54
+ return null
55
+ })
56
+
57
+ const iconClasses = computed(() => [
58
+ {
59
+ 'fill-green-600': props.info === 'valid',
60
+ 'fill-red-600': props.info === 'invalid',
61
+ 'fill-yellow-600': props.info === 'warning',
62
+ },
63
+ ])
64
+
65
+ const closeLabel = computed(() => t('FdsDrawer.closeDrawer'))
66
+
67
+ const setHtmlOverflow = (value: 'auto' | 'hidden') => {
68
+ if (typeof document === 'undefined') return
69
+ const htmlEl = document.documentElement
70
+ if (htmlEl) {
71
+ htmlEl.style.overflow = value
72
+ }
73
+ }
74
+
75
+ const handleBackdropClick = () => {
76
+ handleClose()
77
+ }
78
+
79
+ const restoreFocus = () => {
80
+ if (previouslyFocusedElement.value) {
81
+ previouslyFocusedElement.value.focus()
82
+ previouslyFocusedElement.value = null
83
+ }
84
+ }
85
+
86
+ const handleClose = () => {
87
+ if (props.strict) {
88
+ return
89
+ }
90
+ isOpen.value = false
91
+ setHtmlOverflow('auto')
92
+
93
+ emit('close')
94
+ emit('update:open', false)
95
+ }
96
+
97
+ const handleKeyDown = (e: KeyboardEvent) => {
98
+ if (isOpen.value && e.code === 'Escape') {
99
+ handleClose()
100
+ }
101
+ }
102
+
103
+ const getTabList = (drawer: Element) =>
104
+ drawer.querySelectorAll(
105
+ 'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])',
106
+ )
107
+
108
+ const trapFocus = () => {
109
+ if (!isOpen.value) return
110
+
111
+ nextTick(() => {
112
+ const drawer = document.querySelector('.fds-drawer-inner')
113
+ if (!drawer) return
114
+
115
+ const tabList = getTabList(drawer)
116
+
117
+ if (tabList.length > 0) {
118
+ ;(tabList[0] as HTMLElement).focus()
119
+ }
120
+
121
+ const handleTabKey = (e: KeyboardEvent) => {
122
+ if (e.key !== 'Tab') return
123
+
124
+ const currentTabList = getTabList(drawer)
125
+ if (currentTabList.length === 0) return
126
+
127
+ const firstFocusableEl = currentTabList[0] as HTMLElement
128
+ const lastFocusableEl = currentTabList[currentTabList.length - 1] as HTMLElement
129
+ const isTabOrderReversed = e.shiftKey
130
+
131
+ if (isTabOrderReversed && document.activeElement === firstFocusableEl) {
132
+ lastFocusableEl.focus()
133
+ e.preventDefault()
134
+ } else if (!isTabOrderReversed && document.activeElement === lastFocusableEl) {
135
+ firstFocusableEl.focus()
136
+ e.preventDefault()
137
+ }
138
+ }
139
+
140
+ drawer.addEventListener('keydown', handleTabKey as EventListener, true)
141
+
142
+ cleanupFocusTrap = () => {
143
+ drawer.removeEventListener('keydown', handleTabKey as EventListener, true)
144
+ }
145
+ })
146
+ }
147
+
148
+ watch(
149
+ () => props.open,
150
+ (newValue) => {
151
+ isOpen.value = newValue
152
+ if (newValue) {
153
+ previouslyFocusedElement.value = document.activeElement as HTMLElement
154
+ if (props.lockScroll) {
155
+ setHtmlOverflow('hidden')
156
+ }
157
+ trapFocus()
158
+ } else {
159
+ setHtmlOverflow('auto')
160
+ if (cleanupFocusTrap) {
161
+ cleanupFocusTrap()
162
+ cleanupFocusTrap = null
163
+ }
164
+ }
165
+ },
166
+ )
167
+
168
+ onMounted(() => {
169
+ document.addEventListener('keydown', handleKeyDown, true)
170
+
171
+ if (props.open) {
172
+ previouslyFocusedElement.value = document.activeElement as HTMLElement
173
+
174
+ if (props.lockScroll) {
175
+ setHtmlOverflow('hidden')
176
+ }
177
+ trapFocus()
178
+ }
179
+ })
180
+
181
+ onBeforeUnmount(() => {
182
+ document.removeEventListener('keydown', handleKeyDown, true)
183
+ setHtmlOverflow('auto')
184
+ if (cleanupFocusTrap) {
185
+ cleanupFocusTrap()
186
+ }
187
+ })
188
+
189
+ const drawerAttrs = computed(() => {
190
+ const attrs: Record<string, string> = {}
191
+ if (props.dataTestid) {
192
+ attrs['data-testid'] = props.dataTestid
193
+ }
194
+ return attrs
195
+ })
196
+
197
+ defineSlots<{
198
+ default?: Slot
199
+ }>()
200
+ </script>
201
+
202
+ <template>
203
+ <Teleport to="body">
204
+ <Transition name="fds-drawer" :duration="{ enter: 280, leave: 180 }" @after-leave="restoreFocus">
205
+ <div v-if="isOpen" @click="handleBackdropClick" :class="drawerOuterClasses">
206
+ <!-- Backdrop -->
207
+ <div class="fds-drawer-backdrop fixed top-0 left-0 w-full h-full bg-black/34 z-99"></div>
208
+
209
+ <!-- Drawer Inner -->
210
+ <div
211
+ class="fds-drawer-inner"
212
+ :class="drawerInnerClasses"
213
+ role="dialog"
214
+ aria-modal="true"
215
+ v-bind="drawerAttrs"
216
+ @click.stop
217
+ >
218
+ <div :class="headerClasses">
219
+ <div class="flex items-center gap-2 min-w-0">
220
+ <FdsIcon v-if="iconName" :name="iconName" :size="24" :class="iconClasses" />
221
+ <h3 tabindex="-1" :class="headerTitleClasses">
222
+ {{ heading }}
223
+ </h3>
224
+ </div>
225
+ <div>
226
+ <FdsButtonIcon
227
+ v-if="!strict"
228
+ icon="cross"
229
+ :size="28"
230
+ @click="handleClose()"
231
+ v-bind="{ 'aria-label': closeLabel }"
232
+ class="ml-4 shrink-0"
233
+ />
234
+ </div>
235
+ </div>
236
+
237
+ <!-- Content -->
238
+ <div :class="contentClasses">
239
+ <slot />
240
+ </div>
241
+ </div>
242
+ </div>
243
+ </Transition>
244
+ </Teleport>
245
+ </template>
246
+
247
+ <style scoped>
248
+ /* Slide + fade — mobile: bottom sheet, desktop (md+): right panel */
249
+ .fds-drawer-enter-active .fds-drawer-inner,
250
+ .fds-drawer-leave-active .fds-drawer-inner {
251
+ transition: transform 280ms cubic-bezier(0.32, 0.72, 0, 1);
252
+ }
253
+
254
+ .fds-drawer-leave-active .fds-drawer-inner {
255
+ transition-duration: 180ms;
256
+ }
257
+
258
+ .fds-drawer-enter-from .fds-drawer-inner,
259
+ .fds-drawer-leave-to .fds-drawer-inner {
260
+ transform: translateY(100%);
261
+ }
262
+
263
+ .fds-drawer-enter-active .fds-drawer-backdrop,
264
+ .fds-drawer-leave-active .fds-drawer-backdrop {
265
+ transition: opacity 280ms ease;
266
+ }
267
+
268
+ .fds-drawer-leave-active .fds-drawer-backdrop {
269
+ transition-duration: 180ms;
270
+ }
271
+
272
+ .fds-drawer-enter-from .fds-drawer-backdrop,
273
+ .fds-drawer-leave-to .fds-drawer-backdrop {
274
+ opacity: 0;
275
+ }
276
+
277
+ @media (min-width: 768px) {
278
+ .fds-drawer-enter-from .fds-drawer-inner,
279
+ .fds-drawer-leave-to .fds-drawer-inner {
280
+ transform: translateX(100%);
281
+ }
282
+ }
283
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { HTMLAttributes } from 'vue'
2
+
3
+ export interface FdsDrawerProps extends /* @vue-ignore */ HTMLAttributes {
4
+ open?: boolean
5
+ heading?: string
6
+ size?: 'sm' | 'md' | 'lg' | 'xl'
7
+ strict?: boolean
8
+ lockScroll?: boolean
9
+ info?: 'valid' | 'invalid' | 'warning'
10
+ dataTestid?: string
11
+ onClose?: (value: boolean) => void
12
+ }
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createApp, nextTick } from 'vue'
3
+ import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-router'
4
+ import { useRouteFilters, type UseRouteFiltersReturn, type UseRouteFiltersSchema } from './useRouteFilters'
5
+
6
+ const routes: RouteRecordRaw[] = [{ path: '/', component: { template: '<div />' } }]
7
+
8
+ const flushPromises = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
9
+
10
+ type TestSchema = UseRouteFiltersSchema<
11
+ { status: string[]; active: boolean; count: number },
12
+ { status: string[]; active: boolean }
13
+ >
14
+
15
+ const mountWithRoute = async (initialQuery: Record<string, string | string[]> = {}, schema?: Partial<TestSchema>) => {
16
+ const router = createRouter({ history: createMemoryHistory(), routes })
17
+ await router.push({ path: '/', query: initialQuery })
18
+ await router.isReady()
19
+
20
+ const replaceSpy = vi.spyOn(router, 'replace')
21
+ let composable!: UseRouteFiltersReturn<
22
+ { status: string[]; active: boolean; count: number },
23
+ { status: string[]; active: boolean }
24
+ >
25
+
26
+ const resolvedSchema: TestSchema = {
27
+ filter: { status: [] as string[], active: false as boolean, count: 0 },
28
+ draft: { status: [] as string[], active: false as boolean },
29
+ sort: {},
30
+ search: '',
31
+ page: 0,
32
+ ...schema,
33
+ }
34
+
35
+ const app = createApp({
36
+ setup() {
37
+ composable = useRouteFilters(resolvedSchema)
38
+ return () => null
39
+ },
40
+ })
41
+
42
+ app.use(router)
43
+ app.mount(document.createElement('div'))
44
+ await nextTick()
45
+
46
+ return { composable, router, replaceSpy, app }
47
+ }
48
+
49
+ describe('useRouteFilters', () => {
50
+ it('initializes filter from route query with typed defaults', async () => {
51
+ const { composable, app } = await mountWithRoute({
52
+ status: ['open', 'closed'],
53
+ active: 'true',
54
+ count: '3',
55
+ })
56
+
57
+ expect(composable.filter.status).toEqual(['open', 'closed'])
58
+ expect(composable.filter.active).toBe(true)
59
+ expect(composable.filter.count).toBe(3)
60
+
61
+ app.unmount()
62
+ })
63
+
64
+ it('syncs filter to route and back without looping', async () => {
65
+ const { composable, router, replaceSpy, app } = await mountWithRoute({ active: 'true' })
66
+
67
+ replaceSpy.mockClear()
68
+ composable.filter.active = true
69
+ await nextTick()
70
+ await flushPromises()
71
+ expect(replaceSpy).not.toHaveBeenCalled()
72
+
73
+ composable.filter.status = ['open']
74
+ await nextTick()
75
+ await flushPromises()
76
+ expect(router.currentRoute.value.query).toMatchObject({
77
+ status: ['open'],
78
+ active: 'true',
79
+ })
80
+
81
+ await router.replace({ query: { status: ['new'], active: 'false' } })
82
+ await nextTick()
83
+ expect(composable.filter.status).toEqual(['new'])
84
+ expect(composable.filter.active).toBe(false)
85
+
86
+ app.unmount()
87
+ })
88
+
89
+ it('reset keeps unrelated query params and clear removes managed ones', async () => {
90
+ const { composable, router, app } = await mountWithRoute({
91
+ tab: 'details',
92
+ active: 'true',
93
+ _search: 'foo',
94
+ _page: '5',
95
+ })
96
+
97
+ await composable.reset()
98
+ await flushPromises()
99
+ expect(composable.filter.active).toBe(false)
100
+ expect(composable.search.value).toBe('')
101
+ expect(composable.page.value).toBe(0)
102
+ expect(router.currentRoute.value.query).toEqual({ tab: 'details' })
103
+
104
+ composable.filter.active = true
105
+ composable.search.value = 'bar'
106
+ await nextTick()
107
+ await flushPromises()
108
+
109
+ await composable.clear()
110
+ await flushPromises()
111
+ expect(router.currentRoute.value.query).toEqual({})
112
+
113
+ app.unmount()
114
+ })
115
+
116
+ it('draft apply and cancel keep URL in sync only after apply', async () => {
117
+ const { composable, router, app } = await mountWithRoute({ status: ['active'] })
118
+
119
+ composable.draft.open()
120
+ composable.draft.state.status = ['changed']
121
+ await nextTick()
122
+ await flushPromises()
123
+ expect(router.currentRoute.value.query.status).toEqual(['active'])
124
+ expect(composable.filter.status).toEqual(['active'])
125
+
126
+ composable.draft.cancel()
127
+ expect(composable.draft.state.status).toEqual(['active'])
128
+
129
+ composable.draft.state.status = ['applied']
130
+ composable.draft.apply()
131
+ await nextTick()
132
+ await flushPromises()
133
+ expect(composable.filter.status).toEqual(['applied'])
134
+ expect(router.currentRoute.value.query.status).toEqual(['applied'])
135
+
136
+ app.unmount()
137
+ })
138
+
139
+ it('sort toggle cycles none → desc → asc → none', async () => {
140
+ const { composable, app } = await mountWithRoute()
141
+
142
+ composable.sort.toggle('name')
143
+ expect(composable.sort.value).toBe('-name')
144
+
145
+ composable.sort.toggle('name')
146
+ expect(composable.sort.value).toBe('+name')
147
+
148
+ composable.sort.toggle('name')
149
+ expect(composable.sort.value).toBe('')
150
+
151
+ composable.sort.toggle('created')
152
+ composable.sort.toggle('name')
153
+ expect(composable.sort.value).toBe('-name')
154
+
155
+ app.unmount()
156
+ })
157
+ })