@gitlab/ui 86.5.1 → 86.7.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 +14 -0
- package/dist/components/experimental/duo/chat/components/duo_chat_conversation/duo_chat_conversation.js +13 -1
- package/dist/components/experimental/duo/chat/components/duo_chat_message/buttons_utils.js +25 -0
- package/dist/components/experimental/duo/chat/components/duo_chat_message/copy_code_element.js +2 -21
- package/dist/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.js +9 -2
- package/dist/components/experimental/duo/chat/components/duo_chat_message/insert_code_snippet_element.js +20 -0
- package/dist/components/experimental/duo/chat/duo_chat.js +12 -1
- package/dist/components/experimental/duo/chat/mock_data.js +3 -1
- package/dist/directives/outside/outside.js +89 -39
- package/dist/index.css +1 -1
- package/dist/index.css.map +1 -1
- package/package.json +1 -1
- package/src/components/experimental/duo/chat/components/duo_chat_conversation/duo_chat_conversation.vue +17 -1
- package/src/components/experimental/duo/chat/components/duo_chat_message/buttons_utils.js +30 -0
- package/src/components/experimental/duo/chat/components/duo_chat_message/copy_code_element.js +2 -31
- package/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.scss +19 -2
- package/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.vue +9 -1
- package/src/components/experimental/duo/chat/components/duo_chat_message/insert_code_snippet_element.js +17 -0
- package/src/components/experimental/duo/chat/duo_chat.vue +13 -0
- package/src/components/experimental/duo/chat/mock_data.js +3 -1
- package/src/directives/outside/outside.js +81 -41
- package/src/directives/outside/outside.md +92 -6
package/package.json
CHANGED
|
@@ -29,6 +29,13 @@ export default {
|
|
|
29
29
|
type: Array,
|
|
30
30
|
required: true,
|
|
31
31
|
},
|
|
32
|
+
/**
|
|
33
|
+
* Whether the insertCode feature should be available.
|
|
34
|
+
*/
|
|
35
|
+
enableCodeInsertion: {
|
|
36
|
+
type: Boolean,
|
|
37
|
+
required: true,
|
|
38
|
+
},
|
|
32
39
|
/**
|
|
33
40
|
* Whether to show the delimiter before this conversation
|
|
34
41
|
*/
|
|
@@ -46,12 +53,20 @@ export default {
|
|
|
46
53
|
*/
|
|
47
54
|
this.$emit('track-feedback', event);
|
|
48
55
|
},
|
|
56
|
+
onInsertCodeSnippet(e) {
|
|
57
|
+
this.$emit('insert-code-snippet', e);
|
|
58
|
+
},
|
|
49
59
|
},
|
|
50
60
|
i18n,
|
|
51
61
|
};
|
|
52
62
|
</script>
|
|
53
63
|
<template>
|
|
54
|
-
<div
|
|
64
|
+
<div
|
|
65
|
+
:class="[
|
|
66
|
+
'gl-display-flex gl-flex-direction-column gl-justify-content-end',
|
|
67
|
+
{ 'insert-code-hidden': !enableCodeInsertion },
|
|
68
|
+
]"
|
|
69
|
+
>
|
|
55
70
|
<div
|
|
56
71
|
v-if="showDelimiter"
|
|
57
72
|
class="gl-my-5 gl-display-flex gl-align-items-center gl-gap-4 gl-text-gray-500"
|
|
@@ -67,6 +82,7 @@ export default {
|
|
|
67
82
|
:message="msg"
|
|
68
83
|
:is-cancelled="canceledRequestIds.includes(msg.requestId)"
|
|
69
84
|
@track-feedback="onTrackFeedback"
|
|
85
|
+
@insert-code-snippet="onInsertCodeSnippet"
|
|
70
86
|
/>
|
|
71
87
|
</div>
|
|
72
88
|
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import iconsPath from '@gitlab/svgs/dist/icons.svg';
|
|
2
|
+
|
|
3
|
+
export const createButton = (title = 'Insert the code snippet', iconId = 'insert') => {
|
|
4
|
+
const button = document.createElement('button');
|
|
5
|
+
button.type = 'button';
|
|
6
|
+
button.classList.add(
|
|
7
|
+
'btn',
|
|
8
|
+
'btn-default',
|
|
9
|
+
'btn-md',
|
|
10
|
+
'gl-button',
|
|
11
|
+
'btn-default-secondary',
|
|
12
|
+
'btn-icon'
|
|
13
|
+
);
|
|
14
|
+
button.dataset.title = title;
|
|
15
|
+
|
|
16
|
+
// Create an SVG element with the correct namespace
|
|
17
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
18
|
+
svg.setAttribute('role', 'img');
|
|
19
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
20
|
+
svg.classList.add('gl-button-icon', 'gl-icon', 's16');
|
|
21
|
+
|
|
22
|
+
// Create a 'use' element with the correct namespace
|
|
23
|
+
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
|
24
|
+
use.setAttribute('href', `${iconsPath}#${iconId}`);
|
|
25
|
+
|
|
26
|
+
svg.appendChild(use);
|
|
27
|
+
button.appendChild(svg);
|
|
28
|
+
|
|
29
|
+
return button;
|
|
30
|
+
};
|
package/src/components/experimental/duo/chat/components/duo_chat_message/copy_code_element.js
CHANGED
|
@@ -1,38 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
const createButton = () => {
|
|
4
|
-
const button = document.createElement('button');
|
|
5
|
-
button.type = 'button';
|
|
6
|
-
button.classList.add(
|
|
7
|
-
'btn',
|
|
8
|
-
'btn-default',
|
|
9
|
-
'btn-md',
|
|
10
|
-
'gl-button',
|
|
11
|
-
'btn-default-secondary',
|
|
12
|
-
'btn-icon'
|
|
13
|
-
);
|
|
14
|
-
button.dataset.title = 'Copy to clipboard';
|
|
15
|
-
|
|
16
|
-
// Create an SVG element with the correct namespace
|
|
17
|
-
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
18
|
-
svg.setAttribute('role', 'img');
|
|
19
|
-
svg.setAttribute('aria-hidden', 'true');
|
|
20
|
-
svg.classList.add('gl-button-icon', 'gl-icon', 's16');
|
|
21
|
-
|
|
22
|
-
// Create a 'use' element with the correct namespace
|
|
23
|
-
const use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
|
|
24
|
-
use.setAttribute('href', `${iconsPath}#copy-to-clipboard`);
|
|
25
|
-
|
|
26
|
-
svg.appendChild(use);
|
|
27
|
-
button.appendChild(svg);
|
|
28
|
-
|
|
29
|
-
return button;
|
|
30
|
-
};
|
|
1
|
+
import { createButton } from './buttons_utils';
|
|
31
2
|
|
|
32
3
|
export class CopyCodeElement extends HTMLElement {
|
|
33
4
|
constructor() {
|
|
34
5
|
super();
|
|
35
|
-
const btn = createButton();
|
|
6
|
+
const btn = createButton('Copy to clipboard', 'copy-to-clipboard');
|
|
36
7
|
const wrapper = this.parentNode;
|
|
37
8
|
|
|
38
9
|
this.appendChild(btn);
|
package/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.scss
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
@include gl-mb-0;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
copy-code
|
|
24
|
+
copy-code,
|
|
25
|
+
insert-code-snippet {
|
|
25
26
|
position: absolute;
|
|
26
27
|
@include gl-transition-medium;
|
|
27
28
|
@include gl-opacity-0;
|
|
@@ -29,9 +30,15 @@
|
|
|
29
30
|
top: $gl-spacing-scale-3;
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
copy-code {
|
|
34
|
+
margin-right: $gl-spacing-scale-8;
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
.js-markdown-code.markdown-code-block:hover {
|
|
33
38
|
copy-code,
|
|
34
|
-
copy-code:focus-within
|
|
39
|
+
copy-code:focus-within,
|
|
40
|
+
insert-code-snippet,
|
|
41
|
+
insert-code-snippet:focus-within {
|
|
35
42
|
@include gl-opacity-10;
|
|
36
43
|
}
|
|
37
44
|
}
|
|
@@ -45,3 +52,13 @@
|
|
|
45
52
|
position: absolute;
|
|
46
53
|
}
|
|
47
54
|
}
|
|
55
|
+
|
|
56
|
+
.insert-code-hidden {
|
|
57
|
+
insert-code-snippet {
|
|
58
|
+
display: none;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
copy-code {
|
|
62
|
+
margin-right: 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/components/experimental/duo/chat/components/duo_chat_message/duo_chat_message.vue
CHANGED
|
@@ -11,6 +11,7 @@ import DocumentationSources from '../duo_chat_message_sources/duo_chat_message_s
|
|
|
11
11
|
// eslint-disable-next-line no-restricted-imports
|
|
12
12
|
import { renderDuoChatMarkdownPreview } from '../../markdown_renderer';
|
|
13
13
|
import { CopyCodeElement } from './copy_code_element';
|
|
14
|
+
import { InsertCodeSnippetElement } from './insert_code_snippet_element';
|
|
14
15
|
import { concatUntilEmpty } from './utils';
|
|
15
16
|
|
|
16
17
|
export const i18n = {
|
|
@@ -30,7 +31,7 @@ export default {
|
|
|
30
31
|
name: 'GlDuoChatMessage',
|
|
31
32
|
i18n,
|
|
32
33
|
safeHtmlConfigExtension: {
|
|
33
|
-
ADD_TAGS: ['copy-code'],
|
|
34
|
+
ADD_TAGS: ['copy-code', 'insert-code-snippet'],
|
|
34
35
|
},
|
|
35
36
|
components: {
|
|
36
37
|
DocumentationSources,
|
|
@@ -132,6 +133,9 @@ export default {
|
|
|
132
133
|
if (!customElements.get('copy-code')) {
|
|
133
134
|
customElements.define('copy-code', CopyCodeElement);
|
|
134
135
|
}
|
|
136
|
+
if (!customElements.get('insert-code-snippet')) {
|
|
137
|
+
customElements.define('insert-code-snippet', InsertCodeSnippetElement);
|
|
138
|
+
}
|
|
135
139
|
},
|
|
136
140
|
mounted() {
|
|
137
141
|
if (this.isAssistantMessage) {
|
|
@@ -180,6 +184,9 @@ export default {
|
|
|
180
184
|
this.stopWatchingMessage();
|
|
181
185
|
}
|
|
182
186
|
},
|
|
187
|
+
onInsertCodeSnippet(e) {
|
|
188
|
+
this.$emit('insert-code-snippet', e);
|
|
189
|
+
},
|
|
183
190
|
},
|
|
184
191
|
};
|
|
185
192
|
</script>
|
|
@@ -193,6 +200,7 @@ export default {
|
|
|
193
200
|
'gl-bg-white': isAssistantMessage && !error,
|
|
194
201
|
'gl-bg-red-50 gl-border-none!': error,
|
|
195
202
|
}"
|
|
203
|
+
@insert-code-snippet="onInsertCodeSnippet"
|
|
196
204
|
>
|
|
197
205
|
<gl-icon
|
|
198
206
|
v-if="error"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createButton } from './buttons_utils';
|
|
2
|
+
|
|
3
|
+
export class InsertCodeSnippetElement extends HTMLElement {
|
|
4
|
+
constructor(codeBlock) {
|
|
5
|
+
super();
|
|
6
|
+
const btn = createButton();
|
|
7
|
+
const wrapper = codeBlock;
|
|
8
|
+
this.appendChild(btn);
|
|
9
|
+
btn.addEventListener('click', () => {
|
|
10
|
+
if (wrapper) {
|
|
11
|
+
wrapper.dispatchEvent(
|
|
12
|
+
new CustomEvent('insert-code-snippet', { bubbles: true, cancelable: true })
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -116,6 +116,14 @@ export default {
|
|
|
116
116
|
required: false,
|
|
117
117
|
default: true,
|
|
118
118
|
},
|
|
119
|
+
/**
|
|
120
|
+
* Whether the insertCode feature should be available.
|
|
121
|
+
*/
|
|
122
|
+
enableCodeInsertion: {
|
|
123
|
+
type: Boolean,
|
|
124
|
+
required: false,
|
|
125
|
+
default: false,
|
|
126
|
+
},
|
|
119
127
|
/**
|
|
120
128
|
* Array of predefined prompts to display in the chat to start a conversation.
|
|
121
129
|
*/
|
|
@@ -403,6 +411,9 @@ export default {
|
|
|
403
411
|
this.setPromptAndFocus(`${command.name} `);
|
|
404
412
|
}
|
|
405
413
|
},
|
|
414
|
+
onInsertCodeSnippet(e) {
|
|
415
|
+
this.$emit('insert-code-snippet', e);
|
|
416
|
+
},
|
|
406
417
|
},
|
|
407
418
|
i18n,
|
|
408
419
|
emptySvg,
|
|
@@ -487,10 +498,12 @@ export default {
|
|
|
487
498
|
<gl-duo-chat-conversation
|
|
488
499
|
v-for="(conversation, index) in conversations"
|
|
489
500
|
:key="`conversation-${index}`"
|
|
501
|
+
:enable-code-insertion="enableCodeInsertion"
|
|
490
502
|
:messages="conversation"
|
|
491
503
|
:canceled-request-ids="canceledRequestIds"
|
|
492
504
|
:show-delimiter="index > 0"
|
|
493
505
|
@track-feedback="onTrackFeedback"
|
|
506
|
+
@insert-code-snippet="onInsertCodeSnippet"
|
|
494
507
|
/>
|
|
495
508
|
<template v-if="!hasMessages && !isLoading">
|
|
496
509
|
<gl-empty-state
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { setStoryTimeout } from '../../../../utils/test_utils';
|
|
2
|
+
import { InsertCodeSnippetElement } from './components/duo_chat_message/insert_code_snippet_element';
|
|
2
3
|
import {
|
|
3
4
|
DOCUMENTATION_SOURCE_TYPES,
|
|
4
5
|
MESSAGE_MODEL_ROLES,
|
|
@@ -31,7 +32,7 @@ export const MOCK_RESPONSE_MESSAGE = {
|
|
|
31
32
|
content:
|
|
32
33
|
'Here is a simple JavaScript function to sum two numbers:\n\n ```js\n function sum(a, b) {\n return a + b;\n }\n ```\n \n To use it:\n \n ```js\n const result = sum(5, 3); // result = 8\n ```\n \n This function takes two number parameters, a and b. It returns the sum of adding them together.\n',
|
|
33
34
|
contentHtml:
|
|
34
|
-
'<p data-sourcepos="1:1-1:56" dir="auto">Here is a simple JavaScript function to sum two numbers:</p>\n<div class="gl-relative markdown-code-block js-markdown-code">\n<pre data-sourcepos="3:1-7:3" data-canonical-lang="js" class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"><code><span id="LC1" class="line" lang="javascript"><span class="kd">function</span> <span class="nf">sum</span><span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="p">{</span></span>\n<span id="LC2" class="line" lang="javascript"> <span class="k">return</span> <span class="nx">a</span> <span class="o">+</span> <span class="nx">b</span><span class="p">;</span></span>\n<span id="LC3" class="line" lang="javascript"><span class="p">}</span></span></code></pre>\n<copy-code></copy-code>\n</div>\n<p data-sourcepos="9:1-9:10" dir="auto">To use it:</p>\n<div class="gl-relative markdown-code-block js-markdown-code">\n<pre data-sourcepos="11:1-13:3" data-canonical-lang="js" class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"><code><span id="LC1" class="line" lang="javascript"><span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="nf">sum</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span> <span class="mi">3</span><span class="p">);</span> <span class="c1">// result = 8</span></span></code></pre>\n<copy-code></copy-code>\n</div>\n<p data-sourcepos="15:1-15:95" dir="auto">This function takes two number parameters, a and b. It returns the sum of adding them together.</p>',
|
|
35
|
+
'<p data-sourcepos="1:1-1:56" dir="auto">Here is a simple JavaScript function to sum two numbers:</p>\n<div class="gl-relative markdown-code-block js-markdown-code">\n<pre data-sourcepos="3:1-7:3" data-canonical-lang="js" class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"><code><span id="LC1" class="line" lang="javascript"><span class="kd">function</span> <span class="nf">sum</span><span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="nx">b</span><span class="p">)</span> <span class="p">{</span></span>\n<span id="LC2" class="line" lang="javascript"> <span class="k">return</span> <span class="nx">a</span> <span class="o">+</span> <span class="nx">b</span><span class="p">;</span></span>\n<span id="LC3" class="line" lang="javascript"><span class="p">}</span></span></code></pre>\n<copy-code></copy-code>\n<insert-code-snippet></insert-code-snippet>\n</div>\n<p data-sourcepos="9:1-9:10" dir="auto">To use it:</p>\n<div class="gl-relative markdown-code-block js-markdown-code">\n<pre data-sourcepos="11:1-13:3" data-canonical-lang="js" class="code highlight js-syntax-highlight language-javascript" lang="javascript" v-pre="true"><code><span id="LC1" class="line" lang="javascript"><span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="nf">sum</span><span class="p">(</span><span class="mi">5</span><span class="p">,</span> <span class="mi">3</span><span class="p">);</span> <span class="c1">// result = 8</span></span></code></pre>\n<copy-code></copy-code>\n</div>\n<p data-sourcepos="15:1-15:95" dir="auto">This function takes two number parameters, a and b. It returns the sum of adding them together.</p>',
|
|
35
36
|
role: MESSAGE_MODEL_ROLES.assistant,
|
|
36
37
|
extras: {
|
|
37
38
|
sources: MOCK_SOURCES,
|
|
@@ -137,6 +138,7 @@ export const renderGFM = (el) => {
|
|
|
137
138
|
const codeBlock = el.querySelectorAll('.markdown-code-block');
|
|
138
139
|
codeBlock.forEach((block) => {
|
|
139
140
|
block?.classList.add('gl-markdown', 'gl-compact-markdown');
|
|
141
|
+
block?.appendChild(new InsertCodeSnippetElement(block));
|
|
140
142
|
});
|
|
141
143
|
};
|
|
142
144
|
|
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Map<HTMLElement, Function>
|
|
2
|
+
* Map<HTMLElement, { callback: Function, eventTypes: Array<string> }>
|
|
3
3
|
*/
|
|
4
4
|
const callbacks = new Map();
|
|
5
|
+
const click = 'click';
|
|
6
|
+
const focusin = 'focusin';
|
|
7
|
+
const supportedEventTypes = [click, focusin];
|
|
8
|
+
const defaultEventType = click;
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
|
-
*
|
|
11
|
+
* A Set to keep track of currently active event types.
|
|
12
|
+
* This ensures that event listeners are only added for the event types that are in use.
|
|
13
|
+
*
|
|
14
|
+
* @type {Set<string>}
|
|
8
15
|
*/
|
|
9
|
-
|
|
16
|
+
const activeEventTypes = new Set();
|
|
10
17
|
let lastMousedown = null;
|
|
11
18
|
|
|
12
19
|
const globalListener = (event) => {
|
|
13
|
-
callbacks.forEach((callback, element) => {
|
|
14
|
-
const originalEvent = lastMousedown || event;
|
|
20
|
+
callbacks.forEach(({ callback, eventTypes }, element) => {
|
|
21
|
+
const originalEvent = event.type === click ? lastMousedown || event : event;
|
|
15
22
|
if (
|
|
16
23
|
// Ignore events that aren't targeted outside the element
|
|
17
|
-
element.contains(originalEvent.target)
|
|
24
|
+
element.contains(originalEvent.target) ||
|
|
25
|
+
// Ignore events that aren't the specified types for this element
|
|
26
|
+
!eventTypes.includes(event.type)
|
|
18
27
|
) {
|
|
19
28
|
return;
|
|
20
29
|
}
|
|
@@ -28,7 +37,9 @@ const globalListener = (event) => {
|
|
|
28
37
|
}
|
|
29
38
|
}
|
|
30
39
|
});
|
|
31
|
-
|
|
40
|
+
if (event.type === click) {
|
|
41
|
+
lastMousedown = null;
|
|
42
|
+
}
|
|
32
43
|
};
|
|
33
44
|
|
|
34
45
|
// We need to listen for mouse events because text selection fires click event only when selection ends.
|
|
@@ -38,41 +49,73 @@ const onMousedown = (event) => {
|
|
|
38
49
|
lastMousedown = event;
|
|
39
50
|
};
|
|
40
51
|
|
|
41
|
-
const startListening = () => {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
52
|
+
const startListening = (eventTypes) => {
|
|
53
|
+
eventTypes.forEach((eventType) => {
|
|
54
|
+
if (!activeEventTypes.has(eventType)) {
|
|
55
|
+
// Listening to mousedown events, ensures that a text selection doesn't trigger the
|
|
56
|
+
// GlOutsideDirective 'click' callback if the selection started within the target element.
|
|
57
|
+
if (eventType === click) {
|
|
58
|
+
document.addEventListener('mousedown', onMousedown);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Added { capture: true } to all event types to prevent the behavior discussed in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1686#note_412545027
|
|
62
|
+
// Ensures the event listener handles the event in the capturing phase, avoiding issues encountered previously.
|
|
63
|
+
// Cannot be tested with Jest or Cypress, but can be tested with Playwright in the future: https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/4272#note_1947425384
|
|
64
|
+
document.addEventListener(eventType, globalListener, { capture: true });
|
|
65
|
+
activeEventTypes.add(eventType);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
45
68
|
|
|
46
|
-
document.addEventListener('mousedown', onMousedown);
|
|
47
|
-
// Added { capture: true } to prevent the behavior discussed in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1686#note_412545027
|
|
48
|
-
// Ensures the event listener handles the event in the capturing phase, avoiding issues encountered previously.
|
|
49
|
-
// Cannot be tested with Jest or Cypress, but can be tested with Playwright in the future: https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/4272#note_1947425384
|
|
50
|
-
document.addEventListener('click', globalListener, { capture: true });
|
|
51
|
-
listening = true;
|
|
52
69
|
lastMousedown = null;
|
|
53
70
|
};
|
|
54
71
|
|
|
55
|
-
const stopListening = () => {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
const stopListening = (eventTypesToUnbind) => {
|
|
73
|
+
eventTypesToUnbind.forEach((eventType) => {
|
|
74
|
+
if (activeEventTypes.has(eventType)) {
|
|
75
|
+
if ([...callbacks.values()].every(({ eventTypes }) => !eventTypes.includes(eventType))) {
|
|
76
|
+
document.removeEventListener(eventType, globalListener);
|
|
77
|
+
activeEventTypes.delete(eventType);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
59
81
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
82
|
+
if (eventTypesToUnbind.includes(click) && !activeEventTypes.has(click)) {
|
|
83
|
+
document.removeEventListener('mousedown', onMousedown);
|
|
84
|
+
}
|
|
63
85
|
};
|
|
64
86
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
87
|
+
function parseBinding({ arg, value, modifiers }) {
|
|
88
|
+
const modifiersList = Object.keys(modifiers);
|
|
89
|
+
|
|
90
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
91
|
+
if (typeof value !== 'function') {
|
|
92
|
+
throw new Error(`[GlOutsideDirective] Value must be a function; got ${typeof value}!`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof arg !== 'undefined') {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`[GlOutsideDirective] Arguments are not supported. Consider using modifiers instead.`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
69
100
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
101
|
+
if (modifiersList.some((modifier) => !supportedEventTypes.includes(modifier))) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`[GlOutsideDirective] Cannot bind ${modifiersList} events; supported event types are: ${supportedEventTypes.join(
|
|
104
|
+
', '
|
|
105
|
+
)}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
74
108
|
}
|
|
75
109
|
|
|
110
|
+
return {
|
|
111
|
+
callback: value,
|
|
112
|
+
eventTypes: modifiersList.length > 0 ? modifiersList : [defaultEventType],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const bind = (el, bindings) => {
|
|
117
|
+
const { callback, eventTypes } = parseBinding(bindings);
|
|
118
|
+
|
|
76
119
|
if (callbacks.has(el)) {
|
|
77
120
|
// This element is already bound. This is possible if two components, which
|
|
78
121
|
// share the same root node, (i.e., one is a higher-order component
|
|
@@ -86,18 +129,15 @@ const bind = (el, { value, arg = 'click' }) => {
|
|
|
86
129
|
return;
|
|
87
130
|
}
|
|
88
131
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
callbacks.set(el, value);
|
|
132
|
+
callbacks.set(el, { callback, eventTypes });
|
|
133
|
+
startListening(eventTypes);
|
|
94
134
|
};
|
|
95
135
|
|
|
96
136
|
const unbind = (el) => {
|
|
97
|
-
callbacks.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
stopListening();
|
|
137
|
+
const entry = callbacks.get(el);
|
|
138
|
+
if (entry) {
|
|
139
|
+
callbacks.delete(el);
|
|
140
|
+
stopListening(entry.eventTypes);
|
|
101
141
|
}
|
|
102
142
|
};
|
|
103
143
|
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
A Vue Directive to call a callback when a
|
|
2
|
-
the directive is bound to. Any
|
|
1
|
+
A Vue Directive to call a callback when a supported event type occurs *outside* of the element
|
|
2
|
+
the directive is bound to. Any events on the element or any descendant elements are ignored.
|
|
3
|
+
The directive supports the event types `click` and `focusin` and can be configured in several ways.
|
|
4
|
+
If no event type is set, `click` is the default.
|
|
3
5
|
|
|
4
6
|
## Usage
|
|
5
7
|
|
|
8
|
+
### Default
|
|
9
|
+
|
|
10
|
+
The following example listens for click events outside the specified element:
|
|
11
|
+
|
|
6
12
|
```html
|
|
7
13
|
<script>
|
|
8
14
|
import { GlOutsideDirective as Outside } from '@gitlab/ui';
|
|
@@ -22,6 +28,51 @@ export default {
|
|
|
22
28
|
</template>
|
|
23
29
|
```
|
|
24
30
|
|
|
31
|
+
### When binding another event type than `click`
|
|
32
|
+
|
|
33
|
+
You can specify event types as modifiers. The following example listens for `focusin` events,
|
|
34
|
+
but not for `click`. With this implementation:
|
|
35
|
+
|
|
36
|
+
```html
|
|
37
|
+
<script>
|
|
38
|
+
export default {
|
|
39
|
+
methods: {
|
|
40
|
+
onFocusin(event) {
|
|
41
|
+
console.log('User set the focus somewhere outside of this component', event);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<template>
|
|
48
|
+
<div v-outside.focusin="onFocusin">...</div>
|
|
49
|
+
</template>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### When binding multiple event types
|
|
53
|
+
|
|
54
|
+
You can specify multiple event types by providing multiple modifiers. The following example
|
|
55
|
+
listens for `click` and `focusin` events:
|
|
56
|
+
|
|
57
|
+
```html
|
|
58
|
+
<script>
|
|
59
|
+
export default {
|
|
60
|
+
methods: {
|
|
61
|
+
onEvent(event) {
|
|
62
|
+
console.log('Event occurred outside the element:', event);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
</script>
|
|
67
|
+
|
|
68
|
+
<template>
|
|
69
|
+
<div v-outside.click.focusin="onEvent">...</div>
|
|
70
|
+
</template>
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
💡 The callback function receives the `event` as a parameter. You can use the `event.type`
|
|
74
|
+
property to execute different code paths depending on which event triggered the callback.
|
|
75
|
+
|
|
25
76
|
### When handler expects arguments
|
|
26
77
|
|
|
27
78
|
In case a click handler expects an arument to be passed, simple `v-outside="onClick('foo')"` will
|
|
@@ -36,19 +87,54 @@ import { GlOutsideDirective as Outside } from '@gitlab/ui';
|
|
|
36
87
|
export default {
|
|
37
88
|
directives: { Outside },
|
|
38
89
|
methods: {
|
|
39
|
-
onClick(foo) {
|
|
40
|
-
|
|
90
|
+
onClick(event, foo) {
|
|
91
|
+
console.log('Event occurred outside the element:', event);
|
|
92
|
+
console.log('An argument was passed along:', foo);
|
|
41
93
|
},
|
|
42
94
|
},
|
|
43
95
|
};
|
|
44
96
|
</script>
|
|
45
97
|
|
|
46
98
|
<template>
|
|
47
|
-
<div v-outside="() => onClick('foo')">Click anywhere but here</div>
|
|
99
|
+
<div v-outside="(event) => onClick(event, 'foo')">Click anywhere but here</div>
|
|
48
100
|
</template>
|
|
49
101
|
```
|
|
50
102
|
|
|
51
103
|
## Caveats
|
|
52
104
|
|
|
53
|
-
|
|
105
|
+
* Clicks cannot be detected across document boundaries (e.g., across an
|
|
54
106
|
`iframe` boundary), in either direction.
|
|
107
|
+
* Clicks on focusable elements, such as buttons or input fields, will fire both
|
|
108
|
+
`click` and `focusin` events. When both event types are registered,
|
|
109
|
+
the callback will be executed twice. To prevent executing the same code twice
|
|
110
|
+
after only one user interaction, use a flag in the callback to stop its
|
|
111
|
+
execution. Example:
|
|
112
|
+
|
|
113
|
+
```html
|
|
114
|
+
<script>
|
|
115
|
+
export default {
|
|
116
|
+
data: () => ({
|
|
117
|
+
isOpen: false,
|
|
118
|
+
}),
|
|
119
|
+
methods: {
|
|
120
|
+
openDropdown() {
|
|
121
|
+
this.isOpen = true;
|
|
122
|
+
},
|
|
123
|
+
closeDropdown() {
|
|
124
|
+
if(!this.isOpen) {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// more code
|
|
129
|
+
|
|
130
|
+
this.isOpen = false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<template>
|
|
137
|
+
<button type="button" @click="openDropdown">Open</button>
|
|
138
|
+
<div v-outside.click.focusin="closeDropdown">...</div>
|
|
139
|
+
</template>
|
|
140
|
+
```
|