Skip to content

Commit

Permalink
feat(directives): clickOutside allows to ignore elements
Browse files Browse the repository at this point in the history
  • Loading branch information
LeBenLeBen committed Nov 9, 2022
1 parent 4682191 commit 82c2e85
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 36 deletions.
9 changes: 4 additions & 5 deletions packages/chusho/lib/composables/useCachedUid.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ComponentPublicInstance, Ref, onMounted, readonly, ref } from 'vue';

import { getElement } from '../utils/components';
import { isServer } from '../utils/ssr';
import uid from '../utils/uid';

Expand All @@ -25,12 +26,10 @@ export default function useCachedUid(prefix?: string): UseCachedUid {

onMounted(() => {
if (cacheElement.value) {
// It can happen that the element holding the cache reference a Vue component
// It can happen that the element holding the cache reference is a Vue component
// in this case we’ll use it’s root element instead ($el)
const element =
(cacheElement.value as ComponentPublicInstance).$el ??
cacheElement.value;
const serverId = element.getAttribute(UID_CACHE_ATTR);
const element = getElement(cacheElement);
const serverId = element?.getAttribute(UID_CACHE_ATTR);

if (serverId) {
id.value = serverId;
Expand Down
55 changes: 49 additions & 6 deletions packages/chusho/lib/directives/clickOutside/clickOutside.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils';
import { ref } from 'vue';

import clickOutside from './clickOutside';

Expand All @@ -11,7 +12,7 @@ describe('clickOutside', () => {
beforeEach(() => {
handler = vi.fn();
component = {
template: `<button v-clickOutside="handler">Click</button>`,
template: `<button type="button" v-clickOutside="handler">Click</button>`,
methods: {
handler,
},
Expand All @@ -25,12 +26,12 @@ describe('clickOutside', () => {
});
});

it('should trigger directive when clicking outside', () => {
it('should trigger handler when clicking outside', () => {
document.body.click();
expect(handler).toHaveBeenCalledTimes(1);
});

it('should not trigger directive when clicking inside', () => {
it('should not trigger handler when clicking inside', () => {
wrapper.get('button').trigger('click');
expect(handler).not.toHaveBeenCalled();
});
Expand All @@ -44,9 +45,51 @@ describe('clickOutside', () => {
});
});

it('should warn if the hanlder is not a function', () => {
it('should not trigger handler when clicking on ignored elements', async () => {
handler = vi.fn();

component = {
template: `
<button type="button" v-clickOutside="clickOutside">Click</button>
<button type="button" ref="ignoredBtn">Ignored</button>
`,

setup() {
const ignoredBtn = ref(null);
const clickOutside = {
handler,
options: {
ignore: [ignoredBtn],
},
};

return {
clickOutside,
ignoredBtn,
};
},
};

wrapper = mount(component, {
attachTo: document.body,
global: {
directives: {
clickOutside,
},
},
});

document.body.click();
expect(handler).toHaveBeenCalledTimes(1);
await wrapper.get({ ref: 'ignoredBtn' }).trigger('click');
expect(handler).toHaveBeenCalledTimes(1);

wrapper.unmount();
});

it('should warn if the handler is not a function', () => {
component = {
template: `<button v-clickOutside="notafunction">Click</button>`,
template: `<button type="button" v-clickOutside="notafunction">Click</button>`,
computed: {
notafunction() {
return null;
Expand All @@ -68,6 +111,6 @@ describe('clickOutside', () => {

document.body.click();

expect('clickOutside value must be a Function.').toHaveBeenWarned();
expect('clickOutside handler must be a Function.').toHaveBeenWarned();
});
});
57 changes: 43 additions & 14 deletions packages/chusho/lib/directives/clickOutside/clickOutside.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,60 @@
import { DirectiveBinding, ObjectDirective } from 'vue';
import { ObjectDirective } from 'vue';

import { getElement } from '../../utils/components';
import { warn } from '../../utils/debug';

type ClickOutsideHandler = (e: MouseEvent) => void;

interface ClickOutsideOptions {
ignore?: Array<HTMLElement | SVGElement>;
}

type ClickOutsideBinding =
| ClickOutsideHandler
| { handler: ClickOutsideHandler; options: ClickOutsideOptions };

const handlers = new WeakMap();

function handleClick(e: MouseEvent, el: Element, binding: DirectiveBinding) {
const path = e.composedPath && e.composedPath();
const isClickOutside = path
? path.indexOf(el) < 0
: !el.contains(e.target as Node);

if (isClickOutside) {
if (typeof binding.value === 'function') {
binding.value(e);
} else {
warn('clickOutside value must be a Function.');
function handleClick(
e: MouseEvent,
el: HTMLElement,
handler: ClickOutsideHandler,
options?: ClickOutsideOptions
) {
const composedPath = e.composedPath();

if (!el || el === e.target || composedPath.includes(el)) {
return;
}

if (options?.ignore?.length) {
if (
options.ignore.some((target) => {
const el = getElement(target);
return e.target === el || composedPath.includes(el);
})
) {
return;
}
}

handler(e);
}

export default {
name: 'clickOutside',

mounted(el, binding): void {
handlers.set(el, (e: MouseEvent) => {
handleClick(e, el, binding);
if (typeof binding.value === 'function') {
handleClick(e, el, binding.value);
} else if (typeof binding.value?.handler === 'function') {
handleClick(e, el, binding.value.handler, binding.value.options);
} else {
warn('clickOutside handler must be a Function.');
}
});

document.addEventListener('click', handlers.get(el), {
passive: true,
});
Expand All @@ -37,4 +66,4 @@ export default {
document.removeEventListener('click', handler);
}
},
} as ObjectDirective<Element>;
} as ObjectDirective<HTMLElement, ClickOutsideBinding>;
20 changes: 19 additions & 1 deletion packages/chusho/lib/utils/components.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { PropType, Transition, TransitionProps, VNode, h } from 'vue';
import {
ComponentPublicInstance,
PropType,
Transition,
TransitionProps,
VNode,
h,
unref,
} from 'vue';

import {
ClassGenerator,
ClassGeneratorCommonCtx,
VueClassBinding,
} from '../types';
import { MaybeRef } from '../types/utils';

import { isPlainObject } from '../utils/objects';

Expand Down Expand Up @@ -54,3 +63,12 @@ export function renderWithTransition(

return props ? h(Transition, props, render) : render();
}

export function getElement<
T extends HTMLElement | SVGElement | ComponentPublicInstance | null
>(
el: MaybeRef<T>
): T extends ComponentPublicInstance ? Exclude<T, ComponentPublicInstance> : T {
const plain = unref(el);
return (plain as ComponentPublicInstance)?.$el || plain;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<template>
<CBtn ref="btnRef" @click="displaySpoiler = !displaySpoiler">
Toggle spoiler
</CBtn>

<div
v-if="displaySpoiler"
v-clickOutside="{
handler: () => (displaySpoiler = false),
options: {
ignore: [btnRef],
},
}"
>
Spoiler alert!
</div>
</template>

<script setup>
import { ref } from 'vue';
const btnRef = ref(null);
const displaySpoiler = ref(false);
</script>
58 changes: 48 additions & 10 deletions packages/docs/guide/directives/click-outside.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,62 @@

Trigger a function when a click happens outside the element with the said directive applied.

## Examples

### Simple

```vue
<template>
<CBtn v-clickOutside="handleClickOutside">Label</CBtn>
</template>
<script>
export default {
methods: {
handleClickOutside(event) {
// Do something
},
},
};
<script setup>
function handleClickOutside(event) {
// Do something
}
</script>
```

## v-clickOutside
### Ignoring elements

This is useful in the case of dynamically rendered elements. To avoid the click on the trigger from immediately removing the target due to event bubbling, the triggers must be ignored.

```vue
<template>
<CBtn ref="btnRef" @click="displaySpoiler = !displaySpoiler">
Toggle spoiler
</CBtn>
<div
v-if="displaySpoiler"
v-clickOutside="{
handler: () => (displaySpoiler = false),
options: {
ignore: [btnRef],
},
}"
>
Spoiler alert!
</div>
</template>
- **Expects:** `Function`
<script setup>
import { ref } from 'vue';
const btnRef = ref(null);
const displaySpoiler = ref(false);
</script>
```

## API

- **Expects:** `Function | { handler: Function, options: ClickOutsideOptions }`

```ts
interface ClickOutsideOptions {
// Click on or within ignored elements will not trigger the handler
ignore?: Array<HTMLElement | SVGElement>;
}
```

The function receive the original click Event as the first argument.

0 comments on commit 82c2e85

Please sign in to comment.