ActionBar.vue 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { onKeyDown, useEventListener, whenever } from '@vueuse/core'
  4. import { storeToRefs } from 'pinia'
  5. import { useTemplateRef } from 'vue'
  6. import { computed, nextTick, type Ref, ref, toRef } from 'vue'
  7. import type { EditorButton } from '#shared/components/Form/fields/FieldEditor/useEditorActions.ts'
  8. import {
  9. getFieldEditorClasses,
  10. getFieldEditorProps,
  11. } from '#shared/components/Form/initializeFieldEditor.ts'
  12. import { useTraverseOptions } from '#shared/composables/useTraverseOptions.ts'
  13. import stopEvent from '#shared/utils/events.ts'
  14. // eslint-disable-next-line import/no-restricted-paths
  15. import { useThemeStore } from '#desktop/stores/theme.ts'
  16. import type { Editor } from '@tiptap/core'
  17. interface Props {
  18. actions: EditorButton[]
  19. editor?: Editor
  20. visible?: boolean
  21. isActive?: (type: string, attributes?: Record<string, unknown>) => boolean
  22. noGradient?: boolean
  23. }
  24. const actionBar = useTemplateRef('action-bar')
  25. const props = withDefaults(defineProps<Props>(), {
  26. visible: true,
  27. })
  28. const editor = toRef(props, 'editor')
  29. const emit = defineEmits<{
  30. hide: []
  31. blur: []
  32. 'click-action': [EditorButton, MouseEvent]
  33. }>()
  34. const classes = getFieldEditorClasses()
  35. const opacityGradientEnd = ref('0')
  36. const opacityGradientStart = ref('0')
  37. const restoreScroll = () => {
  38. const menuBar = actionBar.value as HTMLElement
  39. // restore scroll position, if needed
  40. menuBar.scroll(0, 0)
  41. }
  42. const hideAfterLeaving = () => {
  43. restoreScroll()
  44. emit('hide')
  45. }
  46. const recalculateOpacity = () => {
  47. const target = actionBar.value
  48. if (!target) {
  49. return
  50. }
  51. const scrollMin = 40
  52. const bottomMax = target.scrollWidth - target.clientWidth
  53. const bottomMin = bottomMax - scrollMin
  54. const { scrollLeft } = target
  55. opacityGradientStart.value = Math.min(1, scrollLeft / scrollMin).toFixed(2)
  56. const opacityPart = (scrollLeft - bottomMin) / scrollMin
  57. opacityGradientEnd.value = Math.min(1, 1 - opacityPart).toFixed(2)
  58. }
  59. const editorProps = getFieldEditorProps()
  60. useTraverseOptions(actionBar, { direction: 'horizontal', ignoreTabindex: true })
  61. onKeyDown(
  62. 'Escape',
  63. (e) => {
  64. stopEvent(e)
  65. emit('blur')
  66. },
  67. { target: actionBar as Ref<EventTarget> },
  68. )
  69. useEventListener('click', (e) => {
  70. if (!actionBar.value) return
  71. const target = e.target as HTMLElement
  72. if (!actionBar.value.contains(target) && !editor.value?.isFocused) {
  73. restoreScroll()
  74. emit('hide')
  75. }
  76. })
  77. whenever(
  78. () => props.visible,
  79. () => nextTick(recalculateOpacity),
  80. )
  81. const { currentTheme } = storeToRefs(useThemeStore())
  82. const checkCurrentTheme = (currentTheme: 'dark' | 'light' | 'auto') => {
  83. let theme: 'dark' | 'light' = 'dark'
  84. if (currentTheme === 'light') {
  85. theme = 'light'
  86. }
  87. if (currentTheme === 'auto') {
  88. const rootElement = document.documentElement
  89. theme = rootElement.getAttribute('data-theme') as 'dark' | 'light'
  90. }
  91. return theme
  92. }
  93. // Css lint rule for v-bind expects this naming convention
  94. const leftgradientbeforebackground = computed(() => {
  95. const theme = checkCurrentTheme(currentTheme.value)
  96. return theme === 'dark'
  97. ? classes.actionBar.leftGradient.before.background.dark
  98. : classes.actionBar.leftGradient.before.background.light
  99. })
  100. // Css lint rule for v-bind expects this naming convention
  101. const rightgradientbeforebackground = computed(() => {
  102. const theme = checkCurrentTheme(currentTheme.value)
  103. return theme === 'dark'
  104. ? classes.actionBar.rightGradient.before.background.dark
  105. : classes.actionBar.rightGradient.before.background.light
  106. })
  107. // Css lint rule for v-bind expects this naming convention
  108. const beforegradienttop = computed(
  109. () => classes.actionBar.shadowGradient.before.top,
  110. )
  111. // Css lint rule for v-bind expects this naming convention
  112. const beforegradientheight = computed(
  113. () => classes.actionBar.shadowGradient.before.height,
  114. )
  115. // Css lint rule for v-bind expects this naming convention
  116. const leftgradientvalue = computed(() => classes.actionBar.leftGradient.left)
  117. </script>
  118. <template>
  119. <div class="relative">
  120. <!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
  121. <div
  122. ref="action-bar"
  123. data-test-id="action-bar"
  124. class="Menubar relative flex max-w-full overflow-x-auto overflow-y-hidden"
  125. :class="[classes.actionBar.buttonContainer]"
  126. role="toolbar"
  127. tabindex="0"
  128. @keydown.tab="hideAfterLeaving"
  129. @scroll.passive="recalculateOpacity"
  130. >
  131. <template v-for="action in actions" :key="action.name">
  132. <button
  133. :title="action.label || action.name"
  134. type="button"
  135. :class="[
  136. classes.actionBar.button.base,
  137. action.class,
  138. {
  139. [classes.actionBar.button.active]: isActive?.(
  140. action.name,
  141. action.attributes,
  142. ),
  143. 'color-indicator': action.name === 'textColor',
  144. },
  145. ]"
  146. :disabled="action.disabled"
  147. :style="{
  148. '--color-indicator-background': editor?.getAttributes('textStyle')
  149. ?.color
  150. ? editor.getAttributes('textStyle').color
  151. : '#ffffff',
  152. }"
  153. :aria-label="action.label || action.name"
  154. :aria-pressed="isActive?.(action.name, action.attributes)"
  155. tabindex="-1"
  156. @click="
  157. (event) => {
  158. action.command?.(event)
  159. $emit('click-action', action, event)
  160. }
  161. "
  162. >
  163. <CommonIcon
  164. :name="action.icon"
  165. :size="editorProps.actionBar.button.icon.size"
  166. decorative
  167. />
  168. </button>
  169. <div v-if="action.showDivider">
  170. <hr class="h-full w-px border-0 bg-neutral-100 dark:bg-gray-900" />
  171. </div>
  172. </template>
  173. </div>
  174. <template v-if="!props.noGradient">
  175. <div
  176. class="ShadowGradient LeftGradient"
  177. :style="{ opacity: opacityGradientStart }"
  178. />
  179. <div
  180. class="ShadowGradient RightGradient"
  181. :style="{ opacity: opacityGradientEnd }"
  182. />
  183. </template>
  184. </div>
  185. </template>
  186. <style lang="postcss" scoped>
  187. .Menubar {
  188. -ms-overflow-style: none; /* Internet Explorer 10+ */
  189. scrollbar-width: none; /* Firefox */
  190. &::-webkit-scrollbar {
  191. display: none; /* Safari and Chrome */
  192. }
  193. }
  194. .ShadowGradient {
  195. @apply absolute h-full w-8;
  196. }
  197. .ShadowGradient::before {
  198. border-radius: 0 0 0.5rem;
  199. content: '';
  200. position: absolute;
  201. top: v-bind(beforegradienttop);
  202. height: v-bind(beforegradientheight);
  203. pointer-events: none;
  204. }
  205. .LeftGradient::before {
  206. border-radius: 0 0 0 0.5rem;
  207. left: v-bind(leftgradientvalue);
  208. right: 0;
  209. background: v-bind(leftgradientbeforebackground);
  210. }
  211. .RightGradient {
  212. right: 0;
  213. }
  214. .RightGradient::before {
  215. right: 0;
  216. left: 0;
  217. background: v-bind(rightgradientbeforebackground);
  218. }
  219. .color-indicator {
  220. --color-indicator-background: transparent;
  221. @apply relative;
  222. &::before {
  223. content: '';
  224. background: var(--color-indicator-background) !important;
  225. @apply absolute bottom-1 left-1/2 h-0.5 w-1/3 -translate-x-1/2 rounded-full bg-black;
  226. }
  227. }
  228. </style>