@gitlab/ui 78.2.3 → 78.3.0

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly
3
- * Generated on Thu, 21 Mar 2024 17:32:26 GMT
3
+ * Generated on Fri, 22 Mar 2024 12:26:32 GMT
4
4
  */
5
5
 
6
6
  :root {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly
3
- * Generated on Thu, 21 Mar 2024 17:32:27 GMT
3
+ * Generated on Fri, 22 Mar 2024 12:26:32 GMT
4
4
  */
5
5
 
6
6
  :root.gl-dark {
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly
3
- * Generated on Thu, 21 Mar 2024 17:32:27 GMT
3
+ * Generated on Fri, 22 Mar 2024 12:26:32 GMT
4
4
  */
5
5
 
6
6
  export const DATA_VIZ_GREEN_50 = "#133a03";
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Do not edit directly
3
- * Generated on Thu, 21 Mar 2024 17:32:26 GMT
3
+ * Generated on Fri, 22 Mar 2024 12:26:32 GMT
4
4
  */
5
5
 
6
6
  export const DATA_VIZ_GREEN_50 = "#ddfab7";
@@ -1,6 +1,6 @@
1
1
 
2
2
  // Do not edit directly
3
- // Generated on Thu, 21 Mar 2024 17:32:27 GMT
3
+ // Generated on Fri, 22 Mar 2024 12:26:32 GMT
4
4
 
5
5
  $gl-text-tertiary: #737278 !default;
6
6
  $gl-text-secondary: #89888d !default;
@@ -1,6 +1,6 @@
1
1
 
2
2
  // Do not edit directly
3
- // Generated on Thu, 21 Mar 2024 17:32:27 GMT
3
+ // Generated on Fri, 22 Mar 2024 12:26:32 GMT
4
4
 
5
5
  $gl-text-tertiary: #89888d !default;
6
6
  $gl-text-secondary: #737278 !default;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "78.2.3",
3
+ "version": "78.3.0",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -171,7 +171,7 @@
171
171
  "sass-true": "^6.1.0",
172
172
  "start-server-and-test": "^1.10.6",
173
173
  "storybook": "^7.6.17",
174
- "storybook-dark-mode": "4.0.0",
174
+ "storybook-dark-mode": "4.0.1",
175
175
  "style-dictionary": "^3.8.0",
176
176
  "stylelint": "15.10.2",
177
177
  "tailwind-config-viewer": "1.7.3",
@@ -11,15 +11,15 @@ $breadcrumb-max-width: $grid-size * 16;
11
11
  @include gl-align-items-center;
12
12
  @include gl-line-height-normal;
13
13
  @include gl-m-0;
14
- @include media-breakpoint-down(xs) {
15
- @include gl-flex-wrap;
16
- }
14
+ @include gl-flex-nowrap;
15
+ @include gl-max-w-full;
17
16
  }
18
17
 
19
18
  // bootstrap overrides
20
19
  .gl-breadcrumb-item {
21
20
  @include gl-font-sm;
22
21
  @include gl-line-height-normal;
22
+ @include gl-flex-shrink-0;
23
23
 
24
24
  &:not(:last-child)::after {
25
25
  @include gl-text-gray-200;
@@ -29,9 +29,6 @@ $breadcrumb-max-width: $grid-size * 16;
29
29
 
30
30
  > a {
31
31
  @include gl-text-gray-700;
32
- @include media-breakpoint-down(xs) {
33
- @include str-truncated($breadcrumb-max-width);
34
- }
35
32
 
36
33
  &:active,
37
34
  &:focus,
@@ -1,15 +1,20 @@
1
- import { shallowMount } from '@vue/test-utils';
2
- import { nextTick } from 'vue';
1
+ import { mount } from '@vue/test-utils';
3
2
  import avatarPath1 from '../../../../static/img/avatar.png';
4
3
  import avatarPath3 from '../../../../static/img/avatar_1.png';
5
- import GlBreadcrumb, { COLLAPSE_AT_SIZE } from './breadcrumb.vue';
4
+ import GlDisclosureDropdown from '../new_dropdowns/disclosure/disclosure_dropdown.vue';
5
+ import GlDisclosureDropdownItem from '../new_dropdowns/disclosure/disclosure_dropdown_item.vue';
6
+ import GlBreadcrumb from './breadcrumb.vue';
6
7
  import GlBreadcrumbItem from './breadcrumb_item.vue';
7
8
 
8
9
  describe('Breadcrumb component', () => {
9
10
  let wrapper;
10
11
 
11
12
  const items = [
12
- { text: 'first_breadcrumb', href: 'https://gitlab.com', avatarPath: avatarPath1 },
13
+ {
14
+ text: 'first_breadcrumb',
15
+ href: 'https://gitlab.com',
16
+ avatarPath: avatarPath1,
17
+ },
13
18
  {
14
19
  text: 'second_breadcrumb',
15
20
  to: 'to_value',
@@ -21,41 +26,44 @@ describe('Breadcrumb component', () => {
21
26
  },
22
27
  ];
23
28
 
24
- const extraItems = [
25
- { text: 'fourth_breadcrumb', href: 'https://gitlab.com' },
26
- {
27
- text: 'fifth_breadcrumb',
28
- to: 'to_value',
29
- },
30
- ];
31
-
32
29
  const findAllAvatars = () => wrapper.findAll('[data-testid="avatar"]');
33
30
  const findBreadcrumbItems = () => wrapper.findAllComponents(GlBreadcrumbItem);
34
- const findCollapsedListExpander = () => wrapper.find('[data-testid="collapsed-expander"]');
31
+ const findOverflowDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
35
32
 
36
33
  const findVisibleBreadcrumbItems = () =>
37
34
  findBreadcrumbItems().wrappers.filter((item) => item.isVisible());
38
- const findHiddenBreadcrumbItems = () =>
39
- findBreadcrumbItems().wrappers.filter((item) => !item.isVisible());
40
35
 
41
36
  const createComponent = (propsData = { items }) => {
42
- wrapper = shallowMount(GlBreadcrumb, {
37
+ wrapper = mount(GlBreadcrumb, {
43
38
  propsData,
44
39
  stubs: {
45
40
  GlBreadcrumbItem,
41
+ GlDisclosureDropdown,
46
42
  },
47
43
  });
44
+ };
45
+
46
+ const mockWrapperWidth = (widthInPx) => {
47
+ wrapper.element.style.width = `${widthInPx}px`;
48
+
49
+ Object.defineProperty(wrapper.element, 'clientWidth', {
50
+ get: () => widthInPx,
51
+ configurable: true,
52
+ });
53
+ };
48
54
 
49
- wrapper.vm.$refs.firstItem = [
50
- {
51
- querySelector: () => ({ focus: jest.fn() }),
52
- },
53
- ];
55
+ const mockWideWrapperWidth = () => {
56
+ mockWrapperWidth(1000);
57
+ };
58
+
59
+ const mockSmallWrapperWidth = () => {
60
+ mockWrapperWidth(1);
54
61
  };
55
62
 
56
63
  describe('items', () => {
57
- it('has one breadcrumb-item for each item in the items props', () => {
64
+ it('has one breadcrumb-item for each item in the items props', async () => {
58
65
  createComponent();
66
+ await wrapper.vm.$nextTick();
59
67
 
60
68
  expect(findBreadcrumbItems()).toHaveLength(items.length);
61
69
  });
@@ -75,9 +83,36 @@ describe('Breadcrumb component', () => {
75
83
  });
76
84
  });
77
85
 
86
+ describe('showMoreLabel', () => {
87
+ describe('when provided', () => {
88
+ beforeEach(async () => {
89
+ createComponent({ items, showMoreLabel: 'More...' });
90
+ mockSmallWrapperWidth();
91
+ await wrapper.vm.$nextTick();
92
+ });
93
+
94
+ it('uses prop', () => {
95
+ expect(findOverflowDropdown().props('toggleText')).toBe('More...');
96
+ });
97
+ });
98
+
99
+ describe('when not provided', () => {
100
+ beforeEach(async () => {
101
+ createComponent();
102
+ mockSmallWrapperWidth();
103
+ await wrapper.vm.$nextTick();
104
+ });
105
+
106
+ it('uses default', () => {
107
+ expect(findOverflowDropdown().props('toggleText')).toBe('Show more breadcrumbs');
108
+ });
109
+ });
110
+ });
111
+
78
112
  describe('avatars', () => {
79
- it('renders 2 avatars when 2 avatarPaths are passed', () => {
113
+ it('renders 2 avatars when 2 avatarPaths are passed', async () => {
80
114
  createComponent();
115
+ await wrapper.vm.$nextTick();
81
116
 
82
117
  expect(findAllAvatars()).toHaveLength(2);
83
118
  });
@@ -86,6 +121,7 @@ describe('Breadcrumb component', () => {
86
121
  describe('bindings', () => {
87
122
  beforeEach(() => {
88
123
  createComponent();
124
+ mockWideWrapperWidth();
89
125
  });
90
126
 
91
127
  it('first breadcrumb has text, href && ariaCurrent=`false` bound', () => {
@@ -114,12 +150,14 @@ describe('Breadcrumb component', () => {
114
150
  });
115
151
 
116
152
  describe('collapsible', () => {
117
- describe(`when breadcrumbs list size is NOT larger than ${COLLAPSE_AT_SIZE}`, () => {
153
+ describe(`when there is enough room to fit all items`, () => {
118
154
  beforeEach(() => {
119
155
  createComponent();
156
+ mockWideWrapperWidth();
120
157
  });
158
+
121
159
  it('should not display collapsed list expander', () => {
122
- expect(findCollapsedListExpander().exists()).toBe(false);
160
+ expect(findOverflowDropdown().exists()).toBe(false);
123
161
  });
124
162
 
125
163
  it('should display all items visible', () => {
@@ -127,27 +165,21 @@ describe('Breadcrumb component', () => {
127
165
  });
128
166
  });
129
167
 
130
- describe(`when breadcrumbs list size is larger than ${COLLAPSE_AT_SIZE}`, () => {
131
- beforeEach(() => {
132
- createComponent({ items: [...items, ...extraItems] });
133
- });
134
- it('should display collapsed list expander', () => {
135
- expect(findCollapsedListExpander().exists()).toBe(true);
168
+ describe(`when there is NOT enough room to fit all items`, () => {
169
+ beforeEach(async () => {
170
+ createComponent();
171
+ mockSmallWrapperWidth();
172
+ await wrapper.vm.$nextTick();
136
173
  });
137
174
 
138
- it('should display only first && 2 last items and the rest as hidden', () => {
139
- const alwaysVisibleNum = 3;
140
- expect(findVisibleBreadcrumbItems()).toHaveLength(alwaysVisibleNum);
141
- expect(findHiddenBreadcrumbItems()).toHaveLength(
142
- items.length + extraItems.length - alwaysVisibleNum
143
- );
175
+ it('should display overflow dropdown', () => {
176
+ expect(findOverflowDropdown().exists()).toBe(true);
144
177
  });
145
178
 
146
- it('should expand the list on expander click', async () => {
147
- findCollapsedListExpander().vm.$emit('click');
148
- await nextTick();
149
- expect(findHiddenBreadcrumbItems()).toHaveLength(0);
150
- expect(findVisibleBreadcrumbItems()).toHaveLength(items.length + extraItems.length);
179
+ it('moves the overflowing items into the dropdown', () => {
180
+ const fittingItems = findBreadcrumbItems().length;
181
+ const overflowingItems = wrapper.findAllComponents(GlDisclosureDropdownItem).length;
182
+ expect(fittingItems + overflowingItems).toEqual(items.length);
151
183
  });
152
184
  });
153
185
  });
@@ -10,6 +10,15 @@ const template = `
10
10
  />
11
11
  `;
12
12
 
13
+ const collapsedTemplate = `
14
+ <div style="max-width: 300px">
15
+ <gl-breadcrumb
16
+ :items="items"
17
+ :aria-label="ariaLabel"
18
+ />
19
+ </div>
20
+ `;
21
+
13
22
  const defaultItems = [
14
23
  {
15
24
  text: 'First item',
@@ -45,6 +54,14 @@ const Template = (args, { argTypes }) => ({
45
54
  export const Default = Template.bind({});
46
55
  Default.args = generateProps();
47
56
 
57
+ const CollapsedTemplate = (args, { argTypes }) => ({
58
+ components: {
59
+ GlBreadcrumb,
60
+ },
61
+ props: Object.keys(argTypes),
62
+ template: collapsedTemplate,
63
+ });
64
+
48
65
  export default {
49
66
  title: 'base/breadcrumb',
50
67
  component: GlBreadcrumb,
@@ -77,5 +94,5 @@ const extraItems = [
77
94
  },
78
95
  ];
79
96
 
80
- export const CollapsedItems = Template.bind({});
97
+ export const CollapsedItems = CollapsedTemplate.bind({});
81
98
  CollapsedItems.args = generateProps({ items: [...defaultItems, ...extraItems] });
@@ -1,20 +1,20 @@
1
1
  <!-- eslint-disable vue/multi-word-component-names -->
2
2
  <script>
3
3
  import { BBreadcrumb } from 'bootstrap-vue';
4
- import GlButton from '../button/button.vue';
4
+ import debounce from 'lodash/debounce';
5
+ import { translate } from '../../../utils/i18n';
5
6
  import GlAvatar from '../avatar/avatar.vue';
7
+ import GlDisclosureDropdown from '../new_dropdowns/disclosure/disclosure_dropdown.vue';
6
8
  import { GlTooltipDirective } from '../../../directives/tooltip';
7
9
  import GlBreadcrumbItem from './breadcrumb_item.vue';
8
10
 
9
- export const COLLAPSE_AT_SIZE = 4;
10
-
11
11
  export default {
12
12
  name: 'GlBreadcrumb',
13
13
  components: {
14
14
  BBreadcrumb,
15
- GlButton,
16
15
  GlBreadcrumbItem,
17
16
  GlAvatar,
17
+ GlDisclosureDropdown,
18
18
  },
19
19
  directives: {
20
20
  GlTooltip: GlTooltipDirective,
@@ -40,47 +40,118 @@ export default {
40
40
  required: false,
41
41
  default: 'Breadcrumb',
42
42
  },
43
+ /**
44
+ * The label for the collapsed dropdown toggle. Screen-reader only.
45
+ */
46
+ showMoreLabel: {
47
+ type: String,
48
+ required: false,
49
+ default: () => translate('GlBreadcrumb.showMoreLabel', 'Show more breadcrumbs'),
50
+ },
43
51
  },
44
52
  data() {
45
53
  return {
46
- isListCollapsed: true,
54
+ fittingItems: [...this.items], // array of items that fit on the screen
55
+ overflowingItems: [], // array of items that didn't fit and were put in a dropdown instead
56
+ totalBreadcrumbsWidth: 0, // the total width of all breadcrumb items combined
57
+ widthPerItem: [], // array with the indivudal widths of each breadcrumb item
58
+ resizeDone: false, // to apply some CSS only during/after resizing
47
59
  };
48
60
  },
49
61
  computed: {
50
- breadcrumbsSize() {
51
- return this.items.length;
52
- },
53
62
  hasCollapsible() {
54
- return this.breadcrumbsSize > COLLAPSE_AT_SIZE;
63
+ return this.overflowingItems.length > 0;
55
64
  },
56
- nonCollapsibleIndices() {
57
- return [0, this.breadcrumbsSize - 1, this.breadcrumbsSize - 2];
65
+ breadcrumbStyle() {
66
+ return this.resizeDone ? {} : { opacity: 0 };
58
67
  },
68
+ itemStyle() {
69
+ /**
70
+ * If the last/only item, which is always visible, has a very long title,
71
+ * it could overflow the breadcrumb component. This CSS makes sure it
72
+ * shows an ellipsis instead.
73
+ * But this CSS cannot be active while we do the size calculation, as that
74
+ * would then not take the real unshrunk width of that item into account.
75
+ */
76
+ if (this.resizeDone && this.fittingItems.length === 1) {
77
+ return {
78
+ 'flex-shrink': 1,
79
+ 'text-overflow': 'ellipsis',
80
+ 'overflow-x': 'hidden',
81
+ 'text-wrap': 'nowrap',
82
+ };
83
+ }
84
+ return {};
85
+ },
86
+ },
87
+ watch: {
88
+ items: {
89
+ handler: 'measureAndMakeBreadcrumbsFit',
90
+ deep: true,
91
+ },
92
+ },
93
+ created() {
94
+ this.debounceMakeBreadcrumbsFit = debounce(this.makeBreadcrumbsFit, 25);
95
+ },
96
+ mounted() {
97
+ window.addEventListener('resize', this.debounceMakeBreadcrumbsFit);
98
+ this.measureAndMakeBreadcrumbsFit();
99
+ },
100
+ beforeDestroy() {
101
+ window.removeEventListener('resize', this.debounceMakeBreadcrumbsFit);
59
102
  },
60
103
  methods: {
61
- isFirstItem(index) {
62
- return index === 0;
104
+ resetItems() {
105
+ this.fittingItems = [...this.items];
106
+ this.overflowingItems = [];
63
107
  },
64
- isLastItem(index) {
65
- return index === this.breadcrumbsSize - 1;
108
+ async measureAndMakeBreadcrumbsFit() {
109
+ this.resizeDone = false;
110
+ this.resetItems();
111
+
112
+ // Wait for DOM update so all items get rendered and can be measured.
113
+ await this.$nextTick();
114
+
115
+ this.totalBreadcrumbsWidth = 0;
116
+ this.$refs.breadcrumbs.forEach((b, index) => {
117
+ const width = b.$el.clientWidth;
118
+ this.totalBreadcrumbsWidth += width;
119
+ this.widthPerItem[index] = width;
120
+ });
121
+
122
+ this.makeBreadcrumbsFit();
66
123
  },
67
- expandBreadcrumbs() {
68
- this.isListCollapsed = false;
124
+ makeBreadcrumbsFit() {
125
+ this.resizeDone = false;
126
+ this.resetItems();
127
+
128
+ const containerWidth = this.$el.clientWidth;
129
+ const buttonWidth = 50; // px
130
+
131
+ if (this.totalBreadcrumbsWidth + buttonWidth > containerWidth) {
132
+ // Not all breadcrumb items fit. Start moving items over to the dropdown.
133
+ const startSlicingAt = 0;
69
134
 
70
- try {
71
- this.$refs.firstItem[0].querySelector('a').focus();
72
- } catch (e) {
73
- /* eslint-disable-next-line no-console */
74
- console.error(`Failed to set focus on the first breadcrumb item.`);
135
+ // The last item will never be moved into the dropdown.
136
+ const stopSlicingAt = this.items.length - 1;
137
+
138
+ let widthNeeded = this.totalBreadcrumbsWidth;
139
+ for (let index = startSlicingAt; index < stopSlicingAt; index += 1) {
140
+ // Move one breadcrumb item into the dropdown
141
+ this.overflowingItems.push(this.fittingItems[startSlicingAt]);
142
+ this.fittingItems.splice(startSlicingAt, 1);
143
+
144
+ widthNeeded -= this.widthPerItem[index];
145
+
146
+ // Does it fit now? Then stop.
147
+ if (widthNeeded + buttonWidth < containerWidth) break;
148
+ }
75
149
  }
150
+
151
+ this.resizeDone = true;
76
152
  },
77
- showCollapsedBreadcrumbsExpander(index) {
78
- return index === 0 && this.hasCollapsible && this.isListCollapsed;
79
- },
80
- isItemCollapsed(index) {
81
- return (
82
- this.hasCollapsible && this.isListCollapsed && !this.nonCollapsibleIndices.includes(index)
83
- );
153
+ isLastItem(index) {
154
+ return index === this.fittingItems.length - 1;
84
155
  },
85
156
  getAriaCurrentAttr(index) {
86
157
  return this.isLastItem(index) ? 'page' : false;
@@ -89,42 +160,41 @@ export default {
89
160
  };
90
161
  </script>
91
162
  <template>
92
- <nav class="gl-breadcrumbs" :aria-label="ariaLabel">
163
+ <nav class="gl-breadcrumbs" :aria-label="ariaLabel" :style="breadcrumbStyle">
93
164
  <b-breadcrumb class="gl-breadcrumb-list" v-bind="$attrs" v-on="$listeners">
94
- <template v-for="(item, index) in items">
95
- <!-- eslint-disable-next-line vue/valid-v-for (for @vue/compat) -->
96
- <gl-breadcrumb-item
97
- v-show="!isItemCollapsed(index)"
98
- :ref="isFirstItem(index) ? 'firstItem' : null"
99
- :text="item.text"
100
- :href="item.href"
101
- :to="item.to"
102
- :aria-current="getAriaCurrentAttr(index)"
103
- ><gl-avatar
104
- v-if="item.avatarPath"
105
- :src="item.avatarPath"
106
- :size="16"
107
- aria-hidden="true"
108
- class="gl-breadcrumb-avatar-tile gl-border gl-mr-2 gl-rounded-base!"
109
- shape="rect"
110
- data-testid="avatar"
111
- /><span>{{ item.text }}</span>
112
- </gl-breadcrumb-item>
165
+ <li v-if="hasCollapsible" class="gl-breadcrumb-item">
166
+ <gl-disclosure-dropdown
167
+ :items="overflowingItems"
168
+ :toggle-text="showMoreLabel"
169
+ fluid-width
170
+ text-sr-only
171
+ no-caret
172
+ icon="ellipsis_h"
173
+ size="small"
174
+ style="height: 16px"
175
+ placement="left"
176
+ />
177
+ </li>
113
178
 
114
- <template v-if="showCollapsedBreadcrumbsExpander(index)">
115
- <!-- eslint-disable-next-line vue/require-v-for-key (for @vue/compat) -->
116
- <li class="gl-breadcrumb-item">
117
- <gl-button
118
- v-gl-tooltip.hover="'Show all breadcrumbs'"
119
- aria-label="Show all breadcrumbs"
120
- data-testid="collapsed-expander"
121
- icon="ellipsis_h"
122
- category="primary"
123
- @click="expandBreadcrumbs"
124
- />
125
- </li>
126
- </template>
127
- </template>
179
+ <!-- eslint-disable-next-line vue/valid-v-for (for @vue/compat) -->
180
+ <gl-breadcrumb-item
181
+ v-for="(item, index) in fittingItems"
182
+ ref="breadcrumbs"
183
+ :text="item.text"
184
+ :href="item.href"
185
+ :style="itemStyle"
186
+ :to="item.to"
187
+ :aria-current="getAriaCurrentAttr(index)"
188
+ ><gl-avatar
189
+ v-if="item.avatarPath"
190
+ :src="item.avatarPath"
191
+ :size="16"
192
+ aria-hidden="true"
193
+ class="gl-breadcrumb-avatar-tile gl-border gl-mr-2 gl-rounded-base!"
194
+ shape="rect"
195
+ data-testid="avatar"
196
+ /><span>{{ item.text }}</span>
197
+ </gl-breadcrumb-item>
128
198
  </b-breadcrumb>
129
199
  </nav>
130
200
  </template>
@@ -404,7 +404,7 @@ export default {
404
404
  <template v-if="isItem(item)">
405
405
  <!-- eslint-disable-next-line vue/valid-v-for -->
406
406
  <gl-disclosure-dropdown-item :key="uniqueItemId()" :item="item" @action="handleAction">
407
- <template #list-item>
407
+ <template v-if="'list-item' in $scopedSlots" #list-item>
408
408
  <!-- @slot Custom template of the disclosure dropdown item -->
409
409
  <slot name="list-item" :item="item"></slot>
410
410
  </template>
package/translations.json CHANGED
@@ -4,5 +4,6 @@
4
4
  "GlSorting.sortDescending": "Sort direction: descending",
5
5
  "GlSearchBoxByType.clearButtonTitle": "Clear",
6
6
  "GlSearchBoxByType.input.placeholder": "Search",
7
+ "GlBreadcrumb.showMoreLabel": "Show more breadcrumbs",
7
8
  "GlCollapsibleListbox.srOnlyResultsLabel": "Results count"
8
9
  }