@gitlab/duo-ui 8.6.0 → 8.7.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/CHANGELOG.md CHANGED
@@ -1,3 +1,22 @@
1
+ ## [8.7.1](https://gitlab.com/gitlab-org/duo-ui/compare/v8.7.0...v8.7.1) (2025-03-11)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * Duo Chat overflow on header ([90b37c7](https://gitlab.com/gitlab-org/duo-ui/commit/90b37c70ccb25f93dfdb1f3709b59c8125c1b428))
7
+
8
+ # [8.7.0](https://gitlab.com/gitlab-org/duo-ui/compare/v8.6.0...v8.7.0) (2025-03-11)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * adapt default to BCP 47 ([7786272](https://gitlab.com/gitlab-org/duo-ui/commit/7786272f35b6447bda958ac01fc6922d0fb4c102))
14
+
15
+
16
+ ### Features
17
+
18
+ * improve date formatting with localization support ([24c2548](https://gitlab.com/gitlab-org/duo-ui/commit/24c2548b2576fd70e5e93511f7107d965dd6361d))
19
+
1
20
  # [8.6.0](https://gitlab.com/gitlab-org/duo-ui/compare/v8.5.0...v8.6.0) (2025-03-11)
2
21
 
3
22
 
@@ -1,6 +1,6 @@
1
1
  import { GlButton, GlIcon } from '@gitlab/ui';
2
2
  import { translate } from '../../../../utils/i18n';
3
- import { formatDate } from '../../../../utils/date';
3
+ import { formatLocalizedDate } from '../../../../utils/date';
4
4
  import DuoChatThreadsEmpty from './duo_chat_threads_empty';
5
5
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
6
6
 
@@ -19,11 +19,15 @@ var script = {
19
19
  threads: {
20
20
  type: Array,
21
21
  required: true
22
+ },
23
+ preferredLocale: {
24
+ type: Array,
25
+ required: true
22
26
  }
23
27
  },
24
28
  computed: {
25
- formatDate() {
26
- return formatDate;
29
+ formattedLocalDate() {
30
+ return date => formatLocalizedDate(date, this.preferredLocale);
27
31
  },
28
32
  groupedThreads() {
29
33
  return this.threads.reduce((threadsGroupedByDate, thread) => {
@@ -61,7 +65,7 @@ var script = {
61
65
  const __vue_script__ = script;
62
66
 
63
67
  /* template */
64
- var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{staticClass:"gl-h-full gl-p-5"},[_c('div',{staticClass:"gl-bg-gray-50 gl-text-gray-500 gl-p-4 gl-mb-5 gl-rounded-base",attrs:{"data-testid":"chat-threads-info-banner"}},[_c('p',{staticClass:"gl-m-0 gl-flex"},[_c('gl-icon',{staticClass:"gl-mr-4",attrs:{"name":"bulb"}}),_vm._v(_vm._s(_vm.$options.i18n.CHAT_HISTORY_INFO)+"\n ")],1)]),_vm._v(" "),(_vm.hasThreads)?_vm._l((_vm.groupedThreads),function(threadsForDate,date){return _c('div',{key:date},[_c('div',{staticClass:"gl-font-bold gl-neutral-900 gl-mb-4",attrs:{"data-testid":"chat-threads-date-header"}},[_vm._v("\n "+_vm._s(_vm.formatDate(date))+"\n ")]),_vm._v(" "),_c('div',_vm._l((threadsForDate),function(thread){return _c('div',{key:thread.id,staticClass:"gl-flex gl-align-center gl-mb-4"},[_c('div',{staticClass:"thread-box hover:gl-bg-gray-50 focus:gl-bg-gray-50 gl-text-ellipsis gl-overflow-hidden gl-rounded-base gl-cursor-pointer gl-rounded-base gl-p-4 gl-w-full gl-whitespace-nowrap",attrs:{"tabindex":"0","data-testid":"chat-threads-thread-box"},on:{"click":function($event){return _vm.onSelectThread(thread)}}},[_vm._v("\n "+_vm._s(thread.title || 'Untitled Chat')+"\n ")]),_vm._v(" "),_c('gl-button',{staticClass:"gl-neutral-900 !gl-p-4",attrs:{"data-testid":"chat-threads-delete-thread-button","icon":"remove","category":"tertiary","size":"small","aria-label":_vm.$options.i18n.THREAD_DELETE_LABEL},on:{"click":function($event){return _vm.$emit('delete-thread', thread.id)}}})],1)}),0)])}):_c('duo-chat-threads-empty')],2)};
68
+ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{staticClass:"gl-h-full gl-p-5"},[_c('div',{staticClass:"gl-bg-gray-50 gl-text-gray-500 gl-p-4 gl-mb-5 gl-rounded-base",attrs:{"data-testid":"chat-threads-info-banner"}},[_c('p',{staticClass:"gl-m-0 gl-flex"},[_c('gl-icon',{staticClass:"gl-mr-4",attrs:{"name":"bulb"}}),_vm._v(_vm._s(_vm.$options.i18n.CHAT_HISTORY_INFO)+"\n ")],1)]),_vm._v(" "),(_vm.hasThreads)?_vm._l((_vm.groupedThreads),function(threadsForDate,date){return _c('div',{key:date},[_c('div',{staticClass:"gl-font-bold gl-neutral-900 gl-mb-4",attrs:{"data-testid":"chat-threads-date-header"}},[_vm._v("\n "+_vm._s(_vm.formattedLocalDate(date))+"\n ")]),_vm._v(" "),_c('div',_vm._l((threadsForDate),function(thread){return _c('div',{key:thread.id,staticClass:"gl-flex gl-align-center gl-mb-4"},[_c('div',{staticClass:"thread-box hover:gl-bg-gray-50 focus:gl-bg-gray-50 gl-text-ellipsis gl-overflow-hidden gl-rounded-base gl-cursor-pointer gl-rounded-base gl-p-4 gl-w-full gl-whitespace-nowrap",attrs:{"tabindex":"0","data-testid":"chat-threads-thread-box"},on:{"click":function($event){return _vm.onSelectThread(thread)}}},[_vm._v("\n "+_vm._s(thread.title || 'Untitled Chat')+"\n ")]),_vm._v(" "),_c('gl-button',{staticClass:"gl-neutral-900 !gl-p-4",attrs:{"data-testid":"chat-threads-delete-thread-button","icon":"remove","category":"tertiary","size":"small","aria-label":_vm.$options.i18n.THREAD_DELETE_LABEL},on:{"click":function($event){return _vm.$emit('delete-thread', thread.id)}}})],1)}),0)])}):_c('duo-chat-threads-empty')],2)};
65
69
  var __vue_staticRenderFns__ = [];
66
70
 
67
71
  /* style */
@@ -33,6 +33,14 @@ const isThread = thread => typeof thread === 'object' && typeof thread.id === 's
33
33
 
34
34
  // eslint-disable-next-line unicorn/no-array-callback-reference
35
35
  const threadListValidator = threads => threads.every(isThread);
36
+ const localeValidator = value => {
37
+ try {
38
+ Intl.getCanonicalLocales(value);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ };
36
44
  var script = {
37
45
  name: 'DuoChat',
38
46
  components: {
@@ -232,6 +240,16 @@ var script = {
232
240
  type: Boolean,
233
241
  required: false,
234
242
  default: false
243
+ },
244
+ /**
245
+ * The preferred locale for the chat interface.
246
+ * Follows BCP 47 language tag format (e.g., 'en-US', 'fr-FR', 'es-ES').
247
+ */
248
+ preferredLocale: {
249
+ type: Array,
250
+ required: false,
251
+ default: () => ['en-US', 'en'],
252
+ validator: localeValidator
235
253
  }
236
254
  },
237
255
  data() {
@@ -244,7 +262,8 @@ var script = {
244
262
  compositionJustEnded: false,
245
263
  contextItemsMenuIsOpen: false,
246
264
  contextItemMenuRef: null,
247
- currentView: this.multiThreadedView
265
+ currentView: this.multiThreadedView,
266
+ headerHeight: 0
248
267
  };
249
268
  },
250
269
  computed: {
@@ -351,8 +370,13 @@ var script = {
351
370
  },
352
371
  mounted() {
353
372
  this.scrollToBottom();
373
+ this.positionContent();
354
374
  },
355
375
  methods: {
376
+ positionContent() {
377
+ var _this$$refs, _this$$refs$header, _this$$refs$header$$e, _this$$refs$header$$e2;
378
+ this.headerHeight = ((_this$$refs = this.$refs) === null || _this$$refs === void 0 ? void 0 : (_this$$refs$header = _this$$refs.header) === null || _this$$refs$header === void 0 ? void 0 : (_this$$refs$header$$e = _this$$refs$header.$el) === null || _this$$refs$header$$e === void 0 ? void 0 : (_this$$refs$header$$e2 = _this$$refs$header$$e.getBoundingClientRect()) === null || _this$$refs$header$$e2 === void 0 ? void 0 : _this$$refs$header$$e2.height) || 0;
379
+ },
356
380
  onGoBack() {
357
381
  this.$emit('back-to-list');
358
382
  },
@@ -574,7 +598,7 @@ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=
574
598
  },attrs:{"width":_vm.shouldRenderResizable ? _vm.dimensions.width : null,"height":_vm.shouldRenderResizable ? _vm.dimensions.height : null,"max-width":_vm.shouldRenderResizable ? _vm.dimensions.maxWidth : null,"max-height":_vm.shouldRenderResizable ? _vm.dimensions.maxHeight : null,"min-width":_vm.shouldRenderResizable ? _vm.dimensions.minWidth : null,"left":_vm.shouldRenderResizable ? _vm.dimensions.left : null,"top":_vm.shouldRenderResizable ? _vm.dimensions.top : null,"fit-parent":true,"min-height":_vm.shouldRenderResizable ? _vm.dimensions.minHeight : null,"active":_vm.shouldRenderResizable ? ['l', 't', 'lt'] : null},on:{"resize:end":_vm.updateSize}},[(!_vm.isHidden)?_c('aside',{staticClass:"markdown-code-block duo-chat gl-bottom-0 gl-max-h-full",class:{
575
599
  'resizable-content': _vm.shouldRenderResizable,
576
600
  'duo-chat-drawer': !_vm.shouldRenderResizable,
577
- },attrs:{"id":"chat-component","role":"complementary","data-testid":"chat-component"}},[(_vm.showHeader)?_c('duo-chat-header',{attrs:{"active-thread-id":_vm.activeThreadId,"title":_vm.isMultithreaded && _vm.currentView === 'list' ? _vm.$options.i18n.CHAT_HISTORY_TITLE : _vm.title,"error":_vm.error,"is-multithreaded":_vm.isMultithreaded,"current-view":_vm.currentView,"should-render-resizable":_vm.shouldRenderResizable,"badge-type":_vm.isMultithreaded ? null : _vm.badgeType},on:{"go-back":_vm.onGoBack,"new-chat":_vm.onNewChat,"close":_vm.hideChat},scopedSlots:_vm._u([{key:"subheader",fn:function(){return [_vm._t("subheader")]},proxy:true}],null,true)}):_vm._e(),_vm._v(" "),(_vm.shouldShowThreadList)?_c('div',{staticClass:"gl-h-full"},[_c('duo-chat-threads',{attrs:{"threads":_vm.threadList},on:{"new-chat":_vm.onNewChat,"select-thread":_vm.onSelectThread,"delete-thread":_vm.onDeleteThread,"close":_vm.hideChat}})],1):_c('span',{staticClass:"gl-h-full gl-flex gl-flex-col gl-justify-end"},[_c('div',{staticClass:"duo-chat-drawer-body gl-bg-default",attrs:{"data-testid":"chat-history"},on:{"scroll":_vm.handleScrollingTrottled}},[_c('transition-group',{staticClass:"duo-chat-history gl-flex gl-flex-col gl-justify-end",class:[
601
+ },attrs:{"id":"chat-component","role":"complementary","data-testid":"chat-component"}},[(_vm.showHeader)?_c('duo-chat-header',{ref:"header",attrs:{"active-thread-id":_vm.activeThreadId,"title":_vm.isMultithreaded && _vm.currentView === 'list' ? _vm.$options.i18n.CHAT_HISTORY_TITLE : _vm.title,"error":_vm.error,"is-multithreaded":_vm.isMultithreaded,"current-view":_vm.currentView,"should-render-resizable":_vm.shouldRenderResizable,"badge-type":_vm.isMultithreaded ? null : _vm.badgeType},on:{"go-back":_vm.onGoBack,"new-chat":_vm.onNewChat,"close":_vm.hideChat},scopedSlots:_vm._u([{key:"subheader",fn:function(){return [_vm._t("subheader")]},proxy:true}],null,true)}):_vm._e(),_vm._v(" "),(_vm.shouldShowThreadList)?_c('div',{staticClass:"gl-h-full"},[_c('duo-chat-threads',{attrs:{"threads":_vm.threadList,"preferred-locale":_vm.preferredLocale},on:{"new-chat":_vm.onNewChat,"select-thread":_vm.onSelectThread,"delete-thread":_vm.onDeleteThread,"close":_vm.hideChat}})],1):_c('span',{staticClass:"gl-h-full gl-flex gl-flex-col gl-justify-end"},[_c('div',{staticClass:"duo-chat-drawer-body gl-bg-default",style:({ marginTop: (_vm.headerHeight + "px") }),attrs:{"data-testid":"chat-history"},on:{"scroll":_vm.handleScrollingTrottled}},[_c('transition-group',{staticClass:"duo-chat-history gl-flex gl-flex-col gl-justify-end",class:[
578
602
  {
579
603
  'gl-h-full': !_vm.hasMessages,
580
604
  'force-scroll-bar': _vm.hasMessages,
@@ -1,3 +1,4 @@
1
+ const DEFAULT_LOCALE = ['en-US'];
1
2
  function getOrdinalSuffix(day) {
2
3
  if (day > 3 && day < 21) return 'th';
3
4
  switch (day % 10) {
@@ -11,17 +12,39 @@ function getOrdinalSuffix(day) {
11
12
  return 'th';
12
13
  }
13
14
  }
14
- function formatDate(dateStr) {
15
+ const isValidDateString = dateStr => {
16
+ if (!dateStr) return false;
15
17
  const date = new Date(dateStr);
18
+ return date.toString() !== 'Invalid Date';
19
+ };
20
+ const getValidLocale = preferredLocale => {
21
+ const locale = preferredLocale !== null && preferredLocale !== void 0 && preferredLocale.length ? preferredLocale : DEFAULT_LOCALE;
22
+ const supportedLocales = Intl.DateTimeFormat.supportedLocalesOf(locale);
23
+ return supportedLocales.length ? supportedLocales : DEFAULT_LOCALE;
24
+ };
25
+ const createDateFormatter = locale => {
26
+ return new Intl.DateTimeFormat(locale, {
27
+ month: 'long',
28
+ day: 'numeric',
29
+ year: 'numeric'
30
+ });
31
+ };
32
+ const formatEnglishDate = (date, formatter) => {
16
33
  const day = date.getDate();
17
34
  const suffix = getOrdinalSuffix(day);
18
- const month = new Intl.DateTimeFormat('en-US', {
19
- month: 'long'
20
- }).format(date);
21
- const year = new Intl.DateTimeFormat('en-US', {
22
- year: 'numeric'
23
- }).format(date);
24
- return `${month} ${day}${suffix}, ${year}`;
35
+ const parts = formatter.formatToParts(date);
36
+ const formattedParts = parts.map(part => part.type === 'day' ? `${part.value}${suffix}` : part.value);
37
+ return formattedParts.join('');
38
+ };
39
+ function formatLocalizedDate(dateStr, preferredLocale) {
40
+ var _validLocale$;
41
+ if (!isValidDateString(dateStr)) {
42
+ return 'Invalid Date';
43
+ }
44
+ const date = new Date(dateStr);
45
+ const validLocale = getValidLocale(preferredLocale);
46
+ const formatter = createDateFormatter(validLocale);
47
+ return (_validLocale$ = validLocale[0]) !== null && _validLocale$ !== void 0 && _validLocale$.startsWith('en') ? formatEnglishDate(date, formatter) : formatter.format(date);
25
48
  }
26
49
 
27
- export { formatDate, getOrdinalSuffix };
50
+ export { formatLocalizedDate, getOrdinalSuffix };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/duo-ui",
3
- "version": "8.6.0",
3
+ "version": "8.7.1",
4
4
  "description": "Duo UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -1,7 +1,7 @@
1
1
  <script>
2
2
  import { GlButton, GlIcon } from '@gitlab/ui';
3
3
  import { translate } from '../../../../utils/i18n';
4
- import { formatDate } from '../../../../utils/date';
4
+ import { formatLocalizedDate } from '../../../../utils/date';
5
5
  import DuoChatThreadsEmpty from './duo_chat_threads_empty.vue';
6
6
 
7
7
  const i18n = {
@@ -26,11 +26,15 @@ export default {
26
26
  type: Array,
27
27
  required: true,
28
28
  },
29
+ preferredLocale: {
30
+ type: Array,
31
+ required: true,
32
+ },
29
33
  },
30
34
 
31
35
  computed: {
32
- formatDate() {
33
- return formatDate;
36
+ formattedLocalDate() {
37
+ return (date) => formatLocalizedDate(date, this.preferredLocale);
34
38
  },
35
39
 
36
40
  groupedThreads() {
@@ -86,7 +90,7 @@ export default {
86
90
  <template v-if="hasThreads">
87
91
  <div v-for="(threadsForDate, date) in groupedThreads" :key="date">
88
92
  <div data-testid="chat-threads-date-header" class="gl-font-bold gl-neutral-900 gl-mb-4">
89
- {{ formatDate(date) }}
93
+ {{ formattedLocalDate(date) }}
90
94
  </div>
91
95
 
92
96
  <div>
@@ -86,6 +86,15 @@ const isThread = (thread) =>
86
86
  // eslint-disable-next-line unicorn/no-array-callback-reference
87
87
  const threadListValidator = (threads) => threads.every(isThread);
88
88
 
89
+ const localeValidator = (value) => {
90
+ try {
91
+ Intl.getCanonicalLocales(value);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ };
97
+
89
98
  export default {
90
99
  name: 'DuoChat',
91
100
  components: {
@@ -287,6 +296,16 @@ export default {
287
296
  required: false,
288
297
  default: false,
289
298
  },
299
+ /**
300
+ * The preferred locale for the chat interface.
301
+ * Follows BCP 47 language tag format (e.g., 'en-US', 'fr-FR', 'es-ES').
302
+ */
303
+ preferredLocale: {
304
+ type: Array,
305
+ required: false,
306
+ default: () => ['en-US', 'en'],
307
+ validator: localeValidator,
308
+ },
290
309
  },
291
310
  data() {
292
311
  return {
@@ -299,6 +318,7 @@ export default {
299
318
  contextItemsMenuIsOpen: false,
300
319
  contextItemMenuRef: null,
301
320
  currentView: this.multiThreadedView,
321
+ headerHeight: 0,
302
322
  };
303
323
  },
304
324
  computed: {
@@ -418,9 +438,13 @@ export default {
418
438
  },
419
439
  mounted() {
420
440
  this.scrollToBottom();
441
+ this.positionContent();
421
442
  },
422
443
 
423
444
  methods: {
445
+ positionContent() {
446
+ this.headerHeight = this.$refs?.header?.$el?.getBoundingClientRect()?.height || 0;
447
+ },
424
448
  onGoBack() {
425
449
  this.$emit('back-to-list');
426
450
  },
@@ -665,6 +689,7 @@ export default {
665
689
  >
666
690
  <duo-chat-header
667
691
  v-if="showHeader"
692
+ ref="header"
668
693
  :active-thread-id="activeThreadId"
669
694
  :title="
670
695
  isMultithreaded && currentView === 'list' ? $options.i18n.CHAT_HISTORY_TITLE : title
@@ -686,6 +711,7 @@ export default {
686
711
  <div v-if="shouldShowThreadList" class="gl-h-full">
687
712
  <duo-chat-threads
688
713
  :threads="threadList"
714
+ :preferred-locale="preferredLocale"
689
715
  @new-chat="onNewChat"
690
716
  @select-thread="onSelectThread"
691
717
  @delete-thread="onDeleteThread"
@@ -696,6 +722,7 @@ export default {
696
722
  <div
697
723
  class="duo-chat-drawer-body gl-bg-default"
698
724
  data-testid="chat-history"
725
+ :style="{ marginTop: `${headerHeight}px` }"
699
726
  @scroll="handleScrollingTrottled"
700
727
  >
701
728
  <transition-group
package/src/utils/date.js CHANGED
@@ -1,3 +1,5 @@
1
+ const DEFAULT_LOCALE = ['en-US'];
2
+
1
3
  export function getOrdinalSuffix(day) {
2
4
  if (day > 3 && day < 21) return 'th';
3
5
  switch (day % 10) {
@@ -12,13 +14,48 @@ export function getOrdinalSuffix(day) {
12
14
  }
13
15
  }
14
16
 
15
- export function formatDate(dateStr) {
17
+ const isValidDateString = (dateStr) => {
18
+ if (!dateStr) return false;
16
19
  const date = new Date(dateStr);
20
+ return date.toString() !== 'Invalid Date';
21
+ };
22
+
23
+ const getValidLocale = (preferredLocale) => {
24
+ const locale = preferredLocale?.length ? preferredLocale : DEFAULT_LOCALE;
25
+ const supportedLocales = Intl.DateTimeFormat.supportedLocalesOf(locale);
26
+ return supportedLocales.length ? supportedLocales : DEFAULT_LOCALE;
27
+ };
28
+
29
+ const createDateFormatter = (locale) => {
30
+ return new Intl.DateTimeFormat(locale, {
31
+ month: 'long',
32
+ day: 'numeric',
33
+ year: 'numeric',
34
+ });
35
+ };
36
+
37
+ const formatEnglishDate = (date, formatter) => {
17
38
  const day = date.getDate();
18
39
  const suffix = getOrdinalSuffix(day);
19
40
 
20
- const month = new Intl.DateTimeFormat('en-US', { month: 'long' }).format(date);
21
- const year = new Intl.DateTimeFormat('en-US', { year: 'numeric' }).format(date);
41
+ const parts = formatter.formatToParts(date);
42
+ const formattedParts = parts.map((part) =>
43
+ part.type === 'day' ? `${part.value}${suffix}` : part.value
44
+ );
45
+
46
+ return formattedParts.join('');
47
+ };
48
+
49
+ export function formatLocalizedDate(dateStr, preferredLocale) {
50
+ if (!isValidDateString(dateStr)) {
51
+ return 'Invalid Date';
52
+ }
53
+
54
+ const date = new Date(dateStr);
55
+ const validLocale = getValidLocale(preferredLocale);
56
+ const formatter = createDateFormatter(validLocale);
22
57
 
23
- return `${month} ${day}${suffix}, ${year}`;
58
+ return validLocale[0]?.startsWith('en')
59
+ ? formatEnglishDate(date, formatter)
60
+ : formatter.format(date);
24
61
  }