CommonFlyout.vue 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  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. onKeyUp,
  8. useActiveElement,
  9. } from '@vueuse/core'
  10. import {
  11. computed,
  12. nextTick,
  13. useTemplateRef,
  14. onMounted,
  15. type Ref,
  16. ref,
  17. shallowRef,
  18. watch,
  19. } from 'vue'
  20. import stopEvent from '#shared/utils/events.ts'
  21. import { getFirstFocusableElement } from '#shared/utils/getFocusableElements.ts'
  22. import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
  23. import CommonOverlayContainer from '#desktop/components/CommonOverlayContainer/CommonOverlayContainer.vue'
  24. import ResizeLine from '#desktop/components/ResizeLine/ResizeLine.vue'
  25. import { useResizeLine } from '#desktop/components/ResizeLine/useResizeLine.ts'
  26. import CommonFlyoutActionFooter from './CommonFlyoutActionFooter.vue'
  27. import { closeFlyout } from './useFlyout.ts'
  28. import type { ActionFooterOptions, FlyoutSizes } from './types.ts'
  29. export interface Props {
  30. /**
  31. * Unique name which gets used to identify the flyout
  32. * @example 'crop-avatar'
  33. */
  34. name: string
  35. /**
  36. * If true, the given flyout resizable width will be stored in local storage
  37. * Stored under the key `flyout-${name}-width`
  38. * @example 'crop-avatar' => 'flyout-crop-avatar-width'
  39. */
  40. persistResizeWidth?: boolean
  41. headerTitle?: string
  42. size?: FlyoutSizes
  43. headerIcon?: string
  44. resizable?: boolean
  45. showBackdrop?: boolean
  46. noCloseOnBackdropClick?: boolean
  47. noCloseOnEscape?: boolean
  48. hideFooter?: boolean
  49. footerActionOptions?: ActionFooterOptions
  50. noCloseOnAction?: boolean
  51. /**
  52. * Don't focus the first element inside a Flyout after being mounted
  53. * if nothing is focusable, will focus "Close" button when dismissible is active.
  54. */
  55. noAutofocus?: boolean
  56. }
  57. const props = withDefaults(defineProps<Props>(), {
  58. resizable: true,
  59. showBackdrop: true,
  60. })
  61. defineOptions({
  62. inheritAttrs: false,
  63. })
  64. const emit = defineEmits<{
  65. action: []
  66. close: [boolean?]
  67. }>()
  68. const close = async (isCancel?: boolean) => {
  69. emit('close', isCancel)
  70. await closeFlyout(props.name)
  71. }
  72. // TODO: maybe we could add a better handling in combination with a form....
  73. const action = async () => {
  74. emit('action')
  75. if (props.noCloseOnAction) return
  76. await closeFlyout(props.name)
  77. }
  78. const flyoutId = `flyout-${props.name}`
  79. const flyoutSize = { medium: 500 }
  80. // Width control over flyout
  81. let flyoutContainerWidth: Ref<number>
  82. const gap = 16 // Gap between sidebar and flyout
  83. const storageKeys = Object.keys(localStorage).filter((key) =>
  84. key.includes('sidebar-width'),
  85. )
  86. const leftSideBarKey = storageKeys.find((key) => key.includes('left'))
  87. const leftSidebarWidth = leftSideBarKey
  88. ? useLocalStorage(leftSideBarKey, 0)
  89. : shallowRef(0)
  90. const { width: screenWidth } = useWindowSize()
  91. // Calculate the viewport width minus the left sidebar width and a threshold gap
  92. const flyoutMaxWidth = computed(
  93. () => screenWidth.value - leftSidebarWidth.value - gap,
  94. )
  95. if (props.persistResizeWidth) {
  96. flyoutContainerWidth = useLocalStorage(
  97. `${flyoutId}-width`,
  98. flyoutSize[props.size || 'medium'],
  99. )
  100. } else {
  101. flyoutContainerWidth = ref(flyoutSize[props.size || 'medium'])
  102. }
  103. const resizeHandleInstance = useTemplateRef('resize-handle')
  104. const resizeCallback = (valueX: number) => {
  105. if (valueX >= flyoutMaxWidth.value) return
  106. flyoutContainerWidth.value = valueX
  107. }
  108. // a11y keyboard navigation
  109. const activeElement = useActiveElement()
  110. const handleKeyStroke = (e: KeyboardEvent, adjustment: number) => {
  111. if (
  112. !flyoutContainerWidth.value ||
  113. activeElement.value !== resizeHandleInstance.value?.resizeLine
  114. )
  115. return
  116. e.preventDefault()
  117. const newWidth = flyoutContainerWidth.value + adjustment
  118. if (newWidth >= flyoutMaxWidth.value) return
  119. resizeCallback(newWidth)
  120. }
  121. const { startResizing, isResizing } = useResizeLine(
  122. resizeCallback,
  123. resizeHandleInstance.value?.resizeLine,
  124. handleKeyStroke,
  125. {
  126. calculateFromRight: true,
  127. orientation: 'vertical',
  128. },
  129. )
  130. const resetWidth = () => {
  131. flyoutContainerWidth.value = flyoutSize[props.size || 'medium']
  132. }
  133. onMounted(async () => {
  134. // Prevent left sidebar to collapse with flyout
  135. await nextTick()
  136. if (!leftSideBarKey) return
  137. const leftSidebarWidth = useLocalStorage(leftSideBarKey, 500)
  138. watch(leftSidebarWidth, (newWidth, oldValue) => {
  139. if (newWidth + gap < screenWidth.value - flyoutContainerWidth.value) return
  140. resizeCallback(flyoutContainerWidth.value - (newWidth - oldValue))
  141. })
  142. })
  143. // Keyboard
  144. onKeyUp('Escape', (e) => {
  145. if (props.noCloseOnEscape) return
  146. stopEvent(e)
  147. close()
  148. })
  149. // Style
  150. const contentElement = useTemplateRef('content')
  151. const headerElement = useTemplateRef('header')
  152. const footerElement = useTemplateRef('footer')
  153. const { arrivedState } = useScroll(contentElement)
  154. const isContentOverflowing = ref(false)
  155. watch(
  156. flyoutContainerWidth,
  157. async () => {
  158. // Watch if panel gets resized to show and hide styling based on content overflow
  159. await nextTick()
  160. if (
  161. contentElement.value?.scrollHeight &&
  162. contentElement.value?.clientHeight
  163. ) {
  164. isContentOverflowing.value =
  165. contentElement.value.scrollHeight > contentElement.value.clientHeight
  166. }
  167. },
  168. { immediate: true },
  169. )
  170. // Focus
  171. onMounted(() => {
  172. if (props.noAutofocus) return
  173. const firstFocusableNode =
  174. getFirstFocusableElement(contentElement.value) ||
  175. getFirstFocusableElement(footerElement.value) ||
  176. getFirstFocusableElement(headerElement.value)
  177. nextTick(() => {
  178. firstFocusableNode?.focus()
  179. firstFocusableNode?.scrollIntoView({ block: 'nearest' })
  180. })
  181. })
  182. </script>
  183. <template>
  184. <CommonOverlayContainer
  185. :id="flyoutId"
  186. tag="aside"
  187. tabindex="-1"
  188. 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"
  189. :no-close-on-backdrop-click="noCloseOnBackdropClick"
  190. :show-backdrop="showBackdrop"
  191. :style="{ width: `${flyoutContainerWidth}px` }"
  192. :class="{ 'transition-all': !isResizing }"
  193. :aria-label="$t('Side panel')"
  194. :aria-labelledby="`${flyoutId}-title`"
  195. @click-background="close()"
  196. >
  197. <header
  198. ref="header"
  199. 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"
  200. :class="{
  201. 'border-b-neutral-100 dark:border-b-gray-900':
  202. !arrivedState.top && isContentOverflowing,
  203. }"
  204. >
  205. <slot name="header">
  206. <CommonLabel
  207. v-if="headerTitle"
  208. :id="`${flyoutId}-title`"
  209. tag="h2"
  210. class="min-h-7 grow"
  211. size="large"
  212. :prefix-icon="headerIcon"
  213. icon-color="text-stone-200 dark:text-neutral-500"
  214. >
  215. {{ $t(headerTitle) }}
  216. </CommonLabel>
  217. </slot>
  218. <CommonButton
  219. class="ltr:ml-auto rtl:mr-auto"
  220. variant="neutral"
  221. size="medium"
  222. :aria-label="$t('Close side panel')"
  223. icon="x-lg"
  224. @click="close()"
  225. />
  226. </header>
  227. <div ref="content" class="h-full overflow-y-scroll px-3" v-bind="$attrs">
  228. <slot />
  229. </div>
  230. <footer
  231. v-if="$slots.footer || !hideFooter"
  232. ref="footer"
  233. :aria-label="$t('Side panel footer')"
  234. 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"
  235. :class="{
  236. 'border-t-neutral-100 dark:border-t-gray-900':
  237. !arrivedState.bottom && isContentOverflowing,
  238. }"
  239. >
  240. <slot name="footer" v-bind="{ action, close }">
  241. <CommonFlyoutActionFooter
  242. v-bind="footerActionOptions"
  243. @cancel="close(true)"
  244. @action="action()"
  245. />
  246. </slot>
  247. </footer>
  248. <ResizeLine
  249. v-if="resizable"
  250. ref="resize-handle"
  251. :label="$t('Resize side panel')"
  252. 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"
  253. orientation="vertical"
  254. :values="{
  255. current: flyoutContainerWidth,
  256. max: flyoutMaxWidth,
  257. }"
  258. @mousedown-event="startResizing"
  259. @touchstart-event="startResizing"
  260. @dblclick-event="resetWidth()"
  261. />
  262. </CommonOverlayContainer>
  263. </template>