@koumoul/vjsf 3.14.0 → 3.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koumoul/vjsf",
3
- "version": "3.14.0",
3
+ "version": "3.15.1",
4
4
  "description": "Generate forms for the vuetify UI library (vuejs) based on annotated JSON schemas.",
5
5
  "scripts": {
6
6
  "test": "vitest run",
@@ -71,7 +71,7 @@
71
71
  "vuetify": "^3.6.13"
72
72
  },
73
73
  "dependencies": {
74
- "@json-layout/core": "~1.10.0",
74
+ "@json-layout/core": "~1.10.1",
75
75
  "@json-layout/vocabulary": "~2.3.0",
76
76
  "@vueuse/core": "^12.5.0",
77
77
  "debug": "^4.3.4"
@@ -15,24 +15,32 @@ const props = defineProps({
15
15
  type: Object,
16
16
  required: true
17
17
  },
18
- formattedValue: {
18
+ readonly: {
19
+ /** @type import('vue').PropType<boolean> */
20
+ type: Boolean,
21
+ default: true
22
+ },
23
+ placeholder: {
19
24
  /** @type import('vue').PropType<string | null> */
20
25
  type: String,
21
26
  default: null
22
27
  }
23
28
  })
24
29
 
30
+ const emits = defineEmits(['blur'])
31
+
25
32
  const { inputProps, skeleton, compProps, data } = useField(
26
33
  toRef(props, 'modelValue'), props.statefulLayout, { isMainComp: false, bindData: false }
27
34
  )
28
35
 
29
36
  const fieldProps = computed(() => {
30
37
  const fieldProps = { ...inputProps.value }
31
- fieldProps.readonly = true
32
38
  fieldProps.clearable = fieldProps.clearable ?? !skeleton.value.required
39
+ if (props.placeholder) fieldProps.placeholder = props.placeholder
33
40
  fieldProps['onClick:clear'] = () => {
34
41
  props.statefulLayout.input(props.modelValue, null)
35
42
  }
43
+ fieldProps.readonly = props.readonly
36
44
  return fieldProps
37
45
  })
38
46
 
@@ -47,6 +55,7 @@ const menuProps = computed(() => {
47
55
 
48
56
  const textField = ref(null)
49
57
  const menuOpened = defineModel('menuOpened', { type: Boolean, default: false })
58
+ const formattedValue = defineModel('formattedValue', { type: String, default: null })
50
59
 
51
60
  </script>
52
61
 
@@ -56,6 +65,8 @@ const menuOpened = defineModel('menuOpened', { type: Boolean, default: false })
56
65
  v-bind="fieldProps"
57
66
  :model-value="formattedValue ?? data"
58
67
  @click:control="e => {menuOpened = !menuOpened; e.stopPropagation()}"
68
+ @update:model-value="v => formattedValue = v"
69
+ @blur="emits('blur')"
59
70
  >
60
71
  <template #prepend-inner>
61
72
  <slot name="prepend-inner" />
@@ -2,11 +2,14 @@
2
2
  import TextFieldMenu from '../fragments/text-field-menu.vue'
3
3
  import { VIcon } from 'vuetify/components/VIcon'
4
4
  import { VDatePicker } from 'vuetify/components/VDatePicker'
5
- import { useDate, useDefaults } from 'vuetify'
6
- import { computed, ref, toRef } from 'vue'
7
- import { getDateTimeParts, getDateTimeWithOffset } from '../../utils/dates.js'
5
+ import { useDefaults } from 'vuetify'
6
+ import { computed, ref, toRef, watch } from 'vue'
7
+ import Debug from 'debug'
8
+ import { getDateTimeParts, getDateTimeWithOffset, localeKeyboardFormat } from '../../utils/dates.js'
8
9
  import useNode from '../../composables/use-node.js'
9
10
 
11
+ const debug = Debug('vjsf:date-picker')
12
+
10
13
  useDefaults({}, 'VjsfDatePicker')
11
14
 
12
15
  const props = defineProps({
@@ -22,40 +25,60 @@ const props = defineProps({
22
25
  }
23
26
  })
24
27
 
25
- const vDate = useDate()
26
-
27
28
  const menuOpened = ref(false)
28
29
 
29
30
  const { compProps, localData } = useNode(toRef(props, 'modelValue'), props.statefulLayout)
30
31
 
32
+ const updateValue = (/** @type {Date | null} */value) => {
33
+ if (!value) return
34
+
35
+ const isoValue = props.modelValue.layout.format === 'date-time'
36
+ ? getDateTimeWithOffset(value)
37
+ : getDateTimeParts(/** @type Date */(/** @type unknown */(value)))[0]
38
+ if (isoValue !== localData.value) {
39
+ debug(`apply normalized iso value ${value.toLocaleString()} -> ${isoValue}`)
40
+ props.statefulLayout.input(props.modelValue, isoValue)
41
+ menuOpened.value = false
42
+ }
43
+ }
44
+
31
45
  const datePickerProps = computed(() => {
46
+ /** @type Record<String, any> */
32
47
  const datePickerProps = { ...compProps.value }
33
48
  datePickerProps.hideActions = true
34
49
  if (localData.value) datePickerProps.modelValue = new Date(/** @type {string} */(localData.value))
35
50
  datePickerProps['onUpdate:modelValue'] = (/** @type {Date} */value) => {
36
- if (!value) return
37
- if (props.modelValue.layout.format === 'date-time') {
38
- props.statefulLayout.input(props.modelValue, getDateTimeWithOffset(value))
39
- } else {
40
- props.statefulLayout.input(props.modelValue, getDateTimeParts(/** @type Date */(/** @type unknown */(value)))[0])
41
- }
42
- menuOpened.value = false
51
+ updateValue(value)
43
52
  }
44
53
  return datePickerProps
45
54
  })
46
55
 
47
- const formattedValue = computed(() => {
48
- return localData.value ? vDate.format(/** @type {string} */(localData.value), 'fullDateWithWeekday') : null
49
- })
56
+ /** @type {import('vue').Ref<string | null>} */
57
+ const formattedValue = ref('')
58
+ const setFormattedValue = () => {
59
+ formattedValue.value = localData.value ? localeKeyboardFormat(props.modelValue.options.locale).format(new Date(localData.value)) : null
60
+ }
61
+ watch(localData, setFormattedValue, { immediate: true })
62
+ const updateFormattedValue = () => {
63
+ if (formattedValue.value) {
64
+ const newValue = localeKeyboardFormat(props.modelValue.options.locale).parse(formattedValue.value)
65
+ debug(`parsed user input as date ${formattedValue.value} -> ${newValue?.toLocaleString()}`)
66
+ if (!newValue) setFormattedValue()
67
+ else updateValue(newValue)
68
+ }
69
+ }
50
70
 
51
71
  </script>
52
72
 
53
73
  <template>
54
74
  <text-field-menu
55
75
  v-model:menu-opened="menuOpened"
76
+ v-model:formatted-value="formattedValue"
56
77
  :model-value="props.modelValue"
57
78
  :stateful-layout="statefulLayout"
58
- :formatted-value="formattedValue"
79
+ :readonly="false"
80
+ :placeholder="props.modelValue.messages.keyboardDate"
81
+ @blur="updateFormattedValue"
59
82
  >
60
83
  <template #prepend-inner>
61
84
  <v-icon :icon="statefulLayout.options.icons.calendar" />
@@ -44,7 +44,7 @@ export default defineComponent({
44
44
  fieldProps.chips = true
45
45
  fieldProps.closableChips = true
46
46
  }
47
- fieldProps['onUpdate:modelValue'] = (/** @type string[] */value) => props.statefulLayout.input(props.modelValue, value && value.map(Number))
47
+ fieldProps['onUpdate:modelValue'] = (/** @type string[] */value) => props.statefulLayout.input(props.modelValue, value && (Array.isArray(value) ? value.map(Number) : Number(value)))
48
48
  return fieldProps
49
49
  })
50
50
 
@@ -1,9 +1,9 @@
1
1
  // TODO: parts of this can probably be replaced by https://vuetifyjs.com/en/features/dates/
2
2
 
3
3
  // 1 => 01, 12 => 12
4
- export const padTimeComponent = (/** @type number */val) => {
4
+ export const padNumber = (/** @type number */val, size = 2) => {
5
5
  const s = '' + val
6
- return s.length === 1 ? '0' + s : s
6
+ return s.padStart(size, '0')
7
7
  }
8
8
 
9
9
  // storing ISO times with the user's timezone offset is more dense in information that always storing the base ISO date
@@ -12,18 +12,18 @@ export const padTimeComponent = (/** @type number */val) => {
12
12
  // 2020-04-03T19:07:43.152Z => 2020-04-03T21:07:43+02:00
13
13
  export const getDateTimeWithOffset = (/** @type Date */date) => {
14
14
  const offsetMinutes = date.getTimezoneOffset()
15
- const offsetAbs = `${padTimeComponent(Math.abs(offsetMinutes / 60))}:${padTimeComponent(Math.abs(offsetMinutes % 60))}`
15
+ const offsetAbs = `${padNumber(Math.abs(offsetMinutes / 60))}:${padNumber(Math.abs(offsetMinutes % 60))}`
16
16
  let offset
17
17
  if (offsetMinutes < 0) offset = `+${offsetAbs}`
18
18
  else if (offsetMinutes > 0) offset = `-${offsetAbs}`
19
19
  else offset = 'Z'
20
- return `${date.getFullYear()}-${padTimeComponent(date.getMonth() + 1)}-${padTimeComponent(date.getDate())}T${padTimeComponent(date.getHours())}:${padTimeComponent(date.getMinutes())}:${padTimeComponent(date.getSeconds())}${offset}`
20
+ return `${padNumber(date.getFullYear(), 4)}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())}T${padNumber(date.getHours())}:${padNumber(date.getMinutes())}:${padNumber(date.getSeconds())}${offset}`
21
21
  }
22
22
 
23
23
  // get the the date and short time components expected by date-time picker from a full date
24
- // 2020-04-03T21:07:43+02:00 => ['2020-04-03', '19:07']
24
+ // 2020-04-03T21:07:43+02:00 => ['2020-04-03', '21:07']
25
25
  export const getDateTimeParts = (/** @type Date */date) => {
26
- return [`${date.getFullYear()}-${padTimeComponent(date.getMonth() + 1)}-${padTimeComponent(date.getDate())}`, `${padTimeComponent(date.getHours())}:${padTimeComponent(date.getMinutes())}`]
26
+ return [`${padNumber(date.getFullYear(), 4)}-${padNumber(date.getMonth() + 1)}-${padNumber(date.getDate())}`, `${padNumber(date.getHours())}:${padNumber(date.getMinutes())}`]
27
27
  }
28
28
 
29
29
  // get a full date-time from the date and time parts edited by date-time picker
@@ -50,3 +50,70 @@ export const getShortTime = (/** @type string | undefined */time) => {
50
50
  export const getLongTime = (/** @type string */time) => {
51
51
  return time + ':00Z'
52
52
  }
53
+
54
+ const applyDateParts = (/** @type {string} */year, /** @type {string} */month, /** @type {string} */day) => {
55
+ if (!year || !month || !day) return null
56
+ const y = Number(year)
57
+ if (isNaN(y)) return null
58
+ const m = Number(month)
59
+ if (isNaN(m)) return null
60
+ if (m < 1 || m > 12) return null
61
+ const d = Number(day)
62
+ if (isNaN(d)) return null
63
+ if (d < 1 || d > 31) return null
64
+
65
+ const date = new Date()
66
+ date.setFullYear(y)
67
+ date.setMonth(m - 1)
68
+ date.setDate(d)
69
+ date.setHours(0)
70
+ date.setMinutes(0)
71
+ date.setSeconds(0)
72
+ date.setMilliseconds(0)
73
+
74
+ return isNaN(date.getTime()) ? null : date
75
+ }
76
+
77
+ /** @type {Record<string, {format: (date: Date) => string, parse: (formateDate: string) => Date | null}>} */
78
+ const localeKeyboardFormats = {}
79
+ export const localeKeyboardFormat = (/** @type string */ locale) => {
80
+ // cf https://github.com/vuetifyjs/vuetify/blob/master/packages/vuetify/src/composables/date/adapters/vuetify.ts#L239
81
+ const format = new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit' })
82
+ const parts = format.formatToParts(new Date())
83
+ const fns = {
84
+ format: (/** @type Date */ date) => format.format(date),
85
+ parse: (/** @type string */ formattedDate) => {
86
+ let remainingStr = formattedDate
87
+ let year = ''
88
+ let month = ''
89
+ let day = ''
90
+ for (let i = 0; i < parts.length; i++) {
91
+ const part = parts[i]
92
+ if (part.type !== 'literal') {
93
+ const nextSep = parts[i + 1]
94
+ if (nextSep && nextSep.type !== 'literal') {
95
+ console.error('failed to work on keyboard date format', parts)
96
+ throw new Error('failed to work on keyboard date format')
97
+ }
98
+ let matchValue = remainingStr
99
+ if (nextSep?.type === 'literal') {
100
+ const nextSepPos = remainingStr.indexOf(nextSep.value)
101
+ matchValue = remainingStr.substring(0, nextSepPos)
102
+ remainingStr = remainingStr.substring(nextSepPos + nextSep.value.length)
103
+ }
104
+ if (part.type === 'year') year = matchValue
105
+ if (part.type === 'month') month = matchValue
106
+ if (part.type === 'day') day = matchValue
107
+ }
108
+ }
109
+ const date = applyDateParts(year, month, day)
110
+ if (date) return date
111
+
112
+ // also try iso format
113
+ const [y, m, d] = formattedDate.split('-')
114
+ return applyDateParts(y, m, d)
115
+ }
116
+ }
117
+ if (!localeKeyboardFormats[locale]) localeKeyboardFormats[locale] = fns
118
+ return fns
119
+ }
@@ -1,11 +1,14 @@
1
1
  declare const _default: __VLS_WithTemplateSlots<import("vue").DefineComponent<{}, {
2
+ $emit: (event: "blur", ...args: any[]) => void;
2
3
  modelValue: import("../../types.js").VjsfNode;
4
+ readonly: boolean;
3
5
  statefulLayout: import("../../types.js").VjsfStatefulLayout;
4
- formattedValue: string | null;
6
+ placeholder: string | null;
5
7
  $props: {
6
8
  readonly modelValue?: import("../../types.js").VjsfNode | undefined;
9
+ readonly readonly?: boolean | undefined;
7
10
  readonly statefulLayout?: import("../../types.js").VjsfStatefulLayout | undefined;
8
- readonly formattedValue?: string | null | undefined;
11
+ readonly placeholder?: string | null | undefined;
9
12
  };
10
13
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>, {
11
14
  "prepend-inner"?(_: {}): any;
@@ -1 +1 @@
1
- {"version":3,"file":"text-field-menu.vue.d.ts","sourceRoot":"","sources":["../../../src/components/fragments/text-field-menu.vue.js"],"names":[],"mappings":";;;;;;;;;;6BAgJsC,GAAG;;;QACX,GAAG"}
1
+ {"version":3,"file":"text-field-menu.vue.d.ts","sourceRoot":"","sources":["../../../src/components/fragments/text-field-menu.vue.js"],"names":[],"mappings":";;;;;;;;;;;;;6BAiKsC,GAAG;;;QACX,GAAG"}
@@ -1,7 +1,11 @@
1
- export function padTimeComponent(val: number): string;
1
+ export function padNumber(val: number, size?: number): string;
2
2
  export function getDateTimeWithOffset(date: Date): string;
3
3
  export function getDateTimeParts(date: Date): string[];
4
4
  export function getDateTime(parts: [string, string]): string;
5
5
  export function getShortTime(time: string | undefined): string;
6
6
  export function getLongTime(time: string): string;
7
+ export function localeKeyboardFormat(locale: string): {
8
+ format: (date: Date) => string;
9
+ parse: (formattedDate: string) => Date | null;
10
+ };
7
11
  //# sourceMappingURL=dates.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dates.d.ts","sourceRoot":"","sources":["../../src/utils/dates.js"],"names":[],"mappings":"AAGO,sCAAoC,MAAM,UAGhD;AAMM,4CAAyC,IAAI,UAQnD;AAIM,uCAAoC,IAAI,YAE9C;AAIM,mCAA+B,CAAC,MAAM,EAAE,MAAM,CAAC,UAWrD;AAGM,mCAAgC,MAAM,GAAG,SAAS,UAGxD;AAEM,kCAA+B,MAAM,UAE3C"}
1
+ {"version":3,"file":"dates.d.ts","sourceRoot":"","sources":["../../src/utils/dates.js"],"names":[],"mappings":"AAGO,+BAA6B,MAAM,yBAGzC;AAMM,4CAAyC,IAAI,UAQnD;AAIM,uCAAoC,IAAI,YAE9C;AAIM,mCAA+B,CAAC,MAAM,EAAE,MAAM,CAAC,UAWrD;AAGM,mCAAgC,MAAM,GAAG,SAAS,UAGxD;AAEM,kCAA+B,MAAM,UAE3C;AA2BM,6CAAwC,MAAM;mBAK9B,IAAI;2BACL,MAAM;EAkC3B"}