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/components.d.ts +2 -0
- package/dist/components/FdsDrawer/FdsDrawer.vue.d.ts +35 -0
- package/dist/components/FdsDrawer/types.d.ts +11 -0
- package/dist/composables/useRouteFilters.d.ts +73 -0
- package/dist/fds-vue-core.cjs.js +757 -228
- package/dist/fds-vue-core.cjs.js.map +1 -1
- package/dist/fds-vue-core.css +1 -1
- package/dist/fds-vue-core.es.js +758 -229
- package/dist/fds-vue-core.es.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/slot-styles.css +40 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +8 -4
- package/src/components/FdsDrawer/FdsDrawer.stories.ts +155 -0
- package/src/components/FdsDrawer/FdsDrawer.vue +283 -0
- package/src/components/FdsDrawer/types.ts +12 -0
- package/src/composables/useRouteFilters.spec.ts +157 -0
- package/src/composables/useRouteFilters.ts +500 -0
- package/src/index.ts +26 -0
- package/src/lang/en.json +1 -0
- package/src/lang/sv.json +1 -0
- package/src/slot-styles.css +40 -0
- package/src/style.css +45 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fds-vue-core",
|
|
3
|
-
"version": "8.
|
|
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
|
+
})
|