CommonFlyout.vue 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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-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"
  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-white 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. <div
  211. class="flex items-center gap-2 text-base text-gray-100 dark:text-neutral-400"
  212. >
  213. <CommonIcon
  214. v-if="headerIcon"
  215. class="flex-shrink-0"
  216. size="small"
  217. :name="headerIcon"
  218. />
  219. <h2 v-if="headerTitle" :id="`${flyoutId}-title`">
  220. {{ headerTitle }}
  221. </h2>
  222. </div>
  223. </slot>
  224. <CommonButton
  225. class="ltr:ml-auto rtl:mr-auto"
  226. variant="neutral"
  227. size="medium"
  228. :aria-label="$t('Close side panel')"
  229. icon="x-lg"
  230. @click="close()"
  231. />
  232. </header>
  233. <div
  234. ref="contentElement"
  235. class="h-full overflow-y-scroll px-3"
  236. v-bind="$attrs"
  237. >
  238. <slot />
  239. </div>
  240. <footer
  241. v-if="$slots.footer || !hideFooter"
  242. ref="footerElement"
  243. :aria-label="$t('Side panel footer')"
  244. 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"
  245. :class="{
  246. 'border-t-neutral-100 dark:border-t-gray-900':
  247. !arrivedState.bottom && isContentOverflowing,
  248. }"
  249. >
  250. <slot name="footer">
  251. <CommonFlyoutActionFooter
  252. v-bind="footerActionOptions"
  253. @cancel="close()"
  254. @action="action()"
  255. />
  256. </slot>
  257. </footer>
  258. <ResizeHandle
  259. v-if="resizable"
  260. ref="resizeHandleComponent"
  261. class="absolute top-1/2 -translate-y-1/2 ltr:left-0 rtl:right-0"
  262. :aria-label="$t('Resize side panel')"
  263. role="separator"
  264. tabindex="0"
  265. aria-orientation="horizontal"
  266. :aria-valuenow="flyoutContainerWidth"
  267. :aria-valuemax="flyoutMaxWidth"
  268. @mousedown="startResizing"
  269. @touchstart="startResizing"
  270. @dblclick="resetWidth()"
  271. />
  272. </CommonOverlayContainer>
  273. </template>