CommonFlyout.vue 8.5 KB

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