CommonInlineEdit.vue 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { onClickOutside } from '@vueuse/core'
  4. import {
  5. computed,
  6. defineAsyncComponent,
  7. ref,
  8. nextTick,
  9. watch,
  10. onMounted,
  11. useTemplateRef,
  12. } from 'vue'
  13. import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
  14. import { useHtmlLinks } from '#shared/composables/useHtmlLinks.ts'
  15. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  16. import { i18n } from '#shared/i18n/index.ts'
  17. import { textToHtml } from '#shared/utils/helpers.ts'
  18. const CommonButton = defineAsyncComponent(
  19. () => import('#desktop/components/CommonButton/CommonButton.vue'),
  20. )
  21. export interface Props {
  22. value: string
  23. initialEditValue?: string
  24. id?: string
  25. disabled?: boolean
  26. required?: boolean
  27. placeholder?: string
  28. size?: 'xs' | 'small' | 'medium' | 'large' | 'xl'
  29. alternativeBackground?: boolean
  30. submitLabel?: string
  31. cancelLabel?: string
  32. detectLinks?: boolean
  33. labelAttrs?: Record<string, string>
  34. label?: string
  35. block?: boolean
  36. classes?: {
  37. label?: string
  38. input?: string
  39. }
  40. onSubmitEdit?: (
  41. value: string,
  42. ) => Promise<void | (() => void)> | void | (() => void)
  43. }
  44. const props = withDefaults(defineProps<Props>(), {
  45. size: 'medium',
  46. })
  47. const emit = defineEmits<{
  48. 'cancel-edit': []
  49. }>()
  50. const target = useTemplateRef('target')
  51. const isHoverTargetLink = ref(false)
  52. const isValid = ref(false) // default user made no changes
  53. const labelInstance = useTemplateRef('label')
  54. const newEditValue = ref(props.value)
  55. const isEditing = defineModel<boolean>('editing', {
  56. default: false,
  57. })
  58. const activeEditingMode = computed(() => {
  59. return !props.disabled && isEditing.value
  60. })
  61. const contentTooltip = computed(() => {
  62. if (props.disabled) return
  63. if (isHoverTargetLink.value) return i18n.t('Open link')
  64. return props.label || i18n.t('Start Editing')
  65. })
  66. const checkValidity = (edit: string) => {
  67. if (props.required) {
  68. isValid.value = edit.length >= 1
  69. } else {
  70. isValid.value = true
  71. }
  72. return isValid.value
  73. }
  74. const inputValue = computed({
  75. get: () => newEditValue.value,
  76. set: (value: string) => {
  77. newEditValue.value = value
  78. isValid.value = checkValidity(newEditValue.value)
  79. },
  80. })
  81. const stopEditing = (emitCancel = true) => {
  82. isEditing.value = false
  83. if (emitCancel) emit('cancel-edit')
  84. if (!newEditValue.value.length)
  85. newEditValue.value = props.initialEditValue ?? props.value
  86. }
  87. const activateEditing = (event?: MouseEvent | KeyboardEvent) => {
  88. if (props.detectLinks && (event?.target as HTMLElement)?.closest('a')) return // guard to prevent editing when clicking on a link
  89. if (isEditing.value || props.disabled) return
  90. isEditing.value = true
  91. }
  92. const submitEdit = () => {
  93. // Needs to be checked, because the 'onSubmit' function is not required.
  94. if (!props.onSubmitEdit) return undefined
  95. // Don't trigger a mutation if there is no change
  96. if (props.value === newEditValue.value) {
  97. stopEditing(false)
  98. return
  99. }
  100. if (!checkValidity(inputValue.value)) return
  101. const submitEditResult = props.onSubmitEdit(inputValue.value)
  102. if (submitEditResult instanceof Promise)
  103. return submitEditResult
  104. .then((result) => {
  105. result?.()
  106. stopEditing(false)
  107. })
  108. .catch(() => {})
  109. submitEditResult?.()
  110. stopEditing(false)
  111. }
  112. const handleMouseOver = (event: MouseEvent) => {
  113. if (!props.detectLinks) return
  114. if ((event.target as HTMLElement).closest('a')) {
  115. isHoverTargetLink.value = true
  116. return
  117. }
  118. isHoverTargetLink.value = false
  119. }
  120. const handleMouseLeave = () => {
  121. if (!props.detectLinks) return
  122. isHoverTargetLink.value = false
  123. }
  124. onClickOutside(target, () => {
  125. if (isEditing.value) return submitEdit()
  126. stopEditing()
  127. })
  128. const { setupLinksHandlers } = useHtmlLinks('/desktop')
  129. const handleEnterKey = (event: KeyboardEvent) => {
  130. event.preventDefault()
  131. submitEdit()
  132. }
  133. const processedContent = computed(() => {
  134. if (props.detectLinks) return textToHtml(props.value)
  135. return props.value
  136. })
  137. useTrapTab(target)
  138. watch(
  139. () => props.value,
  140. () => {
  141. if (props.detectLinks && labelInstance.value?.$el)
  142. setupLinksHandlers(labelInstance.value?.$el)
  143. },
  144. {
  145. flush: 'post',
  146. },
  147. )
  148. onMounted(() => {
  149. nextTick(() => {
  150. if (props.detectLinks && labelInstance.value?.$el)
  151. setupLinksHandlers(labelInstance.value?.$el)
  152. })
  153. })
  154. watch(isEditing, () => {
  155. newEditValue.value = props.initialEditValue ?? props.value
  156. })
  157. const vFocus = (el: HTMLElement) => {
  158. nextTick(() => el.focus())
  159. checkValidity(inputValue.value)
  160. }
  161. // Styling
  162. const focusClasses = computed(() => {
  163. let classes =
  164. 'group-focus-within:before:absolute group-focus-within:before:-left-[5px] group-focus-within:before:top-1/2 group-focus-within:before:z-0 group-focus-within:before:h-[calc(100%+10px)] group-focus-within:before:w-[calc(100%+10px)] group-focus-within:before:-translate-y-1/2 group-focus-within:before:rounded-md'
  165. if (props.alternativeBackground) {
  166. classes +=
  167. ' group-focus-within:before:bg-neutral-50 group-focus-within:before:dark:bg-gray-500'
  168. } else {
  169. classes +=
  170. ' group-focus-within:before:bg-blue-200 group-focus-within:before:dark:bg-gray-700'
  171. }
  172. return classes
  173. })
  174. const focusNonEditClasses = computed(() => ({
  175. [focusClasses.value]: !isEditing.value && !props.disabled,
  176. }))
  177. const disabledClasses = computed(() => ({
  178. 'cursor-text': props.disabled,
  179. }))
  180. const fontSizeClassMap = {
  181. xs: 'text-[10px] leading-[10px]',
  182. small: 'text-xs leading-snug',
  183. medium: 'text-sm leading-snug',
  184. large: 'text-base leading-snug',
  185. xl: 'text-xl leading-snug',
  186. }
  187. const minHeightClassMap = {
  188. xs: 'min-h-2',
  189. small: 'min-h-3',
  190. medium: 'min-h-4',
  191. large: 'min-h-5',
  192. xl: 'min-h-6',
  193. }
  194. const editBackgroundClass = computed(() =>
  195. props.alternativeBackground
  196. ? 'before:bg-neutral-50 before:dark:bg-gray-500'
  197. : 'before:bg-blue-200 before:dark:bg-gray-700',
  198. )
  199. const hoverClasses = computed(() => {
  200. let classes =
  201. 'before:absolute before:-left-[5px] before:top-1/2 before:-translate-y-1/2 before:-z-10 before:h-[calc(100%+10px)] before:w-[calc(100%+10px)] before:rounded-md'
  202. if (props.alternativeBackground) {
  203. classes += ' hover:before:bg-neutral-50 hover:before:dark:bg-gray-500'
  204. } else {
  205. classes += ' hover:before:bg-blue-200 hover:before:dark:bg-gray-700' // default background
  206. }
  207. return props.disabled ? '' : classes
  208. })
  209. defineExpose({
  210. activateEditing,
  211. isEditing,
  212. })
  213. </script>
  214. <template>
  215. <!-- eslint-disable vuejs-accessibility/no-static-element-interactions,vuejs-accessibility/mouse-events-have-key-events-->
  216. <div
  217. ref="target"
  218. :role="activeEditingMode || disabled ? undefined : 'button'"
  219. class="-:w-fit group relative flex items-center gap-1 focus:outline-none"
  220. :class="[
  221. disabledClasses,
  222. {
  223. 'w-full': block,
  224. },
  225. ]"
  226. :aria-disabled="disabled"
  227. :tabindex="activeEditingMode || disabled ? undefined : 0"
  228. @click.capture="activateEditing"
  229. @keydown.enter.capture="activateEditing"
  230. @mouseover="handleMouseOver"
  231. @mouseleave="handleMouseLeave"
  232. @keydown.esc="stopEditing()"
  233. >
  234. <div
  235. v-if="!isEditing"
  236. v-tooltip="contentTooltip"
  237. class="Content relative z-0 flex grow items-center"
  238. :class="[
  239. {
  240. grow: block,
  241. 'invisible opacity-0': isEditing,
  242. },
  243. focusNonEditClasses,
  244. hoverClasses,
  245. ]"
  246. >
  247. <!-- eslint-disable vue/no-v-text-v-html-on-component vue/no-v-html -->
  248. <CommonLabel
  249. :id="id"
  250. ref="label"
  251. class="z-10 break-words"
  252. style="word-break: break-word"
  253. v-bind="labelAttrs"
  254. :size="size"
  255. :class="[classes?.label, minHeightClassMap[size]]"
  256. v-html="processedContent"
  257. />
  258. </div>
  259. <div
  260. v-else
  261. class="flex max-w-full items-center gap-2 before:absolute before:-left-[5px] before:top-1/2 before:z-0 before:h-[calc(100%+10px)] before:w-[calc(100%+10px)] before:-translate-y-1/2 before:rounded-md"
  262. :class="[
  263. { 'w-full': block },
  264. editBackgroundClass,
  265. fontSizeClassMap[size],
  266. ]"
  267. >
  268. <div class="relative z-10 w-full ltr:pr-14 rtl:pl-14">
  269. <input
  270. key="editable-content-key"
  271. v-model.trim="inputValue"
  272. v-focus
  273. :aria-label="label"
  274. tabindex="0"
  275. class="-:text-gray-100 -:dark:text-neutral-400 block w-full flex-shrink-0 bg-transparent outline-none"
  276. :class="[{ grow: block }, classes?.input || '']"
  277. :disabled="disabled"
  278. :placeholder="placeholder"
  279. @keydown.stop.enter="handleEnterKey"
  280. />
  281. </div>
  282. <div class="absolute z-10 flex gap-1 ltr:right-0 rtl:left-0 rtl:-order-1">
  283. <CommonButton
  284. v-tooltip="cancelLabel || $t('Cancel')"
  285. icon="x-lg"
  286. variant="danger"
  287. @click="stopEditing()"
  288. @keydown.enter.stop="stopEditing()"
  289. />
  290. <CommonButton
  291. v-tooltip="submitLabel || $t('Save changes')"
  292. class="rtl:-order-1"
  293. icon="check2"
  294. :disabled="!isValid"
  295. variant="submit"
  296. @click="submitEdit"
  297. @keydown.enter.stop="submitEdit"
  298. />
  299. </div>
  300. </div>
  301. </div>
  302. </template>