CommonInlineEdit.vue 9.0 KB

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