LayoutSidebar.vue 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  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. }
  36. const props = withDefaults(defineProps<Props>(), {
  37. position: SidebarPosition.Start,
  38. hideButtonWhenCollapsed: false,
  39. })
  40. const emit = defineEmits<{
  41. 'resize-horizontal': [number]
  42. 'resize-horizontal-start': []
  43. 'resize-horizontal-end': []
  44. 'reset-width': []
  45. collapse: [boolean]
  46. expand: [boolean]
  47. }>()
  48. const collapseOptions: CollapseOptions = {
  49. name: props.name,
  50. }
  51. if (props.rememberCollapse)
  52. collapseOptions.storageKey = `${props.name}-sidebar-collapsed`
  53. const { toggleCollapse, isCollapsed } = useCollapseHandler(
  54. emit,
  55. collapseOptions,
  56. )
  57. // a11y keyboard navigation // TS: Does not infer type for some reason?
  58. const resizeLineInstance =
  59. useTemplateRef<InstanceType<typeof ResizeLine>>('resize-line')
  60. const activeElement = useActiveElement()
  61. const handleKeyStroke = (e: KeyboardEvent, adjustment: number) => {
  62. if (
  63. !props.currentWidth ||
  64. activeElement.value !== resizeLineInstance.value?.resizeLine
  65. )
  66. return
  67. e.preventDefault()
  68. const newWidth = Number(props.currentWidth) + adjustment
  69. emit('resize-horizontal', newWidth)
  70. }
  71. const { startResizing, isResizing } = useResizeLine(
  72. (positionX) => emit('resize-horizontal', positionX),
  73. resizeLineInstance.value?.resizeLine,
  74. handleKeyStroke,
  75. {
  76. calculateFromRight: props.position === SidebarPosition.End,
  77. orientation: 'vertical',
  78. },
  79. )
  80. watch(isResizing, (isResizing) => {
  81. if (isResizing) {
  82. emit('resize-horizontal-start')
  83. } else {
  84. emit('resize-horizontal-end')
  85. }
  86. })
  87. const collapseButtonClass = computed(() => {
  88. if (props.position === SidebarPosition.Start)
  89. return 'ltr:rounded-l-none rtl:rounded-r-none'
  90. if (props.position === SidebarPosition.End)
  91. return 'ltr:rounded-r-none rtl:rounded-l-none'
  92. return ''
  93. })
  94. </script>
  95. <template>
  96. <aside
  97. :id="id"
  98. class="-:bg-neutral-950 -:max-h-screen relative flex flex-col overflow-y-clip border-neutral-100 dark:border-gray-900"
  99. :class="{
  100. 'py-3': isCollapsed && !noPadding,
  101. 'border-s': position === SidebarPosition.End,
  102. }"
  103. >
  104. <CommonButton
  105. v-if="iconCollapsed && isCollapsed"
  106. class="mx-auto"
  107. size="large"
  108. data-test-id="action-button"
  109. variant="neutral"
  110. :icon="iconCollapsed"
  111. @click="toggleCollapse"
  112. />
  113. <div
  114. v-else
  115. class="flex h-full max-w-full flex-col overflow-x-hidden"
  116. :class="{
  117. 'px-3 py-2.5': !isCollapsed && !noPadding,
  118. 'overflow-y-hidden': noScroll,
  119. 'overflow-y-auto': !noScroll,
  120. }"
  121. >
  122. <slot v-bind="{ isCollapsed, toggleCollapse }" />
  123. </div>
  124. <ResizeLine
  125. v-if="resizable"
  126. ref="resize-line"
  127. :label="$t('Resize sidebar')"
  128. class="absolute z-20 has-[+div:hover]:opacity-100"
  129. :class="[
  130. {
  131. 'ltr:right-0 ltr:translate-x-1/2 rtl:left-0 rtl:-translate-x-1/2':
  132. position === SidebarPosition.Start,
  133. 'ltr:left-0 ltr:-translate-x-1/2 rtl:right-0 rtl:translate-x-1/2':
  134. position === SidebarPosition.End,
  135. peer: !resizeLineInstance?.resizing,
  136. },
  137. classes?.resizeLine || '',
  138. ]"
  139. :values="{
  140. max: Number(maxWidth)?.toFixed(2),
  141. min: minWidth,
  142. current: currentWidth,
  143. }"
  144. :disabled="isCollapsed"
  145. @mousedown-event="startResizing"
  146. @touchstart-event="startResizing"
  147. @dblclick-event="$emit('reset-width')"
  148. />
  149. <CollapseButton
  150. v-if="collapsible"
  151. :collapsed="isCollapsed"
  152. :owner-id="id"
  153. class="absolute top-[49px] z-20 peer-hover:opacity-100"
  154. :inverse="position === SidebarPosition.End"
  155. variant="tertiary-gray"
  156. :collapse-label="$t('Collapse sidebar')"
  157. :expand-label="$t('Expand sidebar')"
  158. :class="[
  159. {
  160. 'ltr:right-0 ltr:translate-x-[calc(100%-10px)] rtl:left-0 rtl:-translate-x-[calc(100%-10px)]':
  161. position === SidebarPosition.Start,
  162. 'ltr:left-0 ltr:-translate-x-[calc(100%-10px)] rtl:right-0 rtl:translate-x-[calc(100%-10px)]':
  163. position === SidebarPosition.End,
  164. 'ltr:translate-x-[calc(100%-7.5px)] rtl:-translate-x-[calc(100%-7.5px)]':
  165. isCollapsed && position === SidebarPosition.Start,
  166. 'ltr:-translate-x-[calc(100%-7.5px)] rtl:translate-x-[calc(100%-7.5px)]':
  167. isCollapsed && position === SidebarPosition.End,
  168. },
  169. classes?.collapseButton || '',
  170. ]"
  171. :button-class="collapseButtonClass"
  172. @click="(node: MouseEvent) => (node.target as HTMLButtonElement)?.blur()"
  173. @toggle-collapse="toggleCollapse"
  174. />
  175. </aside>
  176. </template>