CommonFlyout.vue 8.5 KB

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