@gitlab/ui 110.1.0 → 111.0.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,18 @@
1
+ # [111.0.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v110.1.0...v111.0.0) (2025-03-12)
2
+
3
+
4
+ ### Features
5
+
6
+ * **GlLink:** Remove `BLink` from `GlLink` ([22f8323](https://gitlab.com/gitlab-org/gitlab-ui/commit/22f8323023ed9d44e2af3705c12f02ba93edd506))
7
+
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ * **GlLink:** Removed support for the following props:
12
+ `append`, `event`, `exact`, `exact-path`, `exact-path-active-class`,
13
+ `no-prefetch`, `router-component-name`, `router-tag`.
14
+ Also removed support for `bv::link::clicked` global event.
15
+
1
16
  # [110.1.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v110.0.0...v110.1.0) (2025-03-10)
2
17
 
3
18
 
@@ -1,17 +1,127 @@
1
- import { BLink } from '../../../vendor/bootstrap-vue/src/components/link/link';
2
- import { SafeLinkMixin } from '../../mixins/safe_link_mixin';
3
- import { isExternalURL } from '../../../directives/safe_link/safe_link';
4
- import { linkVariantOptions } from '../../../utils/constants';
1
+ import isFunction from 'lodash/isFunction';
2
+ import isString from 'lodash/isString';
3
+ import isObject from 'lodash/isObject';
4
+ import toString from 'lodash/toString';
5
+ import isBoolean from 'lodash/isBoolean';
6
+ import concat from 'lodash/concat';
7
+ import { SafeLinkDirective, isExternalURL } from '../../../directives/safe_link/safe_link';
8
+ import { stopEvent } from '../../../utils/utils';
9
+ import { isEvent } from '../../../vendor/bootstrap-vue/src/utils/inspect';
10
+ import { stringifyQueryObj } from '../../../vendor/bootstrap-vue/src/utils/router';
11
+ import { safeVueInstance } from '../../../vendor/bootstrap-vue/src/utils/safe-vue-instance';
12
+ import { attemptFocus, attemptBlur } from '../../../vendor/bootstrap-vue/src/utils/dom';
13
+ import { linkVariantOptions, isVue3 } from '../../../utils/constants';
5
14
  import __vue_normalize__ from 'vue-runtime-helpers/dist/normalize-component.js';
6
15
 
7
16
  //
17
+ const ANCHOR_TAG = 'a';
18
+ const NUXT_LINK_TAG = 'nuxt-link';
19
+ const VUE_ROUTER_LINK_TAG = 'router-link';
8
20
  var script = {
9
21
  name: 'GlLink',
10
- components: {
11
- BLink
22
+ directives: {
23
+ SafeLink: SafeLinkDirective
12
24
  },
13
- mixins: [SafeLinkMixin],
14
25
  props: {
26
+ /**
27
+ * Denotes the target URL of the link for standard links.
28
+ */
29
+ href: {
30
+ type: String,
31
+ required: false,
32
+ default: undefined
33
+ },
34
+ /**
35
+ * When set to `true`, disables the component's functionality and places it in a disabled state.
36
+ */
37
+ disabled: {
38
+ type: Boolean,
39
+ required: false,
40
+ default: false
41
+ },
42
+ /**
43
+ * Skips sanitization of href if true. This should be used sparingly.
44
+ * Consult security team before setting to true.
45
+ */
46
+ isUnsafeLink: {
47
+ type: Boolean,
48
+ required: false,
49
+ default: false
50
+ },
51
+ /**
52
+ * Sets the 'rel' attribute on the rendered link.
53
+ */
54
+ rel: {
55
+ type: String,
56
+ required: false,
57
+ default: null
58
+ },
59
+ /**
60
+ * Sets the 'target' attribute on the rendered link.
61
+ */
62
+ target: {
63
+ type: String,
64
+ required: false,
65
+ default: null
66
+ },
67
+ /**
68
+ * When set to `true`, places the component in the active state with active styling
69
+ */
70
+ active: {
71
+ type: Boolean,
72
+ required: false,
73
+ default: false
74
+ },
75
+ /**
76
+ * <router-link> prop: Denotes the target route of the link.
77
+ * When clicked, the value of the to prop will be passed to `router.push()` internally,
78
+ * so the value can be either a string or a Location descriptor object.
79
+ */
80
+ to: {
81
+ type: [Object, String],
82
+ required: false,
83
+ default: undefined
84
+ },
85
+ /**
86
+ * <router-link> prop: Configure the active CSS class applied when the link is active.
87
+ */
88
+ activeClass: {
89
+ type: String,
90
+ required: false,
91
+ default: undefined
92
+ },
93
+ /**
94
+ * <router-link> prop: Configure the active CSS class applied when the link is active with exact match.
95
+ */
96
+ exactActiveClass: {
97
+ type: String,
98
+ required: false,
99
+ default: undefined
100
+ },
101
+ /**
102
+ * <router-link> prop: Setting the replace prop will call `router.replace()` instead of `router.push()`
103
+ * when clicked, so the navigation will not leave a history record.
104
+ */
105
+ replace: {
106
+ type: Boolean,
107
+ required: false,
108
+ default: false
109
+ },
110
+ /**
111
+ * <nuxt-link> prop: To improve the responsiveness of your Nuxt.js applications, when the link will be displayed within the viewport,
112
+ * Nuxt.js will automatically prefetch the code splitted page. Setting `prefetch` to `true` or `false` will overwrite the default value of `router.prefetchLinks`
113
+ */
114
+ prefetch: {
115
+ type: Boolean,
116
+ required: false,
117
+ // Must be `null` to fall back to the value defined in the
118
+ // `nuxt.config.js` configuration file for `router.prefetchLinks`
119
+ // We convert `null` to `undefined`, so that Nuxt.js will use the
120
+ // compiled default
121
+ // Vue treats `undefined` as default of `false` for Boolean props,
122
+ // so we must set it as `null` here to be a true tri-state prop
123
+ default: null
124
+ },
15
125
  /**
16
126
  * If inline variant, controls ↗ character visibility
17
127
  */
@@ -31,14 +141,142 @@ var script = {
31
141
  }
32
142
  },
33
143
  computed: {
144
+ safeLinkConfig() {
145
+ return {
146
+ skipSanitization: this.isUnsafeLink
147
+ };
148
+ },
149
+ tag() {
150
+ const hasRouter = Boolean(safeVueInstance(this).$router);
151
+ const hasNuxt = Boolean(safeVueInstance(this).$nuxt);
152
+ if (!hasRouter || this.disabled || !this.to) {
153
+ return ANCHOR_TAG;
154
+ }
155
+ return hasNuxt ? NUXT_LINK_TAG : VUE_ROUTER_LINK_TAG;
156
+ },
157
+ isRouterLink() {
158
+ return this.tag !== ANCHOR_TAG;
159
+ },
160
+ isVue3RouterLink() {
161
+ return this.tag === VUE_ROUTER_LINK_TAG && isVue3;
162
+ },
34
163
  isInlineAndHasExternalIcon() {
35
- return this.showExternalIcon && this.variant === 'inline' && this.$attrs.href && isExternalURL(this.target, this.$attrs.href);
164
+ return this.showExternalIcon && this.variant === 'inline' && this.href && isExternalURL(this.target, this.href);
165
+ },
166
+ computedHref() {
167
+ const fallback = '#';
168
+ const toFallback = '/';
169
+ const {
170
+ to
171
+ } = this;
172
+
173
+ // Return `href` when explicitly provided
174
+ if (this.href) {
175
+ return this.href;
176
+ }
177
+ if (isString(to)) {
178
+ return to || toFallback;
179
+ }
180
+
181
+ // Fallback to `to.path' + `to.query` + `to.hash` prop (if `to` is an object)
182
+ if (isObject(to) && (to.path || to.query || to.hash)) {
183
+ const path = toString(to.path);
184
+ const query = stringifyQueryObj(to.query);
185
+ let hash = toString(to.hash);
186
+ hash = !hash || hash.charAt(0) === '#' ? hash : `#${hash}`;
187
+ return `${path}${query}${hash}` || fallback;
188
+ }
189
+ return fallback;
190
+ },
191
+ computedProps() {
192
+ if (this.isRouterLink) {
193
+ return {
194
+ to: this.to,
195
+ activeClass: this.activeClass,
196
+ exactActiveClass: this.exactActiveClass,
197
+ replace: this.replace,
198
+ ...(isBoolean(this.prefetch) ? {
199
+ prefetch: this.prefetch
200
+ } : {})
201
+ };
202
+ }
203
+ return {
204
+ disabled: this.disabled,
205
+ ...(this.disabled ? {
206
+ 'aria-disabled': 'true',
207
+ tabindex: '-1'
208
+ } : {}),
209
+ rel: this.rel,
210
+ target: this.target,
211
+ href: this.computedHref
212
+ };
36
213
  },
37
- linkClasses() {
214
+ computedListeners() {
215
+ const {
216
+ click,
217
+ ...listenersWithoutClick
218
+ } = this.$listeners;
219
+ return listenersWithoutClick;
220
+ },
221
+ computedClass() {
38
222
  return ['gl-link', linkVariantOptions[this.variant], {
223
+ disabled: this.disabled,
224
+ active: this.active,
39
225
  'gl-link-inline-external': this.isInlineAndHasExternalIcon
40
226
  }];
41
227
  }
228
+ },
229
+ methods: {
230
+ onClick(event, navigate) {
231
+ const eventIsEvent = isEvent(event);
232
+ const suppliedHandler = this.$listeners.click;
233
+ if (eventIsEvent && this.disabled) {
234
+ // Stop event from bubbling up
235
+ // Kill the event loop attached to this specific `EventTarget`
236
+ // Needed to prevent `vue-router` from navigating
237
+ stopEvent(event, {
238
+ immediatePropagation: true
239
+ });
240
+ } else {
241
+ // Router links do not emit instance `click` events, so we
242
+ // add in an `$emit('click', event)` on its Vue instance
243
+ //
244
+ // seems not to be required for Vue3 compat build
245
+ if (this.isRouterLink) {
246
+ var _event$currentTarget$;
247
+ // eslint-disable-next-line no-underscore-dangle
248
+ (_event$currentTarget$ = event.currentTarget.__vue__) === null || _event$currentTarget$ === void 0 ? void 0 : _event$currentTarget$.$emit('click', event);
249
+ }
250
+
251
+ // Call the suppliedHandler(s), if any provided
252
+ concat([], suppliedHandler).filter(h => isFunction(h)).forEach(handler => {
253
+ // eslint-disable-next-line prefer-rest-params
254
+ handler(...arguments);
255
+ });
256
+
257
+ // this navigate function comes from Vue 3 router
258
+ // See https://router.vuejs.org/guide/advanced/extending-router-link.html#Extending-RouterLink
259
+ if (isFunction(navigate)) {
260
+ navigate(event);
261
+ }
262
+
263
+ // TODO: Remove deprecated 'clicked::link' event
264
+ this.$root.$emit('clicked::link', event);
265
+ }
266
+ // Stop scroll-to-top behavior or navigation on
267
+ // regular links when href is just '#'
268
+ if (eventIsEvent && !this.isRouterLink && this.computedHref === '#') {
269
+ stopEvent(event, {
270
+ stopPropagation: false
271
+ });
272
+ }
273
+ },
274
+ focus() {
275
+ attemptFocus(this.$el);
276
+ },
277
+ blur() {
278
+ attemptBlur(this.$el);
279
+ }
42
280
  }
43
281
  };
44
282
 
@@ -46,7 +284,13 @@ var script = {
46
284
  const __vue_script__ = script;
47
285
 
48
286
  /* template */
49
- var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('b-link',_vm._g(_vm._b({directives:[{name:"safe-link",rawName:"v-safe-link:[safeLinkConfig]",arg:_vm.safeLinkConfig}],class:_vm.linkClasses,attrs:{"target":_vm.target}},'b-link',_vm.$attrs,false),_vm.$listeners),[_vm._t("default")],2)};
287
+ var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return (_vm.isVue3RouterLink)?_c(_vm.tag,_vm._b({tag:"component",attrs:{"custom":""},scopedSlots:_vm._u([{key:"default",fn:function(ref){
288
+ var _obj;
289
+
290
+ var routerLinkHref = ref.href;
291
+ var isActive = ref.isActive;
292
+ var isExactActive = ref.isExactActive;
293
+ var navigate = ref.navigate;return [_c('a',_vm._g({directives:[{name:"safe-link",rawName:"v-safe-link:[safeLinkConfig]",arg:_vm.safeLinkConfig}],class:[_vm.computedClass, ( _obj = {}, _obj[_vm.activeClass] = isActive, _obj[_vm.exactActiveClass] = isExactActive, _obj )],attrs:{"href":routerLinkHref},on:{"click":function($event){return _vm.onClick($event, navigate)}}},_vm.computedListeners),[_vm._t("default")],2)]}}],null,true)},'component',_vm.computedProps,false)):(_vm.isRouterLink)?_c(_vm.tag,_vm._g(_vm._b({directives:[{name:"safe-link",rawName:"v-safe-link:[safeLinkConfig]",arg:_vm.safeLinkConfig}],tag:"component",class:_vm.computedClass,nativeOn:{"click":function($event){return _vm.onClick.apply(null, arguments)}}},'component',_vm.computedProps,false),_vm.computedListeners),[_vm._t("default")],2):_c(_vm.tag,_vm._g(_vm._b({directives:[{name:"safe-link",rawName:"v-safe-link:[safeLinkConfig]",arg:_vm.safeLinkConfig}],tag:"component",class:_vm.computedClass,on:{"click":_vm.onClick}},'component',_vm.computedProps,false),_vm.computedListeners),[_vm._t("default")],2)};
50
294
  var __vue_staticRenderFns__ = [];
51
295
 
52
296
  /* style */
@@ -7,8 +7,11 @@ const getBaseURL = () => {
7
7
  } = window.location;
8
8
  return `${protocol}//${host}`;
9
9
  };
10
+ const isTargetBlank = target => {
11
+ return target === '_blank';
12
+ };
10
13
  const isExternalURL = (target, hostname) => {
11
- return target === '_blank' && hostname !== window.location.hostname;
14
+ return isTargetBlank(target) && hostname !== window.location.hostname;
12
15
  };
13
16
  const secureRel = rel => {
14
17
  const rels = rel ? rel.trim().split(' ') : [];
@@ -40,13 +43,12 @@ const transform = function (el) {
40
43
  const {
41
44
  href,
42
45
  target,
43
- rel,
44
- hostname
46
+ rel
45
47
  } = el;
46
48
  if (!isSafeURL(href)) {
47
49
  el.href = 'about:blank';
48
50
  }
49
- if (isExternalURL(target, hostname)) {
51
+ if (isTargetBlank(target)) {
50
52
  el.rel = secureRel(rel);
51
53
  }
52
54
  };
@@ -1,3 +1,4 @@
1
+ import Vue from 'vue';
1
2
  import { POSITION } from '../components/utilities/truncate/constants';
2
3
 
3
4
  const COMMA = ',';
@@ -316,5 +317,6 @@ const loadingIconVariants = {
316
317
  spinner: 'spinner',
317
318
  dots: 'dots'
318
319
  };
320
+ const isVue3 = Boolean(Vue.Fragment);
319
321
 
320
- export { COMMA, CONTRAST_LEVELS, HEX_REGEX, LEFT_MOUSE_BUTTON, alertVariantIconMap, alertVariantOptions, alignOptions, animatedIconVariantOptions, avatarShapeOptions, avatarSizeOptions, avatarsInlineSizeOptions, badgeForButtonOptions, badgeIconSizeOptions, badgeSizeOptions, badgeVariantOptions, bannerVariants, breadCrumbSizeOptions, buttonCategoryOptions, buttonSizeOptions, buttonVariantOptions, colorThemes, columnOptions, datepickerWidthOptionsMap, defaultDateFormat, drawerVariants, dropdownAllowedAutoPlacements, dropdownItemVariantOptions, dropdownPlacements, dropdownVariantOptions, focusableTags, formInputWidths, formStateOptions, iconSizeOptions, iconVariantOptions, keyboard, labelColorOptions, linkVariantOptions, loadingIconSizes, loadingIconVariants, maxZIndex, modalButtonDefaults, modalSizeOptions, popoverPlacements, progressBarVariantOptions, resizeDebounceTime, tabsButtonDefaults, targetOptions, toggleLabelPosition, tokenVariants, tooltipActionEvents, tooltipDelay, tooltipPlacements, triggerVariantOptions, truncateOptions, variantCssColorMap, viewModeOptions };
322
+ export { COMMA, CONTRAST_LEVELS, HEX_REGEX, LEFT_MOUSE_BUTTON, alertVariantIconMap, alertVariantOptions, alignOptions, animatedIconVariantOptions, avatarShapeOptions, avatarSizeOptions, avatarsInlineSizeOptions, badgeForButtonOptions, badgeIconSizeOptions, badgeSizeOptions, badgeVariantOptions, bannerVariants, breadCrumbSizeOptions, buttonCategoryOptions, buttonSizeOptions, buttonVariantOptions, colorThemes, columnOptions, datepickerWidthOptionsMap, defaultDateFormat, drawerVariants, dropdownAllowedAutoPlacements, dropdownItemVariantOptions, dropdownPlacements, dropdownVariantOptions, focusableTags, formInputWidths, formStateOptions, iconSizeOptions, iconVariantOptions, isVue3, keyboard, labelColorOptions, linkVariantOptions, loadingIconSizes, loadingIconVariants, maxZIndex, modalButtonDefaults, modalSizeOptions, popoverPlacements, progressBarVariantOptions, resizeDebounceTime, tabsButtonDefaults, targetOptions, toggleLabelPosition, tokenVariants, tooltipActionEvents, tooltipDelay, tooltipPlacements, triggerVariantOptions, truncateOptions, variantCssColorMap, viewModeOptions };
@@ -1,4 +1,5 @@
1
1
  import Vue from 'vue';
2
+ import { isVue3 } from './constants';
2
3
 
3
4
  // Fragment will be available only in Vue.js 3
4
5
  const {
@@ -30,7 +31,6 @@ function isVnodeEmpty(vnode) {
30
31
  }
31
32
  function isSlotEmpty(vueInstance, slot, slotArgs) {
32
33
  var _vueInstance$$scopedS, _vueInstance$$scopedS2;
33
- const isVue3 = Boolean(Fragment);
34
34
  const slotContent = isVue3 ?
35
35
  // we need to check both $slots and $scopedSlots due to https://github.com/vuejs/core/issues/8869
36
36
  // additionally, in @vue/compat $slot might be a function instead of array of vnodes (sigh)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/ui",
3
- "version": "110.1.0",
3
+ "version": "111.0.0",
4
4
  "description": "GitLab UI Components",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
@@ -36,9 +36,8 @@
36
36
  "build-tokens": "make tokens",
37
37
  "build-migration-script": "esbuild --bundle --platform=node --target=esnext --outfile=bin/migrate_custom_utils_to_tw.bundled.mjs --format=esm --banner:js=\"import { createRequire as __gl__createRequire } from 'node:module'; const require = __gl__createRequire(import.meta.url);\" bin/migrate_custom_utils_to_tw.mjs",
38
38
  "clean": "rm -r dist storybook",
39
- "cy:a11y": "cypress run --browser chrome --env grepTags=@a11y",
40
- "cy:edge": "cypress run --browser edge --env grepTags=-@a11y+-@storybook",
41
- "cy:run": "cypress run --browser firefox --env grepTags=-@a11y+-@storybook",
39
+ "cy:edge": "cypress run --browser edge --env grepTags=-@storybook",
40
+ "cy:run": "cypress run --browser firefox --env grepTags=-@storybook",
42
41
  "start": "yarn storybook",
43
42
  "storybook": "yarn storybook:prepare && storybook dev --ci --host ${STORYBOOK_HOST:-localhost} --port ${STORYBOOK_PORT:-9001} -c .storybook",
44
43
  "storybook-vue3": "yarn storybook:prepare && VUE_VERSION=3 storybook dev --ci --host ${STORYBOOK_HOST:-localhost} --port ${STORYBOOK_PORT:-9001} -c .storybook",
@@ -49,7 +48,7 @@
49
48
  "storybook:run": "npx http-server -bgs -p ${STORYBOOK_PORT:-9001} ./storybook",
50
49
  "pretest:unit": "yarn build-tokens",
51
50
  "test": "run-s test:unit test:visual",
52
- "test:integration": "yarn run test:integration:server 'yarn cy:run && yarn cy:edge && yarn cy:a11y'",
51
+ "test:integration": "yarn run test:integration:server 'yarn cy:run && yarn cy:edge'",
53
52
  "test:integration:server": "NODE_ENV=test start-test storybook:run http-get://${STORYBOOK_HOST:-localhost}:${STORYBOOK_PORT:-9001}/iframe.html",
54
53
  "test:unit": "NODE_ENV=test jest",
55
54
  "test:unit:watch": "yarn test:unit --watch",
@@ -140,12 +139,10 @@
140
139
  "acorn": "^8.11.3",
141
140
  "acorn-walk": "^8.3.2",
142
141
  "autoprefixer": "^9.7.6",
143
- "axe-core": "^4.2.3",
144
142
  "axe-playwright": "^2.1.0",
145
143
  "babel-jest": "29.0.1",
146
144
  "babel-loader": "^8.0.5",
147
145
  "cypress": "14.1.0",
148
- "cypress-axe": "^1.4.0",
149
146
  "cypress-real-events": "^1.11.0",
150
147
  "dompurify": "^3.1.2",
151
148
  "emoji-regex": "^10.0.0",
@@ -173,8 +170,8 @@
173
170
  "npm-run-all": "^4.1.5",
174
171
  "patch-package": "^8.0.0",
175
172
  "pikaday": "^1.8.0",
176
- "playwright": "^1.50.1",
177
- "playwright-core": "^1.50.1",
173
+ "playwright": "^1.51.0",
174
+ "playwright-core": "^1.51.0",
178
175
  "postcss": "8.4.28",
179
176
  "postcss-loader": "^7.0.2",
180
177
  "postcss-scss": "4.0.4",
@@ -208,11 +205,6 @@
208
205
  "yargs": "^17.3.1",
209
206
  "yarn-deduplicate": "^6.0.2"
210
207
  },
211
- "overrides": {
212
- "cypress-axe": {
213
- "axe-core": "4.2.3"
214
- }
215
- },
216
208
  "release": {
217
209
  "branches": [
218
210
  "main"
@@ -18,6 +18,115 @@
18
18
  - **Mention**: Indicates when a user is "@" mentioned in the content. The username links to the
19
19
  user's profile. A mention link can be within body or meta content.
20
20
 
21
+ Use `<gl-link>` to render links. It can render standard `<a>` elements,
22
+ and also Vue Router and Nuxt links.
23
+
24
+ ```html
25
+ <gl-link href="#foo">Link</gl-link>
26
+ ```
27
+
28
+ ## Link type
29
+
30
+ By specifying a value in the `href` prop, a standard link (`<a>`) element will be rendered. To
31
+ generate a `<router-link>` instead, specify the route location via the `to` prop.
32
+
33
+ ### Router links
34
+
35
+ Router links support various additional props.
36
+
37
+ If your app is running under [Nuxt.js](https://nuxtjs.org), the
38
+ [`<nuxt-link>`](https://nuxtjs.org/api/components-nuxt-link) component will be used instead of
39
+ `<router-link>`. The `<nuxt-link>` component supports all the same features as `<router-link>` (as
40
+ it is a wrapper component for `<router-link>`) and more.
41
+
42
+ #### `to`
43
+
44
+ - type: `string | Location`
45
+ - required to generate a `<router-link>`
46
+
47
+ Denotes the target route of the link. When clicked, the value of the `to` prop will be passed to
48
+ `router.push()` internally, so the value can be either a string or a location descriptor object.
49
+
50
+ ```html
51
+ <!-- Literal string -->
52
+ <gl-link to="home">Home</gl-link>
53
+ <!-- Renders to -->
54
+ <a href="home">Home</a>
55
+
56
+ <!-- Omitting `v-bind` is fine, just as binding any other prop -->
57
+ <gl-link :to="'home'">Home</gl-link>
58
+
59
+ <!-- Same as above -->
60
+ <gl-link :to="{ path: 'home' }">Home</gl-link>
61
+
62
+ <!-- Named route -->
63
+ <gl-link :to="{ name: 'user', params: { userId: 123 } }">User</gl-link>
64
+
65
+ <!-- With query, resulting in `/register?plan=private` -->
66
+ <gl-link :to="{ path: 'register', query: { plan: 'private' } }">Register</gl-link>
67
+
68
+ <!-- Render a non-router link by omitting `to` and specifying an `href` -->
69
+ <gl-link href="/home">Home</gl-link>
70
+ ```
71
+
72
+ #### `replace`
73
+
74
+ - type: `boolean`
75
+ - default: `false`
76
+
77
+ Setting `replace` prop will call `router.replace()` instead of `router.push()` when clicked, so the
78
+ navigation will not leave a history record.
79
+
80
+ ```html
81
+ <gl-link :to="{ path: '/abc'}" replace></gl-link>
82
+ ```
83
+
84
+ #### `active-class`
85
+
86
+ - type: `string`
87
+ - default: `'router-link-active'` (`'nuxt-link-active'` when using Nuxt.js)
88
+
89
+ Configure the active CSS class applied when the link is active. Note the default value can also be
90
+ configured globally via the `linkActiveClass`
91
+ [router constructor option](https://router.vuejs.org/api/#linkactiveclass).
92
+
93
+ With components that support router links (have a `to` prop), you will want to set this to the class
94
+ `'active'` (or a space separated string that includes `'active'`) to apply Bootstrap's active
95
+ styling on the component when the current route matches the `to` prop.
96
+
97
+ #### `exact-active-class`
98
+
99
+ - type: `string`
100
+ - default: `'router-link-exact-active'` (`'nuxt-link-exact-active'` when using Nuxt.js)
101
+ - availability: Vue Router 2.5.0+
102
+
103
+ Configure the active CSS class applied when the link is active with exact match. Note the default
104
+ value can also be configured globally via the `linkExactActiveClass`
105
+ [router constructor option](https://router.vuejs.org/api/#linkexactactiveclass).
106
+
107
+ With components that support router links (have a `to` prop), you will want to set this to the class
108
+ `'active'` (or a space separated string that includes `'active'`) to apply Bootstrap's active
109
+ styling on the component when the current route matches the `to` prop.
110
+
111
+ ## Links with `href="#"`
112
+
113
+ Typically `<a href="#">` will cause the document to scroll to the top of page when clicked.
114
+ `<gl-link>` addresses this by preventing the default action (scroll to top) when `href` is set to
115
+ `#`.
116
+
117
+ If you need scroll to top behaviour, use a standard `<a href="#">...</a>` tag.
118
+
119
+ ## Link disabled state
120
+
121
+ Disable link functionality by setting the `disabled` prop to true.
122
+
123
+ ```html
124
+ <gl-link href="#foo" disabled>Disabled Link</gl-link>
125
+ ```
126
+
127
+ Disabling a link handles stopping event propagation, preventing the default action from occurring,
128
+ and removing the link from the document tab sequence (`tabindex="-1"`).
129
+
21
130
  ## Security
22
131
 
23
132
  This component implements a few security measures to make it as safe as possible by default.
@@ -1,17 +1,128 @@
1
1
  <!-- eslint-disable vue/multi-word-component-names -->
2
2
  <script>
3
- import { BLink } from '../../../vendor/bootstrap-vue/src/components/link/link';
4
- import { SafeLinkMixin } from '../../mixins/safe_link_mixin';
5
- import { isExternalURL } from '../../../directives/safe_link/safe_link';
6
- import { linkVariantOptions } from '../../../utils/constants';
3
+ import isFunction from 'lodash/isFunction';
4
+ import isString from 'lodash/isString';
5
+ import isObject from 'lodash/isObject';
6
+ import toString from 'lodash/toString';
7
+ import isBoolean from 'lodash/isBoolean';
8
+ import concat from 'lodash/concat';
9
+ import { SafeLinkDirective, isExternalURL } from '../../../directives/safe_link/safe_link';
10
+ import { stopEvent } from '../../../utils/utils';
11
+ import { isEvent } from '../../../vendor/bootstrap-vue/src/utils/inspect';
12
+ import { stringifyQueryObj } from '../../../vendor/bootstrap-vue/src/utils/router';
13
+ import { safeVueInstance } from '../../../vendor/bootstrap-vue/src/utils/safe-vue-instance';
14
+ import { attemptFocus, attemptBlur } from '../../../vendor/bootstrap-vue/src/utils/dom';
15
+ import { linkVariantOptions, isVue3 } from '../../../utils/constants';
16
+
17
+ const ANCHOR_TAG = 'a';
18
+ const NUXT_LINK_TAG = 'nuxt-link';
19
+ const VUE_ROUTER_LINK_TAG = 'router-link';
7
20
 
8
21
  export default {
9
22
  name: 'GlLink',
10
- components: {
11
- BLink,
23
+ directives: {
24
+ SafeLink: SafeLinkDirective,
12
25
  },
13
- mixins: [SafeLinkMixin],
14
26
  props: {
27
+ /**
28
+ * Denotes the target URL of the link for standard links.
29
+ */
30
+ href: {
31
+ type: String,
32
+ required: false,
33
+ default: undefined,
34
+ },
35
+ /**
36
+ * When set to `true`, disables the component's functionality and places it in a disabled state.
37
+ */
38
+ disabled: {
39
+ type: Boolean,
40
+ required: false,
41
+ default: false,
42
+ },
43
+ /**
44
+ * Skips sanitization of href if true. This should be used sparingly.
45
+ * Consult security team before setting to true.
46
+ */
47
+ isUnsafeLink: {
48
+ type: Boolean,
49
+ required: false,
50
+ default: false,
51
+ },
52
+ /**
53
+ * Sets the 'rel' attribute on the rendered link.
54
+ */
55
+ rel: {
56
+ type: String,
57
+ required: false,
58
+ default: null,
59
+ },
60
+ /**
61
+ * Sets the 'target' attribute on the rendered link.
62
+ */
63
+ target: {
64
+ type: String,
65
+ required: false,
66
+ default: null,
67
+ },
68
+ /**
69
+ * When set to `true`, places the component in the active state with active styling
70
+ */
71
+ active: {
72
+ type: Boolean,
73
+ required: false,
74
+ default: false,
75
+ },
76
+ /**
77
+ * <router-link> prop: Denotes the target route of the link.
78
+ * When clicked, the value of the to prop will be passed to `router.push()` internally,
79
+ * so the value can be either a string or a Location descriptor object.
80
+ */
81
+ to: {
82
+ type: [Object, String],
83
+ required: false,
84
+ default: undefined,
85
+ },
86
+ /**
87
+ * <router-link> prop: Configure the active CSS class applied when the link is active.
88
+ */
89
+ activeClass: {
90
+ type: String,
91
+ required: false,
92
+ default: undefined,
93
+ },
94
+ /**
95
+ * <router-link> prop: Configure the active CSS class applied when the link is active with exact match.
96
+ */
97
+ exactActiveClass: {
98
+ type: String,
99
+ required: false,
100
+ default: undefined,
101
+ },
102
+ /**
103
+ * <router-link> prop: Setting the replace prop will call `router.replace()` instead of `router.push()`
104
+ * when clicked, so the navigation will not leave a history record.
105
+ */
106
+ replace: {
107
+ type: Boolean,
108
+ required: false,
109
+ default: false,
110
+ },
111
+ /**
112
+ * <nuxt-link> prop: To improve the responsiveness of your Nuxt.js applications, when the link will be displayed within the viewport,
113
+ * Nuxt.js will automatically prefetch the code splitted page. Setting `prefetch` to `true` or `false` will overwrite the default value of `router.prefetchLinks`
114
+ */
115
+ prefetch: {
116
+ type: Boolean,
117
+ required: false,
118
+ // Must be `null` to fall back to the value defined in the
119
+ // `nuxt.config.js` configuration file for `router.prefetchLinks`
120
+ // We convert `null` to `undefined`, so that Nuxt.js will use the
121
+ // compiled default
122
+ // Vue treats `undefined` as default of `false` for Boolean props,
123
+ // so we must set it as `null` here to be a true tri-state prop
124
+ default: null,
125
+ },
15
126
  /**
16
127
  * If inline variant, controls ↗ character visibility
17
128
  */
@@ -31,33 +142,187 @@ export default {
31
142
  },
32
143
  },
33
144
  computed: {
145
+ safeLinkConfig() {
146
+ return {
147
+ skipSanitization: this.isUnsafeLink,
148
+ };
149
+ },
150
+ tag() {
151
+ const hasRouter = Boolean(safeVueInstance(this).$router);
152
+ const hasNuxt = Boolean(safeVueInstance(this).$nuxt);
153
+
154
+ if (!hasRouter || this.disabled || !this.to) {
155
+ return ANCHOR_TAG;
156
+ }
157
+
158
+ return hasNuxt ? NUXT_LINK_TAG : VUE_ROUTER_LINK_TAG;
159
+ },
160
+ isRouterLink() {
161
+ return this.tag !== ANCHOR_TAG;
162
+ },
163
+ isVue3RouterLink() {
164
+ return this.tag === VUE_ROUTER_LINK_TAG && isVue3;
165
+ },
34
166
  isInlineAndHasExternalIcon() {
35
167
  return (
36
168
  this.showExternalIcon &&
37
169
  this.variant === 'inline' &&
38
- this.$attrs.href &&
39
- isExternalURL(this.target, this.$attrs.href)
170
+ this.href &&
171
+ isExternalURL(this.target, this.href)
40
172
  );
41
173
  },
42
- linkClasses() {
174
+ computedHref() {
175
+ const fallback = '#';
176
+ const toFallback = '/';
177
+ const { to } = this;
178
+
179
+ // Return `href` when explicitly provided
180
+ if (this.href) {
181
+ return this.href;
182
+ }
183
+
184
+ if (isString(to)) {
185
+ return to || toFallback;
186
+ }
187
+
188
+ // Fallback to `to.path' + `to.query` + `to.hash` prop (if `to` is an object)
189
+ if (isObject(to) && (to.path || to.query || to.hash)) {
190
+ const path = toString(to.path);
191
+ const query = stringifyQueryObj(to.query);
192
+ let hash = toString(to.hash);
193
+ hash = !hash || hash.charAt(0) === '#' ? hash : `#${hash}`;
194
+ return `${path}${query}${hash}` || fallback;
195
+ }
196
+
197
+ return fallback;
198
+ },
199
+ computedProps() {
200
+ if (this.isRouterLink) {
201
+ return {
202
+ to: this.to,
203
+ activeClass: this.activeClass,
204
+ exactActiveClass: this.exactActiveClass,
205
+ replace: this.replace,
206
+ ...(isBoolean(this.prefetch) ? { prefetch: this.prefetch } : {}),
207
+ };
208
+ }
209
+
210
+ return {
211
+ disabled: this.disabled,
212
+ ...(this.disabled ? { 'aria-disabled': 'true', tabindex: '-1' } : {}),
213
+ rel: this.rel,
214
+ target: this.target,
215
+ href: this.computedHref,
216
+ };
217
+ },
218
+ computedListeners() {
219
+ const { click, ...listenersWithoutClick } = this.$listeners;
220
+
221
+ return listenersWithoutClick;
222
+ },
223
+ computedClass() {
43
224
  return [
44
225
  'gl-link',
45
226
  linkVariantOptions[this.variant],
46
- { 'gl-link-inline-external': this.isInlineAndHasExternalIcon },
227
+ {
228
+ disabled: this.disabled,
229
+ active: this.active,
230
+ 'gl-link-inline-external': this.isInlineAndHasExternalIcon,
231
+ },
47
232
  ];
48
233
  },
49
234
  },
235
+ methods: {
236
+ onClick(event, navigate) {
237
+ const eventIsEvent = isEvent(event);
238
+ const suppliedHandler = this.$listeners.click;
239
+
240
+ if (eventIsEvent && this.disabled) {
241
+ // Stop event from bubbling up
242
+ // Kill the event loop attached to this specific `EventTarget`
243
+ // Needed to prevent `vue-router` from navigating
244
+ stopEvent(event, { immediatePropagation: true });
245
+ } else {
246
+ // Router links do not emit instance `click` events, so we
247
+ // add in an `$emit('click', event)` on its Vue instance
248
+ //
249
+ // seems not to be required for Vue3 compat build
250
+ if (this.isRouterLink) {
251
+ // eslint-disable-next-line no-underscore-dangle
252
+ event.currentTarget.__vue__?.$emit('click', event);
253
+ }
254
+
255
+ // Call the suppliedHandler(s), if any provided
256
+ concat([], suppliedHandler)
257
+ .filter((h) => isFunction(h))
258
+ .forEach((handler) => {
259
+ // eslint-disable-next-line prefer-rest-params
260
+ handler(...arguments);
261
+ });
262
+
263
+ // this navigate function comes from Vue 3 router
264
+ // See https://router.vuejs.org/guide/advanced/extending-router-link.html#Extending-RouterLink
265
+ if (isFunction(navigate)) {
266
+ navigate(event);
267
+ }
268
+
269
+ // TODO: Remove deprecated 'clicked::link' event
270
+ this.$root.$emit('clicked::link', event);
271
+ }
272
+ // Stop scroll-to-top behavior or navigation on
273
+ // regular links when href is just '#'
274
+ if (eventIsEvent && !this.isRouterLink && this.computedHref === '#') {
275
+ stopEvent(event, { stopPropagation: false });
276
+ }
277
+ },
278
+ focus() {
279
+ attemptFocus(this.$el);
280
+ },
281
+ blur() {
282
+ attemptBlur(this.$el);
283
+ },
284
+ },
50
285
  };
51
286
  </script>
287
+
52
288
  <template>
53
- <b-link
289
+ <component
290
+ :is="tag"
291
+ v-if="isVue3RouterLink"
292
+ #default="{ href: routerLinkHref, isActive, isExactActive, navigate }"
293
+ v-bind="computedProps"
294
+ custom
295
+ >
296
+ <a
297
+ v-safe-link:[safeLinkConfig]
298
+ :class="[computedClass, { [activeClass]: isActive, [exactActiveClass]: isExactActive }]"
299
+ :href="routerLinkHref"
300
+ v-on="computedListeners"
301
+ @click="onClick($event, navigate)"
302
+ >
303
+ <slot></slot>
304
+ </a>
305
+ </component>
306
+ <component
307
+ :is="tag"
308
+ v-else-if="isRouterLink"
309
+ v-safe-link:[safeLinkConfig]
310
+ v-bind="computedProps"
311
+ :class="computedClass"
312
+ v-on="computedListeners"
313
+ @click.native="onClick"
314
+ >
315
+ <slot></slot>
316
+ </component>
317
+ <component
318
+ :is="tag"
319
+ v-else
54
320
  v-safe-link:[safeLinkConfig]
55
- v-bind="$attrs"
56
- :target="target"
57
- :class="linkClasses"
58
- v-on="$listeners"
321
+ v-bind="computedProps"
322
+ :class="computedClass"
323
+ v-on="computedListeners"
324
+ @click="onClick"
59
325
  >
60
- <!-- @slot The link to display. -->
61
326
  <slot></slot>
62
- </b-link>
327
+ </component>
63
328
  </template>
@@ -5,8 +5,12 @@ const getBaseURL = () => {
5
5
  return `${protocol}//${host}`;
6
6
  };
7
7
 
8
+ const isTargetBlank = (target) => {
9
+ return target === '_blank';
10
+ };
11
+
8
12
  export const isExternalURL = (target, hostname) => {
9
- return target === '_blank' && hostname !== window.location.hostname;
13
+ return isTargetBlank(target) && hostname !== window.location.hostname;
10
14
  };
11
15
 
12
16
  const secureRel = (rel) => {
@@ -35,13 +39,13 @@ const transform = (el, { arg: { skipSanitization = false } = {} } = {}) => {
35
39
  return;
36
40
  }
37
41
 
38
- const { href, target, rel, hostname } = el;
42
+ const { href, target, rel } = el;
39
43
 
40
44
  if (!isSafeURL(href)) {
41
45
  el.href = 'about:blank';
42
46
  }
43
47
 
44
- if (isExternalURL(target, hostname)) {
48
+ if (isTargetBlank(target)) {
45
49
  el.rel = secureRel(rel);
46
50
  }
47
51
  };
@@ -1,3 +1,4 @@
1
+ import Vue from 'vue';
1
2
  import { POSITION } from '../components/utilities/truncate/constants';
2
3
 
3
4
  export const COMMA = ',';
@@ -372,3 +373,5 @@ export const loadingIconVariants = {
372
373
  spinner: 'spinner',
373
374
  dots: 'dots',
374
375
  };
376
+
377
+ export const isVue3 = Boolean(Vue.Fragment);
@@ -1,4 +1,5 @@
1
1
  import Vue from 'vue';
2
+ import { isVue3 } from './constants';
2
3
 
3
4
  // Fragment will be available only in Vue.js 3
4
5
  const { Fragment, Comment, Text } = Vue;
@@ -32,8 +33,6 @@ export function isVnodeEmpty(vnode) {
32
33
  }
33
34
 
34
35
  export function isSlotEmpty(vueInstance, slot, slotArgs) {
35
- const isVue3 = Boolean(Fragment);
36
-
37
36
  const slotContent = isVue3
38
37
  ? // we need to check both $slots and $scopedSlots due to https://github.com/vuejs/core/issues/8869
39
38
  // additionally, in @vue/compat $slot might be a function instead of array of vnodes (sigh)