FieldDateTimeInput.vue 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import VueDatePicker from '@vuepic/vue-datepicker'
  4. import { useEventListener } from '@vueuse/core'
  5. import { computed, nextTick, ref, toRef } from 'vue'
  6. import useValue from '#shared/components/Form/composables/useValue.ts'
  7. import type { DateTimeContext } from '#shared/components/Form/fields/FieldDate/types.ts'
  8. import { useDateTime } from '#shared/components/Form/fields/FieldDate/useDateTime.ts'
  9. import { i18n } from '#shared/i18n.ts'
  10. import testFlags from '#shared/utils/testFlags.ts'
  11. import '@vuepic/vue-datepicker/dist/main.css'
  12. interface Props {
  13. context: DateTimeContext
  14. }
  15. const props = defineProps<Props>()
  16. const contextReactive = toRef(props, 'context')
  17. const { localValue } = useValue(contextReactive)
  18. const {
  19. ariaLabels,
  20. displayFormat,
  21. is24,
  22. minDate,
  23. position,
  24. timePicker,
  25. valueFormat,
  26. } = useDateTime(contextReactive)
  27. const config = {
  28. keepActionRow: true,
  29. }
  30. const actionRow = {
  31. showSelect: false,
  32. showCancel: false,
  33. showNow: true,
  34. showPreview: false,
  35. }
  36. const input = ref<HTMLInputElement>()
  37. const picker = ref()
  38. const showPicker = ref(false)
  39. const pickerDisplayStyle = computed(() => (showPicker.value ? 'block' : 'none'))
  40. const expandPicker = () => {
  41. showPicker.value = true
  42. nextTick(() => {
  43. testFlags.set(`field-date-time-${props.context.id}.opened`)
  44. })
  45. }
  46. const collapsePicker = () => {
  47. showPicker.value = false
  48. nextTick(() => {
  49. testFlags.set(`field-date-time-${props.context.id}.closed`)
  50. })
  51. }
  52. // Hide calendar, if clicked outside of the picker or input.
  53. useEventListener('click', (e) => {
  54. const { target } = e
  55. if (!target || !picker.value || !showPicker.value || !input.value) return
  56. const outer = (target as Element).closest('.formkit-outer')
  57. if (!outer) return
  58. const insideFormField = !outer.contains(target as Node)
  59. if (insideFormField) return
  60. collapsePicker()
  61. })
  62. </script>
  63. <template>
  64. <div class="flex w-full">
  65. <!-- eslint-disable vuejs-accessibility/aria-props -->
  66. <VueDatePicker
  67. ref="picker"
  68. v-model="localValue"
  69. :class="{ 'pointer-events-none': context.disabled }"
  70. :uid="context.id"
  71. :model-type="valueFormat"
  72. :name="context.node.name"
  73. :clearable="context.clearable ?? false"
  74. :disabled="context.disabled"
  75. :range="context.range"
  76. :enable-time-picker="timePicker"
  77. :format="displayFormat"
  78. :is-24="is24"
  79. :locale="i18n.locale()"
  80. :max-date="context.maxDate"
  81. :min-date="minDate"
  82. :start-date="minDate || context.maxDate"
  83. :ignore-time-validation="!timePicker"
  84. :prevent-min-max-navigation="
  85. Boolean(minDate || context.maxDate || context.futureOnly)
  86. "
  87. :now-button-label="$t('Today')"
  88. :position="position"
  89. :action-row="actionRow"
  90. :config="config"
  91. :aria-labels="ariaLabels"
  92. :inline="{ input: true }"
  93. :month-change-on-scroll="false"
  94. auto-apply
  95. dark
  96. text-input
  97. @open="expandPicker"
  98. @close="collapsePicker"
  99. @blur="context.handlers.blur"
  100. >
  101. <template
  102. #dp-input="{
  103. value,
  104. onInput,
  105. onEnter,
  106. onTab,
  107. onBlur,
  108. onKeypress,
  109. onPaste,
  110. }"
  111. >
  112. <input
  113. :id="context.id"
  114. ref="input"
  115. :value="value"
  116. :name="context.node.name"
  117. :class="context.classes.input"
  118. :aria-describedby="context.describedBy"
  119. :disabled="context.disabled"
  120. type="text"
  121. v-bind="context.attrs"
  122. @input="onInput"
  123. @keypress.enter="onEnter"
  124. @keypress.tab="onTab"
  125. @keypress="onKeypress"
  126. @paste="onPaste"
  127. @blur="onBlur"
  128. @focus="expandPicker"
  129. />
  130. <div v-if="showPicker" class="w-full" :class="{ 'pe-2': context.link }">
  131. <div class="h-[1px] w-full bg-white/10"></div>
  132. </div>
  133. </template>
  134. <template #clear-icon>
  135. <CommonIcon
  136. class="text-gray absolute -mt-5 shrink-0 ltr:right-2 rtl:left-2"
  137. :aria-label="i18n.t('Clear Selection')"
  138. name="close-small"
  139. size="base"
  140. role="button"
  141. tabindex="0"
  142. @click.stop="picker?.clearValue()"
  143. @keypress.space.prevent.stop="picker?.clearValue()"
  144. />
  145. </template>
  146. <template #clock-icon>
  147. <CommonIcon name="clock" size="tiny" decorative />
  148. </template>
  149. <template #calendar-icon>
  150. <CommonIcon name="calendar" size="tiny" decorative />
  151. </template>
  152. <template #arrow-left>
  153. <CommonIcon name="chevron-left" size="xs" decorative />
  154. </template>
  155. <template #arrow-right>
  156. <CommonIcon name="chevron-right" size="xs" decorative />
  157. </template>
  158. <template #arrow-up>
  159. <CommonIcon name="chevron-up" size="xs" decorative />
  160. </template>
  161. <template #arrow-down>
  162. <CommonIcon name="chevron-down" size="xs" decorative />
  163. </template>
  164. </VueDatePicker>
  165. </div>
  166. </template>
  167. <style scoped>
  168. :deep(.dp__outer_menu_wrap) .dp__menu {
  169. /* stylelint-disable value-keyword-case */
  170. display: v-bind(pickerDisplayStyle);
  171. max-width: var(--dp-menu-min-width);
  172. margin: 0 auto;
  173. }
  174. :deep(.dp__theme_dark) {
  175. --dp-background-color: theme(colors.gray.500);
  176. --dp-text-color: theme(colors.white);
  177. --dp-hover-color: theme(colors.transparent);
  178. --dp-hover-text-color: theme(colors.white);
  179. --dp-hover-icon-color: theme(colors.white);
  180. --dp-primary-color: theme(colors.blue.DEFAULT);
  181. --dp-secondary-color: theme(colors.gray.200);
  182. --dp-border-color: theme(colors.transparent);
  183. --dp-menu-border-color: theme(colors.transparent);
  184. --dp-border-color-hover: theme(colors.transparent);
  185. --dp-range-between-dates-background-color: theme(colors.blue.highlight);
  186. --dp-range-between-dates-text-color: theme(colors.white);
  187. --dp-range-between-border-color: theme(colors.transparent);
  188. &:where([data-errors='true'] *),
  189. &:where([data-invalid='true'] *) {
  190. --dp-background-color: theme(colors.red.dark);
  191. }
  192. }
  193. :deep(.dp__main) {
  194. --dp-font-family: theme(fontFamily.sans);
  195. --dp-border-radius: theme(borderRadius.md);
  196. --dp-cell-border-radius: theme(borderRadius.full);
  197. --dp-button-height: theme(size.8);
  198. --dp-action-button-height: theme(size.8);
  199. --dp-month-year-row-height: theme(size.8);
  200. --dp-month-year-row-button-size: theme(size.8);
  201. --dp-common-padding: theme(padding.2);
  202. --dp-action-row-padding: theme(padding.2);
  203. --dp-menu-min-width: 260px;
  204. --dp-font-size: theme(fontSize.base);
  205. --dp-preview-font-size: theme(fontSize.base);
  206. --dp-time-font-size: theme(fontSize.xl);
  207. & > div {
  208. width: 100%;
  209. }
  210. .dp {
  211. &__button,
  212. &__action_button {
  213. border: none;
  214. color: theme(colors.white);
  215. background: theme(colors.gray.200);
  216. }
  217. &__clear_icon {
  218. top: 2.3rem;
  219. }
  220. &--tp-wrap {
  221. padding: var(--dp-common-padding);
  222. max-width: none;
  223. }
  224. &__btn,
  225. &__button,
  226. &__calendar_item,
  227. &__action_button {
  228. transition: none;
  229. border-radius: theme(borderRadius.md);
  230. }
  231. &__action_buttons {
  232. margin-inline-start: 0;
  233. flex-grow: 1;
  234. }
  235. &__action_button {
  236. margin-inline-start: 0;
  237. transition: none;
  238. flex-grow: 1;
  239. display: inline-flex;
  240. justify-content: center;
  241. border-radius: theme(borderRadius.md);
  242. }
  243. &__action_cancel {
  244. border: none;
  245. }
  246. &--arrow-btn-nav .dp__inner_nav {
  247. color: theme(colors.blue.DEFAULT);
  248. }
  249. &__overlay_container + .dp__button,
  250. &__overlay_row + .dp__button {
  251. width: auto;
  252. margin: theme(margin.2);
  253. }
  254. &__overlay_container + .dp__button:not(.dp__overlay_action) {
  255. width: calc(var(--dp-menu-min-width) - theme(margin[1.5]) * 2);
  256. }
  257. &__overlay_container + .dp__button.dp__overlay_action {
  258. width: calc(var(--dp-menu-min-width) - theme(margin[2.5]) * 2);
  259. }
  260. }
  261. }
  262. </style>