LayoutSidebar.vue 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. <!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { useActiveElement } from '@vueuse/core'
  4. import { computed, useTemplateRef, watch } from 'vue'
  5. import CollapseButton from '#desktop/components/CollapseButton/CollapseButton.vue'
  6. import type { CollapseOptions } from '#desktop/components/CollapseButton/types.ts'
  7. import { useCollapseHandler } from '#desktop/components/CollapseButton/useCollapseHandler.ts'
  8. import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
  9. import ResizeLine from '#desktop/components/ResizeLine/ResizeLine.vue'
  10. import { useResizeLine } from '#desktop/components/ResizeLine/useResizeLine.ts'
  11. import { SidebarPosition } from './types.ts'
  12. interface Props {
  13. name: string
  14. /**
  15. @property currentWidth
  16. @property minWidth
  17. @property maxWidth
  18. - used for accessibility
  19. / */
  20. currentWidth?: number | string
  21. minWidth?: number | string
  22. maxWidth?: number | string
  23. noScroll?: boolean
  24. collapsible?: boolean
  25. iconCollapsed?: string
  26. position?: SidebarPosition
  27. resizable?: boolean
  28. id: string
  29. noPadding?: boolean
  30. classes?: {
  31. resizeLine?: string
  32. collapseButton?: string
  33. }
  34. rememberCollapse?: boolean
  35. backgroundVariant?: 'primary' | 'secondary'
  36. }
  37. const props = withDefaults(defineProps<Props>(), {
  38. position: SidebarPosition.Start,
  39. backgroundVariant: 'primary',
  40. hideButtonWhenCollapsed: false,
  41. })
  42. const emit = defineEmits<{
  43. 'resize-horizontal': [number]
  44. 'resize-horizontal-start': []
  45. 'resize-horizontal-end': []
  46. 'reset-width': []
  47. collapse: [boolean]
  48. expand: [boolean]
  49. }>()
  50. const collapseOptions: CollapseOptions = {
  51. name: props.name,
  52. }
  53. if (props.rememberCollapse)
  54. collapseOptions.storageKey = `${props.name}-sidebar-collapsed`
  55. const { toggleCollapse, isCollapsed } = useCollapseHandler(
  56. emit,
  57. collapseOptions,
  58. )
  59. const backgroundVariantClass = computed(() => {
  60. switch (props.backgroundVariant) {
  61. case 'secondary':
  62. return 'bg-blue-50 dark:bg-gray-800'
  63. case 'primary':
  64. default:
  65. return '-:bg-neutral-950'
  66. }
  67. })
  68. // a11y keyboard navigation // TS: Does not infer type for some reason?
  69. const resizeLineInstance =
  70. useTemplateRef<InstanceType<typeof ResizeLine>>('resize-line')
  71. const activeElement = useActiveElement()
  72. const handleKeyStroke = (e: KeyboardEvent, adjustment: number) => {
  73. if (
  74. !props.currentWidth ||
  75. activeElement.value !== resizeLineInstance.value?.resizeLine
  76. )
  77. return
  78. e.preventDefault()
  79. const newWidth = Number(props.currentWidth) + adjustment
  80. emit('resize-horizontal', newWidth)
  81. }
  82. const { startResizing, isResizing } = useResizeLine(
  83. (positionX) => emit('resize-horizontal', positionX),
  84. resizeLineInstance.value?.resizeLine,
  85. handleKeyStroke,
  86. {
  87. calculateFromRight: props.position === SidebarPosition.End,
  88. orientation: 'vertical',
  89. },
  90. )
  91. watch(isResizing, (isResizing) => {
  92. if (isResizing) {
  93. emit('resize-horizontal-start')
  94. } else {
  95. emit('resize-horizontal-end')
  96. }
  97. })
  98. const collapseButtonClass = computed(() => {
  99. if (props.position === SidebarPosition.Start)
  100. return 'ltr:rounded-l-none rtl:rounded-r-none'
  101. if (props.position === SidebarPosition.End)
  102. return 'ltr:rounded-r-none rtl:rounded-l-none'
  103. return ''
  104. })
  105. </script>
  106. <template>
  107. <aside
  108. :id="id"
  109. class="-:max-h-screen relative flex flex-col overflow-y-clip border-neutral-100 dark:border-gray-900"
  110. :class="[
  111. {
  112. 'py-3': isCollapsed && !noPadding,
  113. 'border-s': position === SidebarPosition.End,
  114. },
  115. backgroundVariantClass,
  116. ]"
  117. >
  118. <CommonButton
  119. v-if="iconCollapsed && isCollapsed"
  120. class="mx-auto"
  121. size="large"
  122. data-test-id="action-button"
  123. variant="neutral"
  124. :icon="iconCollapsed"
  125. @click="toggleCollapse"
  126. />
  127. <div
  128. v-else
  129. class="flex h-full max-w-full flex-col overflow-x-hidden"
  130. :class="{
  131. 'px-3 py-2.5': !isCollapsed && !noPadding,
  132. 'overflow-y-hidden': noScroll,
  133. 'overflow-y-auto': !noScroll,
  134. 'border-e border-neutral-100 dark:border-gray-900':
  135. backgroundVariant === 'secondary' && SidebarPosition.Start,
  136. 'border-s border-neutral-100 dark:border-gray-900':
  137. backgroundVariant === 'secondary' && SidebarPosition.End,
  138. }"
  139. >
  140. <slot v-bind="{ isCollapsed, toggleCollapse }" />
  141. </div>
  142. <ResizeLine
  143. v-if="resizable"
  144. ref="resize-line"
  145. :label="$t('Resize sidebar')"
  146. class="absolute z-20 has-[+div:hover]:opacity-100"
  147. :class="[
  148. {
  149. 'ltr:right-0 ltr:translate-x-1/2 rtl:left-0 rtl:-translate-x-1/2':
  150. position === SidebarPosition.Start,
  151. 'ltr:left-0 ltr:-translate-x-1/2 rtl:right-0 rtl:translate-x-1/2':
  152. position === SidebarPosition.End,
  153. peer: !resizeLineInstance?.resizing,
  154. },
  155. classes?.resizeLine || '',
  156. ]"
  157. :values="{
  158. max: Number(maxWidth)?.toFixed(2),
  159. min: minWidth,
  160. current: currentWidth,
  161. }"
  162. :disabled="isCollapsed"
  163. @mousedown-event="startResizing"
  164. @touchstart-event="startResizing"
  165. @dblclick-event="$emit('reset-width')"
  166. />
  167. <CollapseButton
  168. v-if="collapsible"
  169. :collapsed="isCollapsed"
  170. :owner-id="id"
  171. class="absolute top-[49px] z-20 peer-hover:opacity-100"
  172. :inverse="position === SidebarPosition.End"
  173. variant="tertiary-gray"
  174. :collapse-label="$t('Collapse sidebar')"
  175. :expand-label="$t('Expand sidebar')"
  176. :class="[
  177. {
  178. 'ltr:right-0 ltr:translate-x-[calc(100%-10px)] rtl:left-0 rtl:-translate-x-[calc(100%-10px)]':
  179. position === SidebarPosition.Start,
  180. 'ltr:left-0 ltr:-translate-x-[calc(100%-10px)] rtl:right-0 rtl:translate-x-[calc(100%-10px)]':
  181. position === SidebarPosition.End,
  182. 'ltr:translate-x-[calc(100%-7.5px)] rtl:-translate-x-[calc(100%-7.5px)]':
  183. isCollapsed && position === SidebarPosition.Start,
  184. 'ltr:-translate-x-[calc(100%-7.5px)] rtl:translate-x-[calc(100%-7.5px)]':
  185. isCollapsed && position === SidebarPosition.End,
  186. },
  187. classes?.collapseButton || '',
  188. ]"
  189. :button-class="collapseButtonClass"
  190. @click="(node: MouseEvent) => (node.target as HTMLButtonElement)?.blur()"
  191. @toggle-collapse="toggleCollapse"
  192. />
  193. </aside>
  194. </template>