CommonFlyout.vue 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  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 { useResizeWidthHandle } from '#desktop/components/ResizeHandle/composables/useResizeWidthHandle.ts'
  24. import ResizeHandle from '#desktop/components/ResizeHandle/ResizeHandle.vue'
  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 ResizeHandle>>()
  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?.$el
  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, isResizingHorizontal } = useResizeWidthHandle(
  126. resizeCallback,
  127. resizeHandleComponent,
  128. handleKeyStroke,
  129. {
  130. calculateFromRight: true,
  131. },
  132. )
  133. const resetWidth = () => {
  134. flyoutContainerWidth.value = flyoutSize[props.size || 'medium']
  135. }
  136. onMounted(async () => {
  137. // Prevent left sidebar to collapse with flyout
  138. await nextTick()
  139. if (!leftSideBarKey) return
  140. const leftSidebarWidth = useLocalStorage(leftSideBarKey, 500)
  141. watch(leftSidebarWidth, (newWidth, oldValue) => {
  142. if (newWidth + gap < screenWidth.value - flyoutContainerWidth.value) return
  143. resizeCallback(flyoutContainerWidth.value - (newWidth - oldValue))
  144. })
  145. })
  146. // Keyboard
  147. onKeyUp('Escape', (e) => {
  148. if (props.noCloseOnEscape) return
  149. stopEvent(e)
  150. close()
  151. })
  152. // Style
  153. const contentElement = ref<HTMLDivElement>()
  154. const headerElement = ref<HTMLDivElement>()
  155. const footerElement = ref<HTMLDivElement>()
  156. const { arrivedState } = useScroll(contentElement)
  157. const isContentOverflowing = ref(false)
  158. watch(
  159. flyoutContainerWidth,
  160. async () => {
  161. // Watch if panel gets resized to show and hide styling based on content overflow
  162. await nextTick()
  163. if (
  164. contentElement.value?.scrollHeight &&
  165. contentElement.value?.clientHeight
  166. ) {
  167. isContentOverflowing.value =
  168. contentElement.value.scrollHeight > contentElement.value.clientHeight
  169. }
  170. },
  171. { immediate: true },
  172. )
  173. // Focus
  174. onMounted(() => {
  175. if (props.noAutofocus) return
  176. const firstFocusableNode =
  177. getFirstFocusableElement(contentElement.value) ||
  178. getFirstFocusableElement(footerElement.value) ||
  179. getFirstFocusableElement(headerElement.value)
  180. nextTick(() => {
  181. firstFocusableNode?.focus()
  182. firstFocusableNode?.scrollIntoView({ block: 'nearest' })
  183. })
  184. })
  185. </script>
  186. <template>
  187. <CommonOverlayContainer
  188. :id="flyoutId"
  189. ref="commonOverlayContainer"
  190. tag="aside"
  191. tabindex="-1"
  192. 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"
  193. :no-close-on-backdrop-click="noCloseOnBackdropClick"
  194. :show-backdrop="showBackdrop"
  195. :style="{ width: `${flyoutContainerWidth}px` }"
  196. :class="{ 'transition-all': !isResizingHorizontal }"
  197. :aria-label="$t('Side panel')"
  198. :aria-labelledby="`${flyoutId}-title`"
  199. @click-background="close()"
  200. >
  201. <header
  202. ref="headerElement"
  203. 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"
  204. :class="{
  205. 'border-b-neutral-100 dark:border-b-gray-900':
  206. !arrivedState.top && isContentOverflowing,
  207. }"
  208. >
  209. <slot name="header">
  210. <CommonLabel
  211. v-if="headerTitle"
  212. :id="`${flyoutId}-title`"
  213. tag="h2"
  214. class="min-h-7 grow"
  215. size="large"
  216. :prefix-icon="headerIcon"
  217. icon-color="text-stone-200 dark:text-neutral-500"
  218. >
  219. {{ $t(headerTitle) }}
  220. </CommonLabel>
  221. </slot>
  222. <CommonButton
  223. class="ltr:ml-auto rtl:mr-auto"
  224. variant="neutral"
  225. size="medium"
  226. :aria-label="$t('Close side panel')"
  227. icon="x-lg"
  228. @click="close()"
  229. />
  230. </header>
  231. <div
  232. ref="contentElement"
  233. class="h-full overflow-y-scroll px-3"
  234. v-bind="$attrs"
  235. >
  236. <slot />
  237. </div>
  238. <footer
  239. v-if="$slots.footer || !hideFooter"
  240. ref="footerElement"
  241. :aria-label="$t('Side panel footer')"
  242. 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"
  243. :class="{
  244. 'border-t-neutral-100 dark:border-t-gray-900':
  245. !arrivedState.bottom && isContentOverflowing,
  246. }"
  247. >
  248. <slot name="footer" v-bind="{ action, close }">
  249. <CommonFlyoutActionFooter
  250. v-bind="footerActionOptions"
  251. @cancel="close()"
  252. @action="action()"
  253. />
  254. </slot>
  255. </footer>
  256. <ResizeHandle
  257. v-if="resizable"
  258. ref="resizeHandleComponent"
  259. class="absolute top-1/2 -translate-y-1/2 ltr:left-0 rtl:right-0"
  260. :aria-label="$t('Resize side panel')"
  261. role="separator"
  262. tabindex="0"
  263. aria-orientation="horizontal"
  264. :aria-valuenow="flyoutContainerWidth"
  265. :aria-valuemax="flyoutMaxWidth"
  266. @mousedown="startResizing"
  267. @touchstart="startResizing"
  268. @dblclick="resetWidth()"
  269. />
  270. </CommonOverlayContainer>
  271. </template>