Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • albert.khasanov/gitlab-ui
  • SevenOutman/gitlab-ui
  • ClemMakesApps/gitlab-ui
  • gitlab-org/gitlab-ui
  • gtsiolis/gitlab-ui
  • mark.obradley/gitlab-ui
  • piall/gitlab-ui
  • redreamer/gitlab-ui
  • runrog/gitlab-ui
  • yangchigi/gitlab-ui
  • jayalakshmij/gitlab-ui
  • sonqu/gitlab-ui
  • nnelson/gitlab-ui
  • michel.engelen/gitlab-ui
  • bsradcliffe/gitlab-ui
  • petahbyte/gitlab-ui
  • joe.wollard/gitlab-ui
  • jihye.paik/gitlab-ui
  • Kamikadze4GAME/gitlab-ui
  • Gaslan/gitlab-ui
  • inyee786/gitlab-ui
  • abuuzayr/gitlab-ui
  • NativeUser/gitlab-ui
  • _23phy/gitlab-ui
  • v_hladko/gitlab-ui
  • killbotxd/gitlab-ui
  • yeonyu/gitlab-ui
  • mnzone/gitlab-ui
  • ashishgkwd/gitlab-ui
  • Keimeno/gitlab-ui
  • dcouture/gitlab-ui
  • Rory_Chillmore/gitlab-ui
  • misha28x/gitlab-ui
  • shawchandeshwar61/gitlab-ui
  • aszs/gitlab-ui
  • leetickett/gitlab-ui
  • stalker3343/gitlab-ui
  • davepies/gitlab-ui
  • pravi/gitlab-ui
  • ChasLui/gitlab-ui
  • wangko27/gitlab-ui
  • kaangokdemir/gitlab-ui
  • rajiff/gitlab-ui
  • gitlab-org/frontend/playground/gitlab-ui
  • orozot/gitlab-ui
  • gitlab-renovate-forks/gitlab-ui
  • Meghana-12/gitlab-ui
  • tweichart/gitlab-ui
  • leipert/gitlab-ui
  • wenweicui/gitlab-ui
  • mohanraj.geniebeaver/gitlab-ui
  • imrishabh18/gitlab-ui
  • ma-lihui/gitlab-ui
  • piyushsinghania/gitlab-ui
  • NeetuJain/gitlab-ui
  • waridrox/gitlab-ui
  • ankita.singh.200020/gitlab-ui
  • sercan55344/gitlab-ui
  • pangjian/gitlab-ui
  • 2002newhritik/gitlab-ui
  • rachelvfmurphy/gitlab-ui
  • shridharbhat1998/gitlab-ui
  • paulwvnjohi/gitlab-ui
  • edith007/gitlab-ui
  • IgorPahota/gitlab-ui
  • yashmaheshwari/gitlab-ui
  • chiachenglu/gitlab-ui
  • Dhairya3124/gitlab-ui
  • preetidevsang/gitlab-ui
  • revbp/gitlab-ui
  • khout/gitlab-ui
  • Bajjouayoub/gitlab-ui
  • ali_o_kan/gitlab-ui
  • marcel.feldmann/gitlab-ui
  • serenafang/gitlab-ui
  • jamesliu-gitlab/gitlab-ui
  • wallisaleh87/gitlab-ui
  • ALypovyi/gitlab-ui
  • thutterer/gitlab-ui
  • pikepaule/gitlab-ui
  • splattael/gitlab-ui
  • rettalps/gitlab-ui
  • rajdevelopr/gitlab-ui
  • Mohamadhassan98/gitlab-ui
  • dannyelcf/gitlab-ui
  • vchan14/gitlab-ui
  • 23bytes/gitlab-ui
  • dr.shvets/gitlab-ui
  • crystal.alchemist/gitlab-ui
  • chriscordoba1948/gitlab-ui
  • markrian/gitlab-ui
  • zillemarco/gitlab-ui
  • bhatewarak/gitlab-ui
  • hamare-contrib/gitlab-ui
  • agnieszka.gancarczyk/gitlab-ui
  • khulnasoft/khulnasoft-ui
  • abitrolly/gitlab-ui
  • normatov13/gitlab-ui
  • Brwnknight20/gitlab-ui
  • chekerTlili/medmed-front-test
  • Fcogp90/gitlab-ui
  • Harith_training/gitlab-ui
  • rahulpan_altair/gitlab-ui
  • HelloZJW/gitlab-ui
  • fathead32/gitlab-ui
  • akumar1503/gitlab-ui
  • KhaledElkhoreby/gitlab-ui
  • pierrebelloy/gitlab-ui
  • lxwan/gitlab-ui
  • dpalubin/gitlab-ui
  • gitlab-community/gitlab-ui
  • ubaidisaev/gitlab-ui
  • serenafang/gitlab-ui-serena-test
  • hamzasouelmi/gitlab-ui
  • youngbeomshin/gitlab-ui
  • kimseoha1993/gitlab-ui
  • kevin.rojas/gitlab-ui
  • catinbag/gitlab-ui
  • mathieu.pillar/gitlab-ui
  • qk44077907/gitlab-ui
  • fenyuluoshang/gitlab-ui
  • QingJ/gitlab-ui
  • x--/gitlab-ui
  • nraj0408/gitlab-ui
  • victorelmov/gitlab-ui
  • sollo.nic.c.cc/gitlab-ui
  • sksardar42/gitlab-ui
  • nqdev-fork/gitlab-org/gitlab-ui
  • JeremyWuuuuu/gitlab-ui
  • kara006n/gitlab-ui
  • ndt-contribute/gitlab-ui
  • sahadat-sk/gitlab-ui
  • mdwiltfong/gitlab-ui
  • muntazacloud/gitlab-ui
  • drewcauchi/gitlab-ui
  • liummmm/gitlab-ui
  • ale3oula/gitlab-ui
  • kiran-4444/gitlab-ui
  • DUCKDUCKGODEVELOPER/gitlab-ui
  • g32james/gitlab-ui
  • Saeed178/gitlab-ui
  • nickaldwin/gitlab-ui
  • armbiant/gitlab-gui
  • satyamkale27/gitlab-ui
  • jannik_lehmann/gitlab-ui-mono-tinkering
  • zayminkhant/gitlab-ui
  • aytacyaydem/gitlab-ui
  • initdc/gitlab-ui
  • rungruang1/gitlab-ui
  • dormanshylas1/gitlab-ui
  • armbiant/gitlab-ui
  • Piyush-r-bhaskar/gitlab-ui
  • ollevche/gitlab-ui
  • joefoti178/gitlab-ui
  • william.allen1/gitlab-ui
  • anupam42/gitlab-ui
156 results
Show changes
Commits on Source (3)
# [86.6.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v86.5.1...v86.6.0) (2024-07-10)
### Features
* **OutsideDirective:** Add Event Listener for focusin ([aee99c5](https://gitlab.com/gitlab-org/gitlab-ui/commit/aee99c56b926d9468030774c4bd33eac01f4f601))
## [86.5.1](https://gitlab.com/gitlab-org/gitlab-ui/compare/v86.5.0...v86.5.1) (2024-07-10)
......
{
"name": "@gitlab/ui",
"version": "86.5.1",
"version": "86.6.0",
"description": "GitLab UI Components",
"license": "MIT",
"main": "dist/index.js",
......
/**
* Map<HTMLElement, Function>
* Map<HTMLElement, { callback: Function, eventTypes: Array<string> }>
*/
const callbacks = new Map();
const click = 'click';
const focusin = 'focusin';
const supportedEventTypes = [click, focusin];
const defaultEventType = click;
/**
* Is a global listener already set up?
* A Set to keep track of currently active event types.
* This ensures that event listeners are only added for the event types that are in use.
*
* @type {Set<string>}
*/
let listening = false;
const activeEventTypes = new Set();
let lastMousedown = null;
const globalListener = (event) => {
callbacks.forEach((callback, element) => {
const originalEvent = lastMousedown || event;
callbacks.forEach(({ callback, eventTypes }, element) => {
const originalEvent = event.type === click ? lastMousedown || event : event;
if (
// Ignore events that aren't targeted outside the element
element.contains(originalEvent.target)
element.contains(originalEvent.target) ||
// Ignore events that aren't the specified types for this element
!eventTypes.includes(event.type)
) {
return;
}
......@@ -28,7 +37,9 @@ const globalListener = (event) => {
}
}
});
lastMousedown = null;
if (event.type === click) {
lastMousedown = null;
}
};
// We need to listen for mouse events because text selection fires click event only when selection ends.
......@@ -38,41 +49,73 @@ const onMousedown = (event) => {
lastMousedown = event;
};
const startListening = () => {
if (listening) {
return;
}
const startListening = (eventTypes) => {
eventTypes.forEach((eventType) => {
if (!activeEventTypes.has(eventType)) {
// Listening to mousedown events, ensures that a text selection doesn't trigger the
// GlOutsideDirective 'click' callback if the selection started within the target element.
if (eventType === click) {
document.addEventListener('mousedown', onMousedown);
}
// 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
// Ensures the event listener handles the event in the capturing phase, avoiding issues encountered previously.
// 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
document.addEventListener(eventType, globalListener, { capture: true });
activeEventTypes.add(eventType);
}
});
document.addEventListener('mousedown', onMousedown);
// Added { capture: true } to prevent the behavior discussed in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1686#note_412545027
// Ensures the event listener handles the event in the capturing phase, avoiding issues encountered previously.
// 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
document.addEventListener('click', globalListener, { capture: true });
listening = true;
lastMousedown = null;
};
const stopListening = () => {
if (!listening) {
return;
}
const stopListening = (eventTypesToUnbind) => {
eventTypesToUnbind.forEach((eventType) => {
if (activeEventTypes.has(eventType)) {
if ([...callbacks.values()].every(({ eventTypes }) => !eventTypes.includes(eventType))) {
document.removeEventListener(eventType, globalListener);
activeEventTypes.delete(eventType);
}
}
});
document.removeEventListener('mousedown', onMousedown);
document.removeEventListener('click', globalListener);
listening = false;
if (eventTypesToUnbind.includes(click) && !activeEventTypes.has(click)) {
document.removeEventListener('mousedown', onMousedown);
}
};
const bind = (el, { value, arg = 'click' }) => {
if (typeof value !== 'function') {
throw new Error(`[GlOutsideDirective] Value must be a function; got ${typeof value}!`);
}
function parseBinding({ arg, value, modifiers }) {
const modifiersList = Object.keys(modifiers);
if (process.env.NODE_ENV !== 'production') {
if (typeof value !== 'function') {
throw new Error(`[GlOutsideDirective] Value must be a function; got ${typeof value}!`);
}
if (typeof arg !== 'undefined') {
throw new Error(
`[GlOutsideDirective] Arguments are not supported. Consider using modifiers instead.`
);
}
if (arg !== 'click') {
throw new Error(
`[GlOutsideDirective] Cannot bind ${arg} events; only click events are currently supported!`
);
if (modifiersList.some((modifier) => !supportedEventTypes.includes(modifier))) {
throw new Error(
`[GlOutsideDirective] Cannot bind ${modifiersList} events; supported event types are: ${supportedEventTypes.join(
', '
)}`
);
}
}
return {
callback: value,
eventTypes: modifiersList.length > 0 ? modifiersList : [defaultEventType],
};
}
const bind = (el, bindings) => {
const { callback, eventTypes } = parseBinding(bindings);
if (callbacks.has(el)) {
// This element is already bound. This is possible if two components, which
// share the same root node, (i.e., one is a higher-order component
......@@ -86,18 +129,15 @@ const bind = (el, { value, arg = 'click' }) => {
return;
}
if (!listening) {
startListening();
}
callbacks.set(el, value);
callbacks.set(el, { callback, eventTypes });
startListening(eventTypes);
};
const unbind = (el) => {
callbacks.delete(el);
if (callbacks.size === 0) {
stopListening();
const entry = callbacks.get(el);
if (entry) {
callbacks.delete(el);
stopListening(entry.eventTypes);
}
};
......
A Vue Directive to call a callback when a click occurs *outside* of the element
the directive is bound to. Any clicks on the element or any descendant elements are ignored.
A Vue Directive to call a callback when a supported event type occurs *outside* of the element
the directive is bound to. Any events on the element or any descendant elements are ignored.
The directive supports the event types `click` and `focusin` and can be configured in several ways.
If no event type is set, `click` is the default.
## Usage
### Default
The following example listens for click events outside the specified element:
```html
<script>
import { GlOutsideDirective as Outside } from '@gitlab/ui';
......@@ -22,6 +28,51 @@ export default {
</template>
```
### When binding another event type than `click`
You can specify event types as modifiers. The following example listens for `focusin` events,
but not for `click`. With this implementation:
```html
<script>
export default {
methods: {
onFocusin(event) {
console.log('User set the focus somewhere outside of this component', event);
}
}
};
</script>
<template>
<div v-outside.focusin="onFocusin">...</div>
</template>
```
### When binding multiple event types
You can specify multiple event types by providing multiple modifiers. The following example
listens for `click` and `focusin` events:
```html
<script>
export default {
methods: {
onEvent(event) {
console.log('Event occurred outside the element:', event);
}
}
};
</script>
<template>
<div v-outside.click.focusin="onEvent">...</div>
</template>
```
💡 The callback function receives the `event` as a parameter. You can use the `event.type`
property to execute different code paths depending on which event triggered the callback.
### When handler expects arguments
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';
export default {
directives: { Outside },
methods: {
onClick(foo) {
// This
onClick(event, foo) {
console.log('Event occurred outside the element:', event);
console.log('An argument was passed along:', foo);
},
},
};
</script>
<template>
<div v-outside="() => onClick('foo')">Click anywhere but here</div>
<div v-outside="(event) => onClick(event, 'foo')">Click anywhere but here</div>
</template>
```
## Caveats
- Clicks cannot be detected across document boundaries (e.g., across an
* Clicks cannot be detected across document boundaries (e.g., across an
`iframe` boundary), in either direction.
* Clicks on focusable elements, such as buttons or input fields, will fire both
`click` and `focusin` events. When both event types are registered,
the callback will be executed twice. To prevent executing the same code twice
after only one user interaction, use a flag in the callback to stop its
execution. Example:
```html
<script>
export default {
data: () => ({
isOpen: false,
}),
methods: {
openDropdown() {
this.isOpen = true;
},
closeDropdown() {
if(!this.isOpen) {
return
}
// more code
this.isOpen = false;
}
}
};
</script>
<template>
<button type="button" @click="openDropdown">Open</button>
<div v-outside.click.focusin="closeDropdown">...</div>
</template>
```
......@@ -5,6 +5,7 @@ import { OutsideDirective } from './outside';
describe('outside directive', () => {
let wrapper;
let onClick;
let onFocusin;
const find = (testid) => wrapper.find(`[data-testid="${testid}"]`);
......@@ -13,6 +14,9 @@ describe('outside directive', () => {
<div v-outside="onClick" data-testid="bound">
<div data-testid="inside"></div>
</div>
<button v-outside.focusin="onFocusin" data-testid="bound-focusin">
<span data-testid="inside-focusin" tabindex="0"></span>
</button>
</div>
`;
......@@ -25,6 +29,7 @@ describe('outside directive', () => {
},
methods: {
onClick,
onFocusin,
},
template: defaultTemplate,
},
......@@ -39,9 +44,10 @@ describe('outside directive', () => {
beforeEach(() => {
jest.clearAllMocks();
onClick = jest.fn();
onFocusin = jest.fn();
});
describe('given a callback', () => {
describe('given a callback for click', () => {
it.each`
target | expectedCalls
${'outside'} | ${[[expect.any(MouseEvent)]]}
......@@ -59,6 +65,24 @@ describe('outside directive', () => {
);
});
describe('given a callback for focusin', () => {
it.each`
target | expectedCalls
${'outside'} | ${[[expect.any(FocusEvent)]]}
${'bound-focusin'} | ${[]}
${'inside-focusin'} | ${[]}
`(
'is called with $expectedCalls when focusing on $target element',
async ({ target, expectedCalls }) => {
await createComponent();
find(target).trigger('focusin');
expect(onFocusin.mock.calls).toEqual(expectedCalls);
}
);
});
describe('given multiple instances', () => {
let onClickSibling;
......@@ -112,6 +136,26 @@ describe('outside directive', () => {
expect(document.addEventListener).not.toHaveBeenCalled();
});
it('throws if passed an argument', async () => {
await expect(
createComponent({
template: '<div v-outside:click="onFocusin"></div>',
})
).rejects.toThrow('Arguments are not supported.');
expect(global.console).toHaveLoggedVueErrors();
expect(document.addEventListener).not.toHaveBeenCalled();
});
it('attaches the global listener when binding', async () => {
await createComponent();
expect(document.addEventListener).toHaveBeenCalledTimes(3);
expect(document.addEventListener.mock.calls[0][0]).toBe('mousedown');
expect(document.addEventListener.mock.calls[1][0]).toBe('click');
expect(document.addEventListener.mock.calls[2][0]).toBe('focusin');
});
it('detaches the global listener when last binding is removed', async () => {
await createComponent();
......@@ -120,6 +164,11 @@ describe('outside directive', () => {
document.body.dispatchEvent(new MouseEvent('click'));
expect(onClick).not.toHaveBeenCalled();
expect(document.removeEventListener).toHaveBeenCalledTimes(3);
expect(document.removeEventListener.mock.calls[0][0]).toBe('click');
expect(document.removeEventListener.mock.calls[1][0]).toBe('mousedown');
expect(document.removeEventListener.mock.calls[2][0]).toBe('focusin');
});
it('only unbinds once there are no instances', async () => {
......@@ -151,40 +200,52 @@ describe('outside directive', () => {
});
});
describe('given an arg', () => {
const templateWithArg = (eventType) => `
<div data-testid="outside">
<div v-outside:${eventType}="onClick" data-testid="bound"></div>
describe('given modifiers', () => {
beforeEach(() => {
jest.spyOn(document, 'addEventListener');
});
const templateWitModifiersForFocusIn = (eventTypes) => `
<div data-testid='outside' tabindex='0'>
<button v-outside.${eventTypes.join('.')}="onFocusin" data-testid='bound'>
<span data-testid='inside' tabindex='0'></span>
</button>
</div>`;
it('works with click', async () => {
it('works with focusin', async () => {
await createComponent({
template: templateWithArg('click'),
template: templateWitModifiersForFocusIn(['focusin'], onFocusin),
});
find('outside').trigger('focusin');
expect(onFocusin.mock.calls).toEqual([[expect.any(FocusEvent)]]);
});
it('works with multiple event types', async () => {
await createComponent({
template: templateWitModifiersForFocusIn(['click', 'focusin']),
});
find('outside').trigger('focusin');
find('outside').trigger('click');
expect(onClick.mock.calls).toEqual([[expect.any(MouseEvent)]]);
expect(onFocusin.mock.calls).toEqual([[expect.any(FocusEvent)], [expect.any(MouseEvent)]]);
});
it.each(['mousedown', 'keyup', 'foo'])(
'does not work with any other event, like %s',
async (eventType) => {
jest.spyOn(document, 'addEventListener');
await expect(
createComponent({
template: templateWithArg(eventType),
template: `<div data-testid="outside">
<div v-outside.${eventType}="onClick" data-testid="bound"></div>
</div>`,
})
).rejects.toThrow(`Cannot bind ${eventType} events`);
expect(global.console).toHaveLoggedVueErrors();
expect(document.addEventListener).not.toHaveBeenCalled();
find('outside').trigger('click');
expect(onClick.mock.calls).toEqual([]);
}
);
});
......