From 4ae4465d27d4e63d5beae645b8e3578fa49e5743 Mon Sep 17 00:00:00 2001 From: trydofor Date: Sat, 28 Dec 2024 11:45:54 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20onClickOut=20or=20onEscKey=20to=20h?= =?UTF-8?q?andle=20modal=20#137?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/itchy-lizards-talk.md | 6 ++ layers/common/composables/UseApiRoute.ts | 12 +-- .../common/composables/UseClickoutKeydown.ts | 44 +++++++++++ .../common/tests/UseClickoutKeydown.test.ts | 77 +++++++++++++++++++ layers/common/tests/safe-converter.test.ts | 77 +------------------ layers/common/utils/safe-converter.ts | 30 +------- 6 files changed, 133 insertions(+), 113 deletions(-) create mode 100644 .changeset/itchy-lizards-talk.md create mode 100644 layers/common/composables/UseClickoutKeydown.ts create mode 100644 layers/common/tests/UseClickoutKeydown.test.ts diff --git a/.changeset/itchy-lizards-talk.md b/.changeset/itchy-lizards-talk.md new file mode 100644 index 0000000..cf1aca5 --- /dev/null +++ b/.changeset/itchy-lizards-talk.md @@ -0,0 +1,6 @@ +--- +"@fessional/razor-common": patch +"@fessional/razor-mobile": patch +--- + +✨ onClickOut or onEscKey to handle modal #137 diff --git a/layers/common/composables/UseApiRoute.ts b/layers/common/composables/UseApiRoute.ts index 9636563..a218a94 100644 --- a/layers/common/composables/UseApiRoute.ts +++ b/layers/common/composables/UseApiRoute.ts @@ -92,14 +92,10 @@ export function useApiRoute(ops?: ApiRouteOptions) { const opt = { ...ops, ...op }; - const oq = flatArray(ops.onRequest, op?.onRequest); - if (oq.length >= 1) opt.onRequest = oq; - const oqe = flatArray(ops.onRequestError, op?.onRequestError); - if (oqe.length >= 1) opt.onRequestError = oqe; - const or = flatArray(ops.onResponse, op?.onResponse); - if (or.length >= 1) opt.onResponse = or; - const ore = flatArray(ops.onResponseError, op?.onResponseError); - if (ore.length >= 1) opt.onResponseError = ore; + opt.onRequest = flatArray(ops.onRequest, op?.onRequest); + opt.onRequestError = flatArray(ops.onRequestError, op?.onRequestError); + opt.onResponse = flatArray(ops.onResponse, op?.onResponse); + opt.onResponseError = flatArray(ops.onResponseError, op?.onResponseError); return opt; } diff --git a/layers/common/composables/UseClickoutKeydown.ts b/layers/common/composables/UseClickoutKeydown.ts new file mode 100644 index 0000000..a6e12c7 --- /dev/null +++ b/layers/common/composables/UseClickoutKeydown.ts @@ -0,0 +1,44 @@ +import type { KeyFilter, MaybeElementRef } from '@vueuse/core'; +import { onKeyDown, onClickOutside } from '@vueuse/core'; + +/** + * listen PointerEvent or KeyboardEvent + * + * - click outside of the element + * - keydown, default `Escape` + */ +export function useClickoutKeydown(eleRef: MaybeElementRef, keys: KeyFilter = 'Escape', handler: (evt: PointerEvent | KeyboardEvent) => void) { + let clickoutStub: (() => void) | null = null; + let keydownStub: (() => void) | null = null; + + function active(force: boolean = false) { + if (clickoutStub == null) { + clickoutStub = onClickOutside(eleRef, handler); + } + else if (force) { + clickoutStub(); + clickoutStub = onClickOutside(eleRef, handler); + } + + if (keydownStub == null) { + keydownStub = onKeyDown(keys, handler); + } + else if (force) { + keydownStub(); + keydownStub = onKeyDown(keys, handler); + } + } + + function inactive() { + if (clickoutStub != null) { + clickoutStub(); + clickoutStub = null; + } + if (keydownStub != null) { + keydownStub(); + keydownStub = null; + } + } + + return { active, inactive }; +} diff --git a/layers/common/tests/UseClickoutKeydown.test.ts b/layers/common/tests/UseClickoutKeydown.test.ts new file mode 100644 index 0000000..49018b9 --- /dev/null +++ b/layers/common/tests/UseClickoutKeydown.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ref } from 'vue'; +import { onClickOutside, onKeyDown } from '@vueuse/core'; +import { useClickoutKeydown } from '../composables/UseClickoutKeydown'; + +vi.mock('@vueuse/core', () => ({ + onClickOutside: vi.fn((eleRef, handler) => { + const callback = (evt: MouseEvent) => handler(evt); + document.addEventListener('click', callback); + return () => document.removeEventListener('click', callback); + }), + onKeyDown: vi.fn((keys, handler) => { + const callback = (evt: KeyboardEvent) => handler(evt); + document.addEventListener('keydown', callback); + return () => document.removeEventListener('keydown', callback); + }), +})); + +describe('useClickoutKeydown', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should activate and listen for click outside events', () => { + const handler = vi.fn(); + const eleRef = ref(document.createElement('div')); + const { active, inactive } = useClickoutKeydown(eleRef, 'Escape', handler); + + active(); + + // Simulate click outside + const event = new MouseEvent('click', { bubbles: true }); + document.dispatchEvent(event); + + expect(onClickOutside).toHaveBeenCalledWith(eleRef, expect.any(Function)); + expect(handler).toHaveBeenCalledWith(event); + + inactive(); + + // Simulate click after inactive + document.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(handler).toHaveBeenCalledTimes(1); // No additional calls + }); + + it('should activate and listen for keydown events', () => { + const handler = vi.fn(); + const eleRef = ref(document.createElement('div')); + const { active, inactive } = useClickoutKeydown(eleRef, 'Escape', handler); + + active(); + + // Simulate keydown + const event = new KeyboardEvent('keydown', { key: 'Escape' }); + document.dispatchEvent(event); + + expect(onKeyDown).toHaveBeenCalledWith('Escape', expect.any(Function)); + expect(handler).toHaveBeenCalledWith(event); + + inactive(); + + // Simulate keydown after inactive + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(handler).toHaveBeenCalledTimes(1); // No additional calls + }); + + it('should force rebind listeners when active is called with force=true', () => { + const handler = vi.fn(); + const eleRef = ref(document.createElement('div')); + const { active } = useClickoutKeydown(eleRef, 'Escape', handler); + + active(); + active(true); // Force rebinding + + expect(onClickOutside).toHaveBeenCalledTimes(2); + expect(onKeyDown).toHaveBeenCalledTimes(2); + }); +}); diff --git a/layers/common/tests/safe-converter.test.ts b/layers/common/tests/safe-converter.test.ts index 574d58b..474fc1d 100644 --- a/layers/common/tests/safe-converter.test.ts +++ b/layers/common/tests/safe-converter.test.ts @@ -2,7 +2,7 @@ import { safeString, safeNumber, safeInt, safeBigint, safeBoolean, safeBoolTof, safeBoolNum, safeValues, safeKeys, safeEntries, safeJson, safeObjMap, safeArrSet, safeMapObj, safeSetArr, safeArray, safeValue, safeConvert, - flatArray, flatObject, mergeObject } from '../utils/safe-converter'; + flatArray } from '../utils/safe-converter'; describe('safeConvert', () => { it('should return converted value when input is valid', () => { @@ -627,78 +627,3 @@ describe('flatArray', () => { expect(result).toEqual([]); }); }); - -describe('flatObject', () => { - it('should flatten objects with scalar values', () => { - const result = flatObject('key', { key: 1 }, { key: 2 }, { key: 3 }); - expect(result).toEqual([1, 2, 3]); - }); - - it('should flatten objects with array values', () => { - const result = flatObject('key', { key: [1, 2] }, { key: [3, 4] }); - expect(result).toEqual([1, 2, 3, 4]); - }); - - it('should handle mixed scalar and array values', () => { - const result = flatObject('key', { key: 1 }, { key: [2, 3] }, { key: 4 }); - expect(result).toEqual([1, 2, 3, 4]); - }); - - it('should handle objects with null or undefined values', () => { - const result = flatObject('key', { key: 1 }, { key: null }, { key: undefined }, { key: [2, null] }); - expect(result).toEqual([1, 2]); - }); - - it('should return an empty array if no objects are provided', () => { - const result = flatObject('key'); - expect(result).toEqual([]); - }); - - it('should handle an empty array value', () => { - const result = flatObject('key', { key: [] }, { key: [1, 2] }); - expect(result).toEqual([1, 2]); - }); - - it('should handle an object key with nested arrays', () => { - const result = flatObject('key', { key: [1, [2, 3]] }); - expect(result).toEqual([1, [2, 3]]); // flatObject does not deeply flatten - }); -}); - -describe('mergeObject', () => { - it('should merge scalar values into an object property', () => { - const obj = { key: 1 }; - mergeObject('key', obj, { key: 2 }, { key: 3 }); - expect(obj).toEqual({ key: [1, 2, 3] }); - }); - - it('should merge array values into an object property', () => { - const obj = { key: [1, 2] }; - mergeObject('key', obj, { key: [3, 4] }); - expect(obj).toEqual({ key: [1, 2, 3, 4] }); - }); - - it('should merge mixed scalar and array values', () => { - const obj = { key: 1 }; - mergeObject('key', obj, { key: [2, 3] }, { key: 4 }); - expect(obj).toEqual({ key: [1, 2, 3, 4] }); - }); - - it('should update object property to scalar if only one value remains', () => { - const obj = { key: [1] }; - mergeObject('key', obj, { key: null }); - expect(obj).toEqual({ key: 1 }); - }); - - it('should handle null or undefined values gracefully', () => { - const obj = { key: 1 }; - mergeObject('key', obj, { key: null }, { key: undefined }); - expect(obj).toEqual({ key: 1 }); - }); - - it('should not modify the object if no additional values are provided', () => { - const obj = { key: 1 }; - mergeObject('key', obj); - expect(obj).toEqual({ key: 1 }); - }); -}); diff --git a/layers/common/utils/safe-converter.ts b/layers/common/utils/safe-converter.ts index fd89590..700aac4 100644 --- a/layers/common/utils/safe-converter.ts +++ b/layers/common/utils/safe-converter.ts @@ -336,7 +336,7 @@ export function safeSetArr(set?: Maybe>): T[] { } /** - * flat value T (not array) or its array to array, + * flat value T or its array to array, * - `1, [2, 3], 4 => [1, 2, 3, 4]` * - `[], [1, 2], [undefined], 3 => [1, 2, 3]` */ @@ -354,31 +354,3 @@ export function flatArray(...items: Array): T[] { } return arr; } - -/** - * flat obj's property T (not array) by its key to array - * - size = 0, key or value is null - * - size = 1, property should be T - * - size > 1, property should be T[] - */ -export function flatObject(key: string, ...items: Array<{ [K in typeof key]?: T | T[] }>): T[] { - const its = items.map(it => it?.[key]).filter(it => it != null); - return flatArray(...its); -} - -/** - * merge obj's property T (not array) with items by key - * - size = 0, key or value is null - * - size = 1, property should be T - * - size > 1, property should be T[] - */ -export function mergeObject(key: string, obj: { [K in typeof key]?: T | T[] }, ...items: Array<{ [K in typeof key]?: T | T[] }>) { - const its = items.map(it => it?.[key]).filter(it => it != null); - const arr = flatArray(obj[key], ...its); - if (arr.length == 1) { - obj[key] = arr[0]; - } - else if (arr.length > 1) { - obj[key] = arr; - } -}