CommonFlyout.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import {
  4. useWindowSize,
  5. useLocalStorage,
  6. useScroll,
  7. useActiveElement,
  8. onKeyDown,
  9. useCurrentElement,
  10. type MaybeElementRef,
  11. type ComputedRefWithControl,
  12. type VueInstance,
  13. } from '@vueuse/core'
  14. import { whenever } from '@vueuse/shared'
  15. import {
  16. computed,
  17. nextTick,
  18. useTemplateRef,
  19. onMounted,
  20. type Ref,
  21. ref,
  22. shallowRef,
  23. watch,
  24. } from 'vue'
  25. import { useRoute, useRouter } from 'vue-router'
  26. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  27. import stopEvent from '#shared/utils/events.ts'
  28. import { getFirstFocusableElement } from '#shared/utils/getFocusableElements.ts'
  29. import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
  30. import CommonOverlayContainer from '#desktop/components/CommonOverlayContainer/CommonOverlayContainer.vue'
  31. import ResizeLine from '#desktop/components/ResizeLine/ResizeLine.vue'
  32. import { useResizeLine } from '#desktop/components/ResizeLine/useResizeLine.ts'
  33. import CommonFlyoutActionFooter from './CommonFlyoutActionFooter.vue'
  34. import { closeFlyout } from './useFlyout.ts'
  35. import type { ActionFooterOptions, FlyoutSizes } from './types.ts'
  36. export interface Props {
  37. /**
  38. * Unique name which gets used to identify the flyout
  39. * @example 'crop-avatar'
  40. */
  41. name: string
  42. /**
  43. * If true, the given flyout resizable width will be stored in local storage
  44. * Stored under the key `flyout-${name}-width`
  45. * @example 'crop-avatar' => 'flyout-crop-avatar-width'
  46. */
  47. persistResizeWidth?: boolean
  48. headerTitle?: string
  49. size?: FlyoutSizes
  50. headerIcon?: string
  51. resizable?: boolean
  52. showBackdrop?: boolean
  53. noCloseOnBackdropClick?: boolean
  54. noCloseOnEscape?: boolean
  55. hideFooter?: boolean
  56. footerActionOptions?: ActionFooterOptions
  57. noCloseOnAction?: boolean
  58. /**
  59. * Don't focus the first element inside a Flyout after being mounted
  60. * if nothing is focusable, will focus "Close" button when dismissible is active.
  61. */
  62. noAutofocus?: boolean
  63. fullscreen?: boolean
  64. /**
  65. * If true, no page context will be added to the name, e.g. for confirmation dialogs.
  66. */
  67. global?: boolean
  68. }
  69. const props = withDefaults(defineProps<Props>(), {
  70. resizable: true,
  71. showBackdrop: true,
  72. })
  73. defineOptions({
  74. inheritAttrs: false,
  75. })
  76. const emit = defineEmits<{
  77. action: []
  78. close: [boolean?]
  79. activated: []
  80. }>()
  81. const { path } = useRoute()
  82. const router = useRouter()
  83. const isActive = computed(() =>
  84. props.fullscreen ? true : path === router.currentRoute.value.path,
  85. )
  86. whenever(isActive, () => {
  87. emit('activated')
  88. })
  89. const close = async (isCancel?: boolean) => {
  90. emit('close', isCancel)
  91. await closeFlyout(props.name, props.global)
  92. }
  93. // TODO: maybe we could add a better handling in combination with a form....
  94. const action = async () => {
  95. emit('action')
  96. if (props.noCloseOnAction) return
  97. await closeFlyout(props.name, props.global)
  98. }
  99. const flyoutId = `flyout-${props.name}`
  100. const flyoutSize = { medium: 500, large: 800 }
  101. const overlayInstance = useTemplateRef('flyout-container')
  102. // :TODO: seems to not be typed correctly inside the library
  103. const flyoutContainerElement = useCurrentElement(
  104. overlayInstance as MaybeElementRef<VueInstance> | undefined,
  105. )
  106. useTrapTab(flyoutContainerElement as ComputedRefWithControl<HTMLElement>)
  107. // Width control over flyout
  108. let flyoutContainerWidth: Ref<number>
  109. const gap = 16 // Gap between sidebar and flyout
  110. const storageKeys = Object.keys(localStorage).filter((key) =>
  111. key.includes('sidebar-width'),
  112. )
  113. const leftSideBarKey = storageKeys.find((key) => key.includes('left'))
  114. const leftSidebarWidth = leftSideBarKey
  115. ? useLocalStorage(leftSideBarKey, 0)
  116. : shallowRef(0)
  117. const { width: screenWidth } = useWindowSize()
  118. // Calculate the viewport width minus the left sidebar width and a threshold gap
  119. const flyoutMaxWidth = computed(
  120. () => screenWidth.value - leftSidebarWidth.value - gap,
  121. )
  122. if (props.persistResizeWidth) {
  123. flyoutContainerWidth = useLocalStorage(
  124. `${flyoutId}-width`,
  125. flyoutSize[props.size || 'medium'],
  126. )
  127. } else {
  128. flyoutContainerWidth = ref(flyoutSize[props.size || 'medium'])
  129. }
  130. const resizeHandleInstance = useTemplateRef('resize-handle')
  131. const resizeCallback = (valueX: number) => {
  132. if (valueX >= flyoutMaxWidth.value) return
  133. flyoutContainerWidth.value = valueX
  134. }
  135. // a11y keyboard navigation
  136. const activeElement = useActiveElement()
  137. const handleKeyStroke = (e: KeyboardEvent, adjustment: number) => {
  138. if (
  139. !flyoutContainerWidth.value ||
  140. activeElement.value !== resizeHandleInstance.value?.resizeLine
  141. )
  142. return
  143. e.preventDefault()
  144. const newWidth = flyoutContainerWidth.value + adjustment
  145. if (newWidth >= flyoutMaxWidth.value) return
  146. resizeCallback(newWidth)
  147. }
  148. const { startResizing, isResizing } = useResizeLine(
  149. resizeCallback,
  150. resizeHandleInstance.value?.resizeLine,
  151. handleKeyStroke,
  152. {
  153. calculateFromRight: true,
  154. orientation: 'vertical',
  155. },
  156. )
  157. const resetWidth = () => {
  158. flyoutContainerWidth.value = flyoutSize[props.size || 'medium']
  159. }
  160. onMounted(async () => {
  161. // Prevent left sidebar to collapse with flyout
  162. await nextTick()
  163. if (!leftSideBarKey) return
  164. const leftSidebarWidth = useLocalStorage(leftSideBarKey, 500)
  165. watch(leftSidebarWidth, (newWidth, oldValue) => {
  166. if (newWidth + gap < screenWidth.value - flyoutContainerWidth.value) return
  167. resizeCallback(flyoutContainerWidth.value - (newWidth - oldValue))
  168. })
  169. })
  170. onKeyDown('Escape', (e) => {
  171. if (props.noCloseOnEscape) return
  172. stopEvent(e)
  173. close()
  174. })
  175. // Style
  176. const contentElement = useTemplateRef('content')
  177. const headerElement = useTemplateRef('header')
  178. const footerElement = useTemplateRef('footer')
  179. const { arrivedState } = useScroll(contentElement)
  180. const isContentOverflowing = ref(false)
  181. watch(
  182. flyoutContainerWidth,
  183. async () => {
  184. // Watch if panel gets resized to show and hide styling based on content overflow
  185. await nextTick()
  186. if (
  187. contentElement.value?.scrollHeight &&
  188. contentElement.value?.clientHeight
  189. ) {
  190. isContentOverflowing.value =
  191. contentElement.value.scrollHeight > contentElement.value.clientHeight
  192. }
  193. },
  194. { immediate: true },
  195. )
  196. // Focus
  197. onMounted(() => {
  198. if (props.noAutofocus) return
  199. const firstFocusableNode =
  200. getFirstFocusableElement(contentElement.value) ||
  201. getFirstFocusableElement(footerElement.value) ||
  202. getFirstFocusableElement(headerElement.value)
  203. nextTick(() => {
  204. firstFocusableNode?.focus()
  205. firstFocusableNode?.scrollIntoView({ block: 'nearest' })
  206. })
  207. })
  208. // It is the same as dialog, but could be changed in the future?
  209. const transition = VITE_TEST_MODE
  210. ? undefined
  211. : {
  212. enterActiveClass: 'duration-300 ease-out',
  213. enterFromClass: 'opacity-0 rtl:-translate-x-3/4 ltr:translate-x-3/4',
  214. enterToClass: 'opacity-100 rtl:-translate-x-0 ltr:translate-x-0',
  215. leaveActiveClass: 'duration-200 ease-in',
  216. leaveFromClass: 'opacity-100 rtl:-translate-x-0 ltr:translate-x-0',
  217. leaveToClass: 'opacity-0 rtl:-translate-x-3/4 ltr:translate-x-3/4',
  218. }
  219. </script>
  220. <template>
  221. <Transition :appear="isActive" v-bind="transition">
  222. <!-- `display:none` to prevent showing up inactive flyout for cached instance -->
  223. <CommonOverlayContainer
  224. :id="flyoutId"
  225. ref="flyout-container"
  226. tag="aside"
  227. tabindex="-1"
  228. class="overflow-clip-x fixed bottom-0 top-0 z-40 flex max-h-dvh min-w-min flex-col border-y border-neutral-100 bg-neutral-50 ltr:right-0 ltr:rounded-l-xl ltr:border-l rtl:left-0 rtl:rounded-r-xl rtl:border-r dark:border-gray-900 dark:bg-gray-500"
  229. :no-close-on-backdrop-click="noCloseOnBackdropClick"
  230. :show-backdrop="showBackdrop && isActive"
  231. :style="{ width: `${flyoutContainerWidth}px` }"
  232. :class="{ 'transition-all': !isResizing, hidden: !isActive }"
  233. :fullscreen="fullscreen"
  234. :aria-labelledby="`${flyoutId}-title`"
  235. @click-background="close()"
  236. >
  237. <header
  238. ref="header"
  239. class="sticky top-0 flex items-center border-b border-neutral-100 border-b-transparent bg-neutral-50 p-3 ltr:rounded-tl-xl rtl:rounded-tr-xl dark:bg-gray-500"
  240. :class="{
  241. 'border-b-neutral-100 dark:border-b-gray-900':
  242. !arrivedState.top && isContentOverflowing,
  243. }"
  244. >
  245. <slot name="header">
  246. <CommonLabel
  247. v-if="headerTitle"
  248. :id="`${flyoutId}-title`"
  249. tag="h2"
  250. class="min-h-7 grow gap-1.5"
  251. size="large"
  252. :prefix-icon="headerIcon"
  253. icon-color="text-stone-200 dark:text-neutral-500"
  254. >
  255. {{ $t(headerTitle) }}
  256. </CommonLabel>
  257. </slot>
  258. <CommonButton
  259. class="ltr:ml-auto rtl:mr-auto"
  260. variant="neutral"
  261. size="medium"
  262. :aria-label="$t('Close side panel')"
  263. icon="x-lg"
  264. @click="close()"
  265. />
  266. </header>
  267. <div ref="content" class="h-full overflow-y-scroll px-3" v-bind="$attrs">
  268. <slot />
  269. </div>
  270. <footer
  271. v-if="$slots.footer || !hideFooter"
  272. ref="footer"
  273. :aria-label="$t('Side panel footer')"
  274. class="sticky bottom-0 border-t border-t-transparent bg-neutral-50 p-3 ltr:rounded-bl-xl rtl:rounded-br-xl dark:bg-gray-500"
  275. :class="{
  276. 'border-t-neutral-100 dark:border-t-gray-900':
  277. !arrivedState.bottom && isContentOverflowing,
  278. }"
  279. >
  280. <slot name="footer" v-bind="{ action, close }">
  281. <CommonFlyoutActionFooter
  282. v-bind="footerActionOptions"
  283. @cancel="close(true)"
  284. @action="action()"
  285. />
  286. </slot>
  287. </footer>
  288. <ResizeLine
  289. v-if="resizable"
  290. ref="resize-handle"
  291. :label="$t('Resize side panel')"
  292. class="absolute top-2 h-[calc(100%-16px)] overflow-clip ltr:left-px ltr:-translate-x-1/2 rtl:right-px rtl:translate-x-1/2"
  293. orientation="vertical"
  294. :values="{
  295. current: flyoutContainerWidth,
  296. max: flyoutMaxWidth,
  297. }"
  298. @mousedown-event="startResizing"
  299. @touchstart-event="startResizing"
  300. @dblclick-event="resetWidth()"
  301. />
  302. </CommonOverlayContainer>
  303. </Transition>
  304. </template>