first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,24 @@
|
||||
# @n8n/composables
|
||||
|
||||
A collection of Vue composables that provide common functionality across n8n's Front-End packages.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
## Features
|
||||
|
||||
- **Reusable Logic**: Encapsulate complex stateful logic into composable functions.
|
||||
- **Consistency**: Ensure consistent patterns and practices across our Vue components.
|
||||
- **Extensible**: Easily add new composables as our project grows.
|
||||
- **Optimized**: Fully compatible with the Composition API.
|
||||
|
||||
## Contributing
|
||||
|
||||
For more details, please read our [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
For more details, please read our [LICENSE.md](LICENSE.md).
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "../../../../node_modules/@biomejs/biome/configuration_schema.json",
|
||||
"extends": ["../../../../biome.jsonc"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { frontendConfig } from '@n8n/eslint-config/frontend';
|
||||
|
||||
export default defineConfig(frontendConfig, {
|
||||
files: ['**/*.test.ts'],
|
||||
rules: { '@typescript-eslint/no-unsafe-assignment': 'warn' },
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@n8n/composables",
|
||||
"type": "module",
|
||||
"version": "1.14.0",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
"./*": {
|
||||
"types": "./dist/*.d.mts",
|
||||
"import": "./dist/*.mjs",
|
||||
"require": "./dist/*.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsdown --watch",
|
||||
"build": "tsdown",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:dev": "vitest --silent=false",
|
||||
"lint": "eslint src --quiet",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"format": "biome format --write . && prettier --write . --ignore-path ../../../../.prettierignore",
|
||||
"format:check": "biome ci . && prettier --check . --ignore-path ../../../../.prettierignore"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@n8n/eslint-config": "workspace:*",
|
||||
"@n8n/typescript-config": "workspace:*",
|
||||
"@n8n/vitest-config": "workspace:*",
|
||||
"@testing-library/jest-dom": "catalog:frontend",
|
||||
"@testing-library/user-event": "catalog:frontend",
|
||||
"@testing-library/vue": "catalog:frontend",
|
||||
"@vitejs/plugin-vue": "catalog:frontend",
|
||||
"@vue/tsconfig": "catalog:frontend",
|
||||
"@vueuse/core": "catalog:frontend",
|
||||
"vue": "catalog:frontend",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"vue-tsc": "catalog:frontend"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vueuse/core": "catalog:frontend",
|
||||
"vue": "catalog:frontend"
|
||||
},
|
||||
"license": "See LICENSE.md file in the root of the repository"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import '@testing-library/jest-dom';
|
||||
import { configure } from '@testing-library/vue';
|
||||
|
||||
configure({ testIdAttribute: 'data-test-id' });
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useDeviceSupport } from './useDeviceSupport';
|
||||
|
||||
const detectPointerType = (query: string) => {
|
||||
const isCoarse = query === '(any-pointer: coarse)';
|
||||
const isFine = query === '(any-pointer: fine)';
|
||||
return { fine: isFine, coarse: isCoarse };
|
||||
};
|
||||
|
||||
describe('useDeviceSupport()', () => {
|
||||
beforeEach(() => {
|
||||
global.window = Object.create(window);
|
||||
global.navigator = { userAgent: 'test-agent', maxTouchPoints: 0 } as Navigator;
|
||||
});
|
||||
|
||||
describe('isTouchDevice', () => {
|
||||
it('should be false if window matches `any-pointer: fine` and `!any-pointer: coarse`', () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
value: vi.fn().mockImplementation((query: string) => {
|
||||
const { fine, coarse } = detectPointerType(query);
|
||||
return { matches: fine && !coarse };
|
||||
}),
|
||||
});
|
||||
const { isTouchDevice } = useDeviceSupport();
|
||||
expect(isTouchDevice).toEqual(false);
|
||||
});
|
||||
|
||||
it('should be false if window matches `any-pointer: fine` and `any-pointer: coarse`', () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
value: vi.fn().mockImplementation((query: string) => {
|
||||
const { fine, coarse } = detectPointerType(query);
|
||||
return { matches: fine && coarse };
|
||||
}),
|
||||
});
|
||||
const { isTouchDevice } = useDeviceSupport();
|
||||
expect(isTouchDevice).toEqual(false);
|
||||
});
|
||||
|
||||
it('should be true if window matches `any-pointer: coarse` and `!any-pointer: fine`', () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
value: vi.fn().mockImplementation((query: string) => {
|
||||
const { fine, coarse } = detectPointerType(query);
|
||||
return { matches: coarse && !fine };
|
||||
}),
|
||||
});
|
||||
const { isTouchDevice } = useDeviceSupport();
|
||||
expect(isTouchDevice).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMacOs', () => {
|
||||
it('should be true for macOS user agent', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });
|
||||
const { isMacOs } = useDeviceSupport();
|
||||
expect(isMacOs).toEqual(true);
|
||||
});
|
||||
|
||||
it('should be false for non-macOS user agent', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
|
||||
const { isMacOs } = useDeviceSupport();
|
||||
expect(isMacOs).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('controlKeyCode', () => {
|
||||
it('should return Meta on macOS', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });
|
||||
const { controlKeyCode } = useDeviceSupport();
|
||||
expect(controlKeyCode).toEqual('Meta');
|
||||
});
|
||||
|
||||
it('should return Control on non-macOS', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
|
||||
const { controlKeyCode } = useDeviceSupport();
|
||||
expect(controlKeyCode).toEqual('Control');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMobileDevice', () => {
|
||||
it('should be true for iOS user agent', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'iphone' });
|
||||
const { isMobileDevice } = useDeviceSupport();
|
||||
expect(isMobileDevice).toEqual(true);
|
||||
});
|
||||
|
||||
it('should be true for Android user agent', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'android' });
|
||||
const { isMobileDevice } = useDeviceSupport();
|
||||
expect(isMobileDevice).toEqual(true);
|
||||
});
|
||||
|
||||
it('should be false for non-mobile user agent', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
|
||||
const { isMobileDevice } = useDeviceSupport();
|
||||
expect(isMobileDevice).toEqual(false);
|
||||
});
|
||||
|
||||
it('should be true for iPad user agent', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'ipad' });
|
||||
const { isMobileDevice } = useDeviceSupport();
|
||||
expect(isMobileDevice).toEqual(true);
|
||||
});
|
||||
|
||||
it('should be true for iPod user agent', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'ipod' });
|
||||
const { isMobileDevice } = useDeviceSupport();
|
||||
expect(isMobileDevice).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCtrlKeyPressed()', () => {
|
||||
it('should return true for metaKey press on macOS', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'macintosh' });
|
||||
const { isCtrlKeyPressed } = useDeviceSupport();
|
||||
const event = new KeyboardEvent('keydown', { metaKey: true });
|
||||
expect(isCtrlKeyPressed(event)).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return true for ctrlKey press on non-macOS', () => {
|
||||
Object.defineProperty(navigator, 'userAgent', { value: 'windows' });
|
||||
const { isCtrlKeyPressed } = useDeviceSupport();
|
||||
const event = new KeyboardEvent('keydown', { ctrlKey: true });
|
||||
expect(isCtrlKeyPressed(event)).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export function useDeviceSupport() {
|
||||
/**
|
||||
* Check if the device is a touch device but exclude devices that have a fine pointer (mouse or track-pad)
|
||||
* - `fine` will check for an accurate pointing device. Examples include mice, touch-pads, and drawing styluses
|
||||
* - `coarse` will check for a pointing device of limited accuracy. Examples include touchscreens and motion-detection sensors
|
||||
* - `any-pointer` will check for the presence of any pointing device, if there are multiple of them
|
||||
*/
|
||||
const isTouchDevice = ref(
|
||||
window.matchMedia('(any-pointer: coarse)').matches &&
|
||||
!window.matchMedia('(any-pointer: fine)').matches,
|
||||
);
|
||||
const userAgent = ref(navigator.userAgent.toLowerCase());
|
||||
|
||||
const isIOs = ref(
|
||||
userAgent.value.includes('iphone') ||
|
||||
userAgent.value.includes('ipad') ||
|
||||
userAgent.value.includes('ipod'),
|
||||
);
|
||||
const isAndroidOs = ref(userAgent.value.includes('android'));
|
||||
const isMacOs = ref(userAgent.value.includes('macintosh') || isIOs.value);
|
||||
const isMobileDevice = ref(isIOs.value || isAndroidOs.value);
|
||||
|
||||
const controlKeyCode = ref(isMacOs.value ? 'Meta' : 'Control');
|
||||
const controlKeyText = computed(() => (isMacOs.value ? '⌘' : 'Ctrl'));
|
||||
|
||||
function isCtrlKeyPressed(e: MouseEvent | KeyboardEvent): boolean {
|
||||
if (isMacOs.value) {
|
||||
return (e as KeyboardEvent).metaKey;
|
||||
}
|
||||
return (e as KeyboardEvent).ctrlKey;
|
||||
}
|
||||
|
||||
return {
|
||||
userAgent: userAgent.value,
|
||||
isTouchDevice: isTouchDevice.value,
|
||||
isAndroidOs: isAndroidOs.value,
|
||||
isIOs: isIOs.value,
|
||||
isMacOs: isMacOs.value,
|
||||
isMobileDevice: isMobileDevice.value,
|
||||
controlKeyCode: controlKeyCode.value,
|
||||
controlKeyText,
|
||||
isCtrlKeyPressed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { useDocumentTitle } from './useDocumentTitle';
|
||||
|
||||
describe('useDocumentTitle', () => {
|
||||
beforeEach(() => {
|
||||
document.title = '';
|
||||
});
|
||||
|
||||
it('should set the document title', () => {
|
||||
const { set } = useDocumentTitle();
|
||||
set('Test Title');
|
||||
expect(document.title).toBe('Test Title - n8n');
|
||||
});
|
||||
|
||||
it('should reset the document title', () => {
|
||||
const { set, reset } = useDocumentTitle();
|
||||
set('Test Title');
|
||||
reset();
|
||||
expect(document.title).toBe('Workflow Automation - n8n');
|
||||
});
|
||||
|
||||
it('should use the correct suffix for the release channel', () => {
|
||||
const { set } = useDocumentTitle({ releaseChannel: 'beta' });
|
||||
set('Test Title');
|
||||
expect(document.title).toBe('Test Title - n8n[BETA]');
|
||||
});
|
||||
|
||||
it('should use default suffix for stable release channel', () => {
|
||||
const { set } = useDocumentTitle({ releaseChannel: 'stable' });
|
||||
set('Test Title');
|
||||
expect(document.title).toBe('Test Title - n8n');
|
||||
});
|
||||
|
||||
it('should use default suffix when release channel is undefined', () => {
|
||||
const { set } = useDocumentTitle({ releaseChannel: undefined });
|
||||
set('Test Title');
|
||||
expect(document.title).toBe('Test Title - n8n');
|
||||
});
|
||||
|
||||
describe('setDocumentTitle', () => {
|
||||
it('should set document title with IDLE status', () => {
|
||||
const { setDocumentTitle } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'IDLE');
|
||||
expect(document.title).toBe('▶️ My Workflow - n8n');
|
||||
});
|
||||
|
||||
it('should set document title with EXECUTING status', () => {
|
||||
const { setDocumentTitle } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'EXECUTING');
|
||||
expect(document.title).toBe('🔄 My Workflow - n8n');
|
||||
});
|
||||
|
||||
it('should set document title with ERROR status', () => {
|
||||
const { setDocumentTitle } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'ERROR');
|
||||
expect(document.title).toBe('⚠️ My Workflow - n8n');
|
||||
});
|
||||
|
||||
it('should set document title with DEBUG status', () => {
|
||||
const { setDocumentTitle } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'DEBUG');
|
||||
expect(document.title).toBe('⚠️ My Workflow - n8n');
|
||||
});
|
||||
|
||||
it('should set document title with AI_BUILDING status', () => {
|
||||
const { setDocumentTitle } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'AI_BUILDING');
|
||||
expect(document.title).toBe('[Building] My Workflow - n8n');
|
||||
});
|
||||
|
||||
it('should set document title with AI_DONE status', () => {
|
||||
const { setDocumentTitle } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'AI_DONE');
|
||||
expect(document.title).toBe('[Done] My Workflow - n8n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDocumentState', () => {
|
||||
it('should return undefined initially', () => {
|
||||
const { getDocumentState } = useDocumentTitle();
|
||||
expect(getDocumentState()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the current state after setDocumentTitle is called', () => {
|
||||
const { setDocumentTitle, getDocumentState } = useDocumentTitle();
|
||||
setDocumentTitle('My Workflow', 'AI_BUILDING');
|
||||
expect(getDocumentState()).toBe('AI_BUILDING');
|
||||
});
|
||||
|
||||
it('should track state changes', () => {
|
||||
const { setDocumentTitle, getDocumentState } = useDocumentTitle();
|
||||
|
||||
setDocumentTitle('My Workflow', 'IDLE');
|
||||
expect(getDocumentState()).toBe('IDLE');
|
||||
|
||||
setDocumentTitle('My Workflow', 'AI_BUILDING');
|
||||
expect(getDocumentState()).toBe('AI_BUILDING');
|
||||
|
||||
setDocumentTitle('My Workflow', 'AI_DONE');
|
||||
expect(getDocumentState()).toBe('AI_DONE');
|
||||
});
|
||||
|
||||
it('should return undefined after reset is called', () => {
|
||||
const { setDocumentTitle, getDocumentState, reset } = useDocumentTitle();
|
||||
|
||||
setDocumentTitle('My Workflow', 'AI_DONE');
|
||||
expect(getDocumentState()).toBe('AI_DONE');
|
||||
|
||||
reset();
|
||||
expect(getDocumentState()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { ref, type Ref } from 'vue';
|
||||
|
||||
const DEFAULT_TITLE = 'n8n';
|
||||
const DEFAULT_TAGLINE = 'Workflow Automation';
|
||||
|
||||
export type WorkflowTitleStatus =
|
||||
| 'EXECUTING'
|
||||
| 'IDLE'
|
||||
| 'ERROR'
|
||||
| 'DEBUG'
|
||||
| 'AI_BUILDING'
|
||||
| 'AI_DONE';
|
||||
|
||||
export interface UseDocumentTitleOptions {
|
||||
/**
|
||||
* The release channel (e.g., 'stable', 'beta', 'dev').
|
||||
* If not provided or 'stable', the title will be 'n8n'.
|
||||
* Otherwise, it will be 'n8n[CHANNEL]'.
|
||||
*/
|
||||
releaseChannel?: string;
|
||||
/**
|
||||
* Optional window reference for setting the document title.
|
||||
* Useful for pop-out windows.
|
||||
*/
|
||||
windowRef?: Ref<Window | undefined>;
|
||||
}
|
||||
|
||||
export function useDocumentTitle(options: UseDocumentTitleOptions = {}) {
|
||||
const { releaseChannel, windowRef } = options;
|
||||
const suffix =
|
||||
!releaseChannel || releaseChannel === 'stable'
|
||||
? DEFAULT_TITLE
|
||||
: `${DEFAULT_TITLE}[${releaseChannel.toUpperCase()}]`;
|
||||
|
||||
const currentState = ref<WorkflowTitleStatus | undefined>(undefined);
|
||||
|
||||
const set = (title: string) => {
|
||||
const sections = [title || DEFAULT_TAGLINE, suffix];
|
||||
(windowRef?.value?.document ?? document).title = sections.join(' - ');
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
currentState.value = undefined;
|
||||
set('');
|
||||
};
|
||||
|
||||
const setDocumentTitle = (workflowName: string, status: WorkflowTitleStatus) => {
|
||||
currentState.value = status;
|
||||
let prefix = '⚠️';
|
||||
if (status === 'EXECUTING') {
|
||||
prefix = '🔄';
|
||||
} else if (status === 'IDLE') {
|
||||
prefix = '▶️';
|
||||
} else if (status === 'AI_BUILDING') {
|
||||
prefix = '[Building]';
|
||||
} else if (status === 'AI_DONE') {
|
||||
prefix = '[Done]';
|
||||
}
|
||||
set(`${prefix} ${workflowName}`);
|
||||
};
|
||||
|
||||
const getDocumentState = () => currentState.value;
|
||||
|
||||
return { set, reset, setDocumentTitle, getDocumentState };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { onKeyDown, onKeyUp } from '@vueuse/core';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useShortKeyPress } from './useShortKeyPress';
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
onKeyDown: vi.fn(),
|
||||
onKeyUp: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useShortKeyPress', () => {
|
||||
it('should call the function on short key press', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fn = vi.fn();
|
||||
const key = 'a';
|
||||
const threshold = 300;
|
||||
const disabled = ref(false);
|
||||
|
||||
useShortKeyPress(key, fn, { threshold, disabled });
|
||||
|
||||
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
|
||||
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
|
||||
|
||||
keyDownHandler(new KeyboardEvent('keydown', { key }));
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
keyUpHandler(new KeyboardEvent('keydown', { key }));
|
||||
|
||||
expect(fn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call the function if key press duration exceeds threshold', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fn = vi.fn();
|
||||
const key = 'a';
|
||||
const threshold = 300;
|
||||
const disabled = ref(false);
|
||||
|
||||
useShortKeyPress(key, fn, { threshold, disabled });
|
||||
|
||||
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
|
||||
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
|
||||
|
||||
keyDownHandler(new KeyboardEvent('keydown', { key }));
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
keyUpHandler(new KeyboardEvent('keydown', { key }));
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call the function if disabled is true', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fn = vi.fn();
|
||||
const key = 'a';
|
||||
const threshold = 300;
|
||||
const disabled = ref(true);
|
||||
|
||||
useShortKeyPress(key, fn, { threshold, disabled });
|
||||
|
||||
const keyDownHandler = vi.mocked(onKeyDown).mock.calls[0][1];
|
||||
const keyUpHandler = vi.mocked(onKeyUp).mock.calls[0][1];
|
||||
|
||||
keyDownHandler(new KeyboardEvent('keydown', { key }));
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
keyUpHandler(new KeyboardEvent('keydown', { key }));
|
||||
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { onKeyDown, onKeyUp } from '@vueuse/core';
|
||||
import type { KeyFilter } from '@vueuse/core';
|
||||
import { ref, unref } from 'vue';
|
||||
import type { MaybeRefOrGetter } from 'vue';
|
||||
|
||||
export function useShortKeyPress(
|
||||
key: KeyFilter,
|
||||
fn: () => void,
|
||||
{
|
||||
dedupe = true,
|
||||
threshold = 300,
|
||||
disabled = false,
|
||||
}: {
|
||||
dedupe?: boolean;
|
||||
threshold?: number;
|
||||
disabled?: MaybeRefOrGetter<boolean>;
|
||||
},
|
||||
) {
|
||||
const keyDownTime = ref<number | null>(null);
|
||||
|
||||
onKeyDown(
|
||||
key,
|
||||
() => {
|
||||
if (unref(disabled)) return;
|
||||
|
||||
keyDownTime.value = Date.now();
|
||||
},
|
||||
{
|
||||
dedupe,
|
||||
},
|
||||
);
|
||||
|
||||
onKeyUp(key, () => {
|
||||
if (unref(disabled) || !keyDownTime.value) return;
|
||||
|
||||
const isShortPress = Date.now() - keyDownTime.value < threshold;
|
||||
if (isShortPress) {
|
||||
fn();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
import { useThrottleWithReactiveDelay } from './useThrottleWithReactiveDelay';
|
||||
|
||||
describe(useThrottleWithReactiveDelay, () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return throttled ref with initial value', () => {
|
||||
const state = ref('initial');
|
||||
const delay = ref(100);
|
||||
const throttled = useThrottleWithReactiveDelay(state, delay);
|
||||
|
||||
expect(throttled.value).toBe('initial');
|
||||
});
|
||||
|
||||
it('should throttle state updates', async () => {
|
||||
const state = ref('initial');
|
||||
const delay = ref(100);
|
||||
const throttled = useThrottleWithReactiveDelay(state, delay);
|
||||
|
||||
state.value = 'updated';
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe('initial');
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe('updated');
|
||||
});
|
||||
|
||||
it('should respect reactive delay changes', async () => {
|
||||
const state = ref('initial');
|
||||
const delay = ref(100);
|
||||
const throttled = useThrottleWithReactiveDelay(state, delay);
|
||||
|
||||
delay.value = 200;
|
||||
state.value = 'updated';
|
||||
await nextTick();
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe('initial');
|
||||
|
||||
vi.advanceTimersByTime(100);
|
||||
await nextTick();
|
||||
expect(throttled.value).toBe('updated');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { shallowRef, watch, type Ref, type ShallowRef } from 'vue';
|
||||
|
||||
/**
|
||||
* Similar to `useThrottle` from @vueuse/core, but with changeable delay
|
||||
*/
|
||||
export function useThrottleWithReactiveDelay<T>(state: Ref<T>, delay: Ref<number>): ShallowRef<T> {
|
||||
const throttled = shallowRef(state.value);
|
||||
|
||||
watch(
|
||||
state,
|
||||
useThrottleFn(
|
||||
(latest: T) => {
|
||||
throttled.value = latest;
|
||||
},
|
||||
delay,
|
||||
true,
|
||||
true,
|
||||
),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return throttled;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "@n8n/typescript-config/tsconfig.frontend.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"rootDir": ".",
|
||||
"outDir": "dist",
|
||||
"types": ["vite/client", "vitest/globals"],
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "tsdown.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
// eslint-disable-next-line import-x/no-default-export
|
||||
export default defineConfig({
|
||||
entry: ['src/**/*.ts', '!src/**/*.test.ts', '!src/**/*.d.ts', '!src/__tests__/**/*'],
|
||||
format: ['cjs', 'esm'],
|
||||
clean: true,
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
hash: false,
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineConfig, mergeConfig } from 'vite';
|
||||
import { vitestConfig } from '@n8n/vitest-config/frontend';
|
||||
|
||||
export default mergeConfig(defineConfig({}), vitestConfig);
|
||||
Reference in New Issue
Block a user