├── .eslintrc.js
├── .github
├── README.md
└── workflows
│ ├── e2e.yml
│ └── validate.yml
├── .gitignore
├── .prettierignore
├── README.md
├── e2e
├── command-palette-basic.spec.ts
├── demo-page.spec.ts
└── testUtils
│ └── checkMac.ts
├── index.html
├── package-lock.json
├── package.json
├── playwright.config.ts
├── prettier.config.js
├── public
├── images
│ ├── branding
│ │ ├── logo-dark-horizontal-large.png
│ │ ├── logo-dark-horizontal.png
│ │ ├── logo-dark-stamp.svg
│ │ ├── logo-dark-vertical-large.png
│ │ ├── logo-dark-vertical.png
│ │ ├── logo-light-favicon.png
│ │ ├── logo-light-horizontal-large.png
│ │ ├── logo-light-horizontal.png
│ │ ├── logo-light-stamp.svg
│ │ ├── logo-light-vertical-large.png
│ │ └── logo-light-vertical.png
│ ├── command-palette-examples
│ │ ├── devtools.png
│ │ ├── github.png
│ │ ├── linear.png
│ │ └── tailwind.png
│ └── demo-minimal.gif
└── snippets
│ ├── api-command-palette.html
│ ├── api-define-action.html
│ ├── api-root.html
│ ├── installation-define-actions.html
│ ├── installation-integrate-components.html
│ ├── installation-packages.html
│ └── installation-setup.html
├── scripts
└── generateSnippets.ts
├── snippets
├── api-command-palette.snippet.tsx
├── api-define-action.snippet.tsx
├── api-root.snippet.tsx
├── installation-define-actions.snippet.tsx
├── installation-integrate-components.snippet.tsx
└── installation-packages.snippet.sh
├── src
├── app
│ ├── App.tsx
│ ├── index.tsx
│ ├── shared
│ │ └── Loader
│ │ │ ├── Loader.module.css
│ │ │ └── Loader.tsx
│ ├── utils.module.css
│ └── views
│ │ ├── app
│ │ ├── AppShell.module.css
│ │ ├── AppShell.view.tsx
│ │ ├── SocialIcons.tsx
│ │ └── actions.ts
│ │ ├── demo
│ │ ├── CustomComponentsDemo
│ │ │ ├── CustomComponentsDemo.module.css
│ │ │ ├── CustomComponentsDemo.tsx
│ │ │ └── components.ts
│ │ ├── Demo.module.css
│ │ ├── Demo.view.tsx
│ │ ├── DynamicActionContextDemo
│ │ │ ├── DynamicActionContextDemo.module.css
│ │ │ ├── DynamicActionContextDemo.tsx
│ │ │ ├── data.ts
│ │ │ ├── dynamicContextActions.ts
│ │ │ └── types.ts
│ │ ├── NestedActionDemo
│ │ │ ├── NestedActionDemo.module.css
│ │ │ ├── NestedActionDemo.tsx
│ │ │ └── nestedActions.ts
│ │ ├── actions.ts
│ │ ├── demoUtils.module.css
│ │ └── types.ts
│ │ ├── docs
│ │ ├── Api
│ │ │ ├── Api.view.tsx
│ │ │ ├── ApiCommandPalette.view.tsx
│ │ │ ├── ApiDefineAction.view.tsx
│ │ │ └── ApiRoot.view.tsx
│ │ ├── Docs.view.tsx
│ │ ├── DocsShell
│ │ │ ├── DocsShell.module.css
│ │ │ └── DocsShell.view.tsx
│ │ ├── Snippet
│ │ │ ├── Snippet.module.css
│ │ │ └── Snippet.tsx
│ │ ├── docsUtils.module.css
│ │ └── introduction
│ │ │ ├── Installation.view.tsx
│ │ │ └── Overview.view.tsx
│ │ └── home
│ │ ├── Home.module.css
│ │ ├── Home.view.tsx
│ │ └── exampleSlider
│ │ ├── ExampleSlider.module.css
│ │ ├── ExampleSlider.tsx
│ │ └── data.ts
└── lib
│ ├── CommandPalette.module.css
│ ├── CommandPalette.tsx
│ ├── CommandPalettePortal.tsx
│ ├── KbdShortcut
│ ├── KbdShortcut.module.css
│ ├── KbdShortcut.tsx
│ ├── types.ts
│ ├── utils.test.ts
│ ├── utils.ts
│ ├── utilsMac.test.ts
│ └── utilsWin.test.ts
│ ├── Panel
│ ├── Footer
│ │ ├── Footer.module.css
│ │ └── Footer.tsx
│ └── Result
│ │ ├── Result.module.css
│ │ └── Result.tsx
│ ├── Root.tsx
│ ├── ScrollAssist
│ ├── ScrollAssist.module.css
│ └── ScrollAssist.tsx
│ ├── StoreContext.tsx
│ ├── actionUtils
│ ├── actionUtils.test.ts
│ └── actionUtils.ts
│ ├── constants.ts
│ ├── createActionList.ts
│ ├── createKbdShortcuts.ts
│ ├── createSyncActionsContext.ts
│ ├── defineAction.ts
│ ├── index.ts
│ ├── types.ts
│ ├── useControls.ts
│ └── utils.module.css
├── tsconfig-node.json
├── tsconfig.json
├── vercel.json
├── vite-pkg.config.ts
└── vite.config.ts
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | },
6 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
7 | parser: '@typescript-eslint/parser',
8 | parserOptions: {
9 | ecmaVersion: 'latest',
10 | },
11 | plugins: ['@typescript-eslint'],
12 | rules: {
13 | '@typescript-eslint/no-unused-vars': [
14 | 'error',
15 | {
16 | varsIgnorePattern: '^_',
17 | argsIgnorePattern: '^_',
18 | },
19 | ],
20 | },
21 | };
22 |
--------------------------------------------------------------------------------
/.github/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Boost your users productivity by 10x 🚀
10 |
11 |
12 |
13 | ### Some of the features offered by this library-
14 |
15 | 1. Define actions with a simple config.
16 | 1. Full keyboard support like open with CMD + K , navigate between actions using arrow keys.
17 | 1. Fuzzy search between your actions on title, subtile, keywords.
18 | 1. Bind custom keyboard shortcuts to your actions. They can be single letter, modifier combinations Shift + l or sequences g p .
19 | 1. Enable actions based on dynamic conditions.
20 | 1. Share your app state and methods to run any kind of functionality from actions.
21 | 1. Full static type safety across the board.
22 |
23 | ## Demo
24 |
25 | 
26 |
27 | Try the full demo on [our documentation site](https://solid-command-palette.vercel.app/demo).
28 |
29 | ## Usage
30 |
31 | #### Install the library
32 |
33 | ```sh
34 | # Core Library
35 | npm install solid-command-palette
36 |
37 | # Peer Dependencies
38 | npm install solid-transition-group tinykeys fuse.js
39 | ```
40 |
41 | - [solid-transition-group](https://github.com/solidjs/solid-transition-group) (1.6KB): provides advanced animation support. It's the official recommendation from SolidJS team so you might be using it already.
42 | - [tinykeys](https://github.com/jamiebuilds/tinykeys) (700B): provides keyboard shortcut support. You can use this in your app for all kinds of keybindings.
43 | - [fuse.js](https://github.com/krisk/fuse) (5KB): provides fuzzy search support to find relevant actions.
44 |
45 | #### Integrate with app
46 |
47 | ```jsx
48 | // define actions in one module say `actions.ts`
49 |
50 | import { defineAction } from 'solid-command-palette';
51 |
52 | const minimalAction = defineAction({
53 | id: 'minimal',
54 | title: 'Minimal Action',
55 | run: () => {
56 | console.log('ran minimal action');
57 | },
58 | });
59 |
60 | const incrementCounterAction = defineAction({
61 | id: 'increment-counter',
62 | title: 'Increment Counter by 1',
63 | subtitle: 'Press CMD + E to trigger this.',
64 | shortcut: '$mod+e', // $mod = Command on Mac & Control on Windows.
65 | run: ({ rootContext }) => {
66 | rootContext.increment();
67 | },
68 | });
69 |
70 | export const actions = {
71 | [minimalAction.id]: minimalAction,
72 | [incrementCounterAction.id]: incrementCounterAction,
73 | };
74 | ```
75 |
76 | ```jsx
77 | // render inside top level Solid component
78 |
79 | import { Root, CommandPalette } from 'solid-command-palette';
80 | import { actions } from './actions';
81 | import 'solid-command-palette/pkg-dist/style.css';
82 |
83 | const App = () => {
84 | const actionsContext = {
85 | increment() {
86 | console.log('increment count state by 1');
87 | },
88 | };
89 |
90 | return (
91 |
92 |
96 |
97 |
98 |
99 | );
100 | };
101 | ```
102 |
--------------------------------------------------------------------------------
/.github/workflows/e2e.yml:
--------------------------------------------------------------------------------
1 | name: E2E Tests
2 |
3 | on: [deployment_status]
4 |
5 | jobs:
6 | test:
7 | timeout-minutes: 60
8 | runs-on: ubuntu-latest
9 | if: github.event.deployment_status.state == 'success'
10 | steps:
11 | - name: Checkout commit
12 | uses: actions/checkout@v2
13 | - name: Setup Node.js
14 | uses: actions/setup-node@v2
15 | with:
16 | node-version: 14
17 | - name: Install Dependencies
18 | run: npm ci
19 | - name: Install Playwright
20 | run: npx playwright install --with-deps
21 | - name: Run E2E tests
22 | run: npm run test:e2e:run
23 | env:
24 | PLAYWRIGHT_TEST_BASE_URL: ${{ github.event.deployment_status.target_url }}
25 | - name: Save Test Results
26 | uses: actions/upload-artifact@v2
27 | if: always()
28 | with:
29 | name: playwright-report
30 | path: playwright-report/
31 | retention-days: 2
32 |
--------------------------------------------------------------------------------
/.github/workflows/validate.yml:
--------------------------------------------------------------------------------
1 | name: Validate
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | validate:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout commit
14 | uses: actions/checkout@v2
15 | - name: Setup Node.js
16 | uses: actions/setup-node@v2
17 | with:
18 | node-version: 14
19 | cache: 'npm'
20 | - name: Install Dependencies
21 | run: npm ci
22 | - name: Check for linting issues
23 | run: npm run lint
24 | - name: Run functional tests
25 | run: npm run test:func:run
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .vercel
3 | dist
4 | pkg-dist
5 | coverage
6 | .DS_Store
7 | test-results/
8 | playwright-report/
9 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .vercel
3 | dist
4 | public
5 | pkg-dist
6 | coverage
7 | .DS_Store
8 | test-results/
9 | playwright-report/
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Boost your users productivity by 10x 🚀
9 |
10 |
11 |
12 | ### Some of the features offered by this library-
13 |
14 | 1. Define actions with a simple config.
15 | 1. Full keyboard support like open with CMD + K , navigate between actions using arrow keys.
16 | 1. Fuzzy search between your actions on title, subtile, keywords.
17 | 1. Bind custom keyboard shortcuts to your actions. They can be single letter, modifier combinations Shift + l or sequences g p .
18 | 1. Enable actions based on dynamic conditions.
19 | 1. Share your app state and methods to run any kind of functionality from actions.
20 | 1. Full static type safety across the board.
21 |
22 | ## Demo
23 |
24 | 
25 |
26 | Try the full demo on [our documentation site](https://solid-command-palette.vercel.app/demo).
27 |
28 | ## Usage
29 |
30 | #### Install the library
31 |
32 | ```sh
33 | # Core Library
34 | npm install solid-command-palette
35 |
36 | # Peer Dependencies
37 | npm install solid-transition-group tinykeys fuse.js
38 | ```
39 |
40 | - [solid-transition-group](https://github.com/solidjs/solid-transition-group) (1.6KB): provides advanced animation support. It's the official recommendation from SolidJS team so you might be using it already.
41 | - [tinykeys](https://github.com/jamiebuilds/tinykeys) (700B): provides keyboard shortcut support. You can use this in your app for all kinds of keybindings.
42 | - [fuse.js](https://github.com/krisk/fuse) (5KB): provides fuzzy search support to find relevant actions.
43 |
44 | #### Integrate with app
45 |
46 | ```jsx
47 | // define actions in one module say `actions.ts`
48 |
49 | import { defineAction } from 'solid-command-palette';
50 |
51 | const minimalAction = defineAction({
52 | id: 'minimal',
53 | title: 'Minimal Action',
54 | run: () => {
55 | console.log('ran minimal action');
56 | },
57 | });
58 |
59 | const incrementCounterAction = defineAction({
60 | id: 'increment-counter',
61 | title: 'Increment Counter by 1',
62 | subtitle: 'Press CMD + E to trigger this.',
63 | shortcut: '$mod+e', // $mod = Command on Mac & Control on Windows.
64 | run: ({ rootContext }) => {
65 | rootContext.increment();
66 | },
67 | });
68 |
69 | export const actions = {
70 | [minimalAction.id]: minimalAction,
71 | [incrementCounterAction.id]: incrementCounterAction,
72 | };
73 | ```
74 |
75 | ```jsx
76 | // render inside top level Solid component
77 |
78 | import { Root, CommandPalette } from 'solid-command-palette';
79 | import { actions } from './actions';
80 | import 'solid-command-palette/pkg-dist/style.css';
81 |
82 | const App = () => {
83 | const actionsContext = {
84 | increment() {
85 | console.log('increment count state by 1');
86 | },
87 | };
88 |
89 | return (
90 |
91 |
95 |
96 |
97 |
98 | );
99 | };
100 | ```
101 |
--------------------------------------------------------------------------------
/e2e/command-palette-basic.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect, Page } from '@playwright/test';
2 | import { checkMac } from './testUtils/checkMac';
3 |
4 | async function triggerCommandPaletteOpen(page: Page) {
5 | const isMac = await checkMac(page);
6 |
7 | if (isMac) {
8 | await page.keyboard.press('Meta+k');
9 | } else {
10 | await page.keyboard.press('Control+k');
11 | }
12 | }
13 |
14 | test.describe('Test basic interactions of Command Palette', () => {
15 | test('should be able to open command palette & run first action', async ({ page }) => {
16 | await page.goto('/demo');
17 | await triggerCommandPaletteOpen(page);
18 |
19 | await page.click('text=Increment Counter by 1');
20 | await expect(page.locator('strong >> text=1')).toBeVisible();
21 | });
22 |
23 | test('should be able to search for actions in command palette', async ({ page }) => {
24 | await page.goto('/demo');
25 | await triggerCommandPaletteOpen(page);
26 |
27 | await page.keyboard.type('GitHub');
28 |
29 | const searchLocator = page.locator('input[type="search"]');
30 | await expect(searchLocator).toHaveValue('GitHub');
31 |
32 | const optionLocator = page.locator('[role="combobox"] >> [role="option"]');
33 |
34 | const optionsNum = await optionLocator.count();
35 | expect(optionsNum).toBe(1);
36 |
37 | await expect(optionLocator.first()).toContainText('Go to GitHub repo');
38 | });
39 |
40 | test('should be able to navigate between actions using keyboard', async ({ page }) => {
41 | await page.goto('/demo');
42 | await triggerCommandPaletteOpen(page);
43 |
44 | // Navigate to third action by pressing Down key twice.
45 | await page.keyboard.press('ArrowDown');
46 | await page.keyboard.press('ArrowDown');
47 |
48 | const optionLocator = page.locator('[role="combobox"] >> [role="option"]');
49 | let isThirdOptionSelected = await optionLocator.nth(2).getAttribute('aria-selected');
50 |
51 | expect(isThirdOptionSelected).toEqual('true');
52 |
53 | // Navigate to second action by pressing Up key once.
54 | await page.keyboard.press('ArrowUp');
55 |
56 | isThirdOptionSelected = await optionLocator.nth(2).getAttribute('aria-selected');
57 | const isSecondOptionSelected = await optionLocator.nth(1).getAttribute('aria-selected');
58 |
59 | expect(isThirdOptionSelected).toEqual('false');
60 | expect(isSecondOptionSelected).toEqual('true');
61 | });
62 |
63 | test('should only render Unmute Audio action when muted', async ({ page }) => {
64 | await page.goto('/demo');
65 | await triggerCommandPaletteOpen(page);
66 |
67 | await expect(page.locator('[role="combobox"]')).not.toContainText('Unmute');
68 |
69 | await page.keyboard.press('Escape');
70 | await page.check('label >> text=Audible');
71 | await triggerCommandPaletteOpen(page);
72 | await page.click('[role="combobox"] >> text=Unmute');
73 |
74 | const isUnmuted = await page.isChecked('label >> text=Audible');
75 | expect(isUnmuted).toBeFalsy();
76 | });
77 |
78 | test('should be able to run nested actions', async ({ page }) => {
79 | await page.goto('/demo');
80 | await triggerCommandPaletteOpen(page);
81 |
82 | await page.keyboard.type('Profile');
83 |
84 | const optionLocator = page.locator('[role="combobox"] >> [role="option"]');
85 |
86 | await optionLocator.locator('text=Set profile').click();
87 |
88 | const searchLocator = page.locator('input[type="search"]');
89 | await expect(searchLocator).toHaveValue('');
90 |
91 | const optionsNum = await optionLocator.count();
92 | expect(optionsNum).toBe(2);
93 |
94 | await optionLocator.locator('text=Set to Work profile').click();
95 |
96 | const profileStatusLocator = page
97 | .locator('section', {
98 | hasText: 'Nested actions',
99 | })
100 | .last()
101 | .locator('text=Active profile is work');
102 |
103 | await expect(profileStatusLocator).toBeVisible();
104 | });
105 | });
106 |
--------------------------------------------------------------------------------
/e2e/demo-page.spec.ts:
--------------------------------------------------------------------------------
1 | import { test, expect } from '@playwright/test';
2 | import { checkMac } from './testUtils/checkMac';
3 |
4 | test.describe('Test basic interactions of Demo Page', () => {
5 | test.beforeEach(async ({ page }) => {
6 | await page.goto('/demo');
7 | });
8 |
9 | test('should be able to increment count', async ({ page }) => {
10 | await page.locator('button >> text=Increment Count').click();
11 | await expect(page.locator('strong >> text=1')).toBeVisible();
12 | });
13 |
14 | test('should be able to toggle mute', async ({ page }) => {
15 | const unmuteLabelLocator = page.locator('label >> text=Audible');
16 | const muteLabelLocator = page.locator('label >> text=Muted');
17 |
18 | const isMac = await checkMac(page);
19 | const shortcutPrefix = isMac ? '⌘' : 'Ctrl';
20 | const kbdShortcutLocator = page.locator(`kbd >> text=${shortcutPrefix}U`).first();
21 |
22 | await unmuteLabelLocator.check();
23 | await expect(muteLabelLocator).toBeChecked();
24 | await expect(kbdShortcutLocator).toBeVisible();
25 |
26 | await muteLabelLocator.uncheck();
27 | await expect(unmuteLabelLocator).toBeChecked({
28 | checked: false,
29 | });
30 | await expect(kbdShortcutLocator).not.toBeVisible();
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/e2e/testUtils/checkMac.ts:
--------------------------------------------------------------------------------
1 | import { Page } from '@playwright/test';
2 |
3 | export async function checkMac(page: Page) {
4 | const platform = await page.evaluate(() => window.navigator.platform);
5 | const isMac = platform.includes('Mac');
6 |
7 | return isMac;
8 | }
9 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
13 |
17 |
21 |
26 |
30 | Solid Command Palette
31 |
32 |
33 |
34 | You need to enable JavaScript to run this app.
35 |
36 |
37 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "solid-command-palette",
3 | "version": "1.0.0",
4 | "description": "Add a command palette to your Solid.js App",
5 | "info": "Boost your users productivity 10x by integrating our command palette.",
6 | "main": "./pkg-dist/solid-command-palette.cjs.js",
7 | "module": "./pkg-dist/solid-command-palette.es.js",
8 | "types": "./pkg-dist/types",
9 | "sideEffects": false,
10 | "scripts": {
11 | "dev": "vite",
12 | "build": "vite build",
13 | "serve": "vite preview",
14 | "lint:format": "prettier --check .",
15 | "lint:format:fix": "prettier --write .",
16 | "lint:types": "tsc --noEmit",
17 | "lint:types:watch": "npm run lint:types -- --watch",
18 | "lint:syntax": "eslint --ext '.ts,.tsx' 'src/'",
19 | "lint:syntax:fix": "npm run lint:syntax -- --fix",
20 | "lint": "npm run lint:format && npm run lint:syntax && npm run lint:types",
21 | "test:func": "vitest src",
22 | "test:func:run": "vitest run src",
23 | "test:e2e": "playwright test --headed --project='chromium'",
24 | "test:e2e:run": "playwright test",
25 | "test:e2e:open": "playwright open 'http://localhost:3000'",
26 | "test:e2e:list": "playwright test --list",
27 | "gen:types": "tsc --emitDeclarationOnly",
28 | "gen:lib": "vite build -c vite-pkg.config.ts",
29 | "gen:snippets": "ts-node -P tsconfig-node.json ./scripts/generateSnippets.ts",
30 | "prepublishOnly": "npm run gen:lib && npm run gen:types"
31 | },
32 | "license": "MIT",
33 | "devDependencies": {
34 | "@playwright/test": "^1.19.0",
35 | "@stackblitz/sdk": "^1.6.0",
36 | "@types/node": "^17.0.21",
37 | "@typescript-eslint/eslint-plugin": "^5.14.0",
38 | "@typescript-eslint/parser": "^5.14.0",
39 | "c8": "^7.11.0",
40 | "eslint": "^8.8.0",
41 | "fuse.js": "^6.5.3",
42 | "happy-dom": "^2.31.1",
43 | "prettier": "2.6.0",
44 | "shiki": "^0.10.1",
45 | "solid-app-router": "^0.2.1",
46 | "solid-js": "^1.3.2",
47 | "solid-transition-group": "0.0.8",
48 | "tinykeys": "^1.4.0",
49 | "ts-node": "^10.5.0",
50 | "typescript": "^4.6.2",
51 | "vite": "^2.7.10",
52 | "vite-plugin-solid": "^2.2.1",
53 | "vitest": "^0.3.2"
54 | },
55 | "peerDependencies": {
56 | "fuse.js": "^6.5.3",
57 | "solid-js": "^1.3.2",
58 | "solid-transition-group": "0.0.8",
59 | "tinykeys": "^1.4.0"
60 | },
61 | "files": [
62 | "pkg-dist"
63 | ],
64 | "homepage": "https://solid-command-palette.vercel.app/",
65 | "repository": {
66 | "type": "git",
67 | "url": "github:itaditya/solid-command-palette"
68 | },
69 | "bugs": "https://github.com/itaditya/solid-command-palette/issues",
70 | "contributors": [
71 | {
72 | "name": "Aditya Agarwal",
73 | "email": "adityaa803@gmail.com",
74 | "url": "https://devadi.netlify.app"
75 | }
76 | ],
77 | "keywords": [
78 | "palette",
79 | "actions",
80 | "command",
81 | "CMD+K",
82 | "quick menu",
83 | "search bar",
84 | "solidhack",
85 | "best_ecosystem"
86 | ]
87 | }
88 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import type { PlaywrightTestConfig } from '@playwright/test';
2 | import { devices } from '@playwright/test';
3 |
4 | const isCi = Boolean(process.env.CI);
5 |
6 | const baseURL = process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://localhost:3000';
7 | const reporter: PlaywrightTestConfig['reporter'] = isCi ? 'html' : 'list';
8 |
9 | const config: PlaywrightTestConfig = {
10 | testDir: './e2e',
11 | timeout: 30 * 1000,
12 | expect: {
13 | timeout: 5000,
14 | },
15 | forbidOnly: isCi,
16 | retries: isCi ? 1 : 0,
17 | reporter,
18 | use: {
19 | actionTimeout: 0,
20 | baseURL,
21 | trace: 'on-first-retry',
22 | },
23 | projects: [
24 | {
25 | name: 'chromium',
26 | use: {
27 | ...devices['Desktop Chrome'],
28 | },
29 | },
30 | {
31 | name: 'firefox',
32 | use: {
33 | ...devices['Desktop Firefox'],
34 | },
35 | },
36 | {
37 | name: 'webkit',
38 | use: { ...devices['Desktop Safari'] },
39 | },
40 | ],
41 | };
42 |
43 | export default config;
44 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | printWidth: 100,
3 | singleQuote: true,
4 | singleAttributePerLine: true,
5 | };
6 |
--------------------------------------------------------------------------------
/public/images/branding/logo-dark-horizontal-large.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/branding/logo-dark-horizontal-large.png
--------------------------------------------------------------------------------
/public/images/branding/logo-dark-horizontal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/branding/logo-dark-horizontal.png
--------------------------------------------------------------------------------
/public/images/branding/logo-dark-stamp.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/public/images/branding/logo-dark-vertical-large.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/branding/logo-dark-vertical-large.png
--------------------------------------------------------------------------------
/public/images/branding/logo-dark-vertical.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/branding/logo-dark-vertical.png
--------------------------------------------------------------------------------
/public/images/branding/logo-light-favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/branding/logo-light-favicon.png
--------------------------------------------------------------------------------
/public/images/branding/logo-light-horizontal-large.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/branding/logo-light-horizontal-large.png
--------------------------------------------------------------------------------
/public/images/branding/logo-light-horizontal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/branding/logo-light-horizontal.png
--------------------------------------------------------------------------------
/public/images/branding/logo-light-stamp.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/public/images/branding/logo-light-vertical-large.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/branding/logo-light-vertical-large.png
--------------------------------------------------------------------------------
/public/images/branding/logo-light-vertical.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/branding/logo-light-vertical.png
--------------------------------------------------------------------------------
/public/images/command-palette-examples/devtools.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/command-palette-examples/devtools.png
--------------------------------------------------------------------------------
/public/images/command-palette-examples/github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/command-palette-examples/github.png
--------------------------------------------------------------------------------
/public/images/command-palette-examples/linear.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/command-palette-examples/linear.png
--------------------------------------------------------------------------------
/public/images/command-palette-examples/tailwind.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/command-palette-examples/tailwind.png
--------------------------------------------------------------------------------
/public/images/demo-minimal.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/itaditya/solid-command-palette/595a4a9c7cb7ee535c103669b788297bdbf004c3/public/images/demo-minimal.gif
--------------------------------------------------------------------------------
/public/snippets/api-command-palette.html:
--------------------------------------------------------------------------------
1 | import { CommandPalette } from 'solid-command-palette' ;
2 |
3 | < CommandPalette
4 | /* Placeholder for palette's search field */
5 | searchPlaceholder = "Search actions"
6 | />;
7 |
--------------------------------------------------------------------------------
/public/snippets/api-define-action.html:
--------------------------------------------------------------------------------
1 | import { defineAction } from 'solid-command-palette' ;
2 |
3 | export const myAction = defineAction ({
4 | /* Unique identifier for your action */
5 | id: 'my-action' ,
6 |
7 | /* Nest current action inside a parent action */
8 | parentActionId: 'my-parent-action' ,
9 |
10 | /* Short heading. Visible and Searchable */
11 | title: 'My Action' ,
12 |
13 | /* Long description. Visible and Searchable */
14 | subtitle: 'My Action' ,
15 |
16 | /* Other words to identify action. Hidden but Searchable */
17 | keywords: [ 'example' , 'command' ],
18 |
19 | /* Keyboard shortcut for action. $mod = Cmd (Mac) / Ctrl (Win) */
20 | shortcut: '$mod+e' ,
21 |
22 | /* Condition for allowing action */
23 | cond : ({ actionId , rootContext , dynamicContext }) => {
24 | const isAllowed = someLogic ({ rootContext, dynamicContext });
25 | return isAllowed;
26 | },
27 |
28 | /* Code to execute when action is triggered */
29 | run : ({ actionId , rootContext , dynamicContext }) => {
30 | console. log ( 'Run' , actionId);
31 | rootContext. incrementCount ();
32 | rootContext. deleteMessage (dynamicContext.messageId);
33 | },
34 | });
35 |
--------------------------------------------------------------------------------
/public/snippets/api-root.html:
--------------------------------------------------------------------------------
1 | import { Root } from 'solid-command-palette' ;
2 |
3 | < Root
4 | /* map of all actions */
5 | actions = {{
6 | [myAction.id]: myAction,
7 | /* ... other actions ... */
8 | }}
9 |
10 | /* share App's signals and methods with actions */
11 | actionsContext = {{
12 | count,
13 | incrementCount,
14 | deleteMessage,
15 | }}
16 |
17 | /* Custom components to render in palette */
18 | components = {{
19 | /* content for action in search result list */
20 | ResultContent,
21 | }}
22 | />
23 |
--------------------------------------------------------------------------------
/public/snippets/installation-define-actions.html:
--------------------------------------------------------------------------------
1 | import { defineAction } from 'solid-command-palette' ;
2 |
3 | const minimalAction = defineAction ({
4 | id: 'minimal' ,
5 | title: 'Minimal Action' ,
6 | run : () => {
7 | console. log ( 'ran minimal action' );
8 | },
9 | });
10 |
11 | const incrementCounterAction = defineAction ({
12 | id: 'increment-counter' ,
13 | title: 'Increment Counter by 1' ,
14 | subtitle: 'Press CMD + E to trigger this.' ,
15 | shortcut: '$mod+e' , // $mod = Command on Mac & Control on Windows.
16 | run : ({ rootContext }) => {
17 | rootContext. increment ();
18 | },
19 | });
20 |
21 | export const actions = {
22 | [minimalAction.id]: minimalAction,
23 | [incrementCounterAction.id]: incrementCounterAction,
24 | };
25 |
--------------------------------------------------------------------------------
/public/snippets/installation-integrate-components.html:
--------------------------------------------------------------------------------
1 | import { Root, CommandPalette } from 'solid-command-palette' ;
2 | import { actions } from './actions' ;
3 | import 'solid-command-palette/pkg-dist/style.css' ;
4 |
5 | const App = () => {
6 | const actionsContext = {
7 | increment () {
8 | console. log ( 'increment count state by 1' );
9 | },
10 | };
11 |
12 | return (
13 | < div class = "my-app" >
14 | < Root
15 | actions = {actions}
16 | actionsContext = {actionsContext}
17 | >
18 | < CommandPalette />
19 | </ Root >
20 | </ div >
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/public/snippets/installation-packages.html:
--------------------------------------------------------------------------------
1 | # Core Library
2 | npm install solid-command-palette
3 |
4 | # Peer Dependencies
5 | npm install solid-transition-group tinykeys fuse.js
6 |
--------------------------------------------------------------------------------
/public/snippets/installation-setup.html:
--------------------------------------------------------------------------------
1 | # Core Library
2 | npm install solid-command-palette
3 |
4 | # Peer Dependencies
5 | npm install solid-transition-group tinykeys fuse.js
6 |
--------------------------------------------------------------------------------
/scripts/generateSnippets.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import fs from 'fs/promises';
3 | import { getHighlighter, Highlighter } from 'shiki';
4 |
5 | type Snippet = {
6 | id: string;
7 | lang: string;
8 | };
9 | type SnippetList = Array;
10 |
11 | async function getSnippetList() {
12 | const snippetFolderPath = path.join(process.cwd(), 'snippets');
13 | const snippetFilePaths = await fs.readdir(snippetFolderPath);
14 | const snippetIdList: SnippetList = [];
15 |
16 | snippetFilePaths.forEach((file) => {
17 | const fileName = path.basename(file);
18 | const [id, lang] = fileName.split('.snippet.');
19 |
20 | if (id && lang) {
21 | snippetIdList.push({
22 | id,
23 | lang,
24 | });
25 | }
26 | });
27 |
28 | return snippetIdList;
29 | }
30 |
31 | async function readPlainSnippet(snippet: Snippet) {
32 | const { id, lang } = snippet;
33 | const snippetFilePath = path.join(process.cwd(), 'snippets', `${id}.snippet.${lang}`);
34 | const plainSnippet = await fs.readFile(snippetFilePath, 'utf8');
35 | return plainSnippet;
36 | }
37 |
38 | async function writeHighlightedSnippet(snippet: Snippet, highlightedSnippet: string) {
39 | const snippetFilePath = path.join(process.cwd(), 'public', 'snippets', `${snippet.id}.html`);
40 | await fs.writeFile(snippetFilePath, highlightedSnippet, 'utf8');
41 | }
42 |
43 | async function generateSnippet(highlighter: Highlighter, snippet: Snippet) {
44 | const plainSnippet = await readPlainSnippet(snippet);
45 | const highlightedSnippet = highlighter.codeToHtml(plainSnippet, {
46 | lang: snippet.lang,
47 | });
48 | await writeHighlightedSnippet(snippet, highlightedSnippet);
49 | }
50 |
51 | async function generateSnippets() {
52 | const highlighter = await getHighlighter({
53 | theme: 'github-dark',
54 | langs: ['tsx', 'sh'],
55 | });
56 |
57 | const snippetList = await getSnippetList();
58 |
59 | const promises = snippetList.map((snippet) => {
60 | return generateSnippet(highlighter, snippet);
61 | });
62 |
63 | await Promise.all(promises);
64 | console.log('Snippets generated!\n', snippetList);
65 | }
66 |
67 | generateSnippets();
68 |
--------------------------------------------------------------------------------
/snippets/api-command-palette.snippet.tsx:
--------------------------------------------------------------------------------
1 | import { CommandPalette } from 'solid-command-palette';
2 |
3 | ;
7 |
--------------------------------------------------------------------------------
/snippets/api-define-action.snippet.tsx:
--------------------------------------------------------------------------------
1 | import { defineAction } from 'solid-command-palette';
2 |
3 | export const myAction = defineAction({
4 | /* Unique identifier for your action */
5 | id: 'my-action',
6 |
7 | /* Nest current action inside a parent action */
8 | parentActionId: 'my-parent-action',
9 |
10 | /* Short heading. Visible and Searchable */
11 | title: 'My Action',
12 |
13 | /* Long description. Visible and Searchable */
14 | subtitle: 'My Action',
15 |
16 | /* Other words to identify action. Hidden but Searchable */
17 | keywords: ['example', 'command'],
18 |
19 | /* Keyboard shortcut for action. $mod = Cmd (Mac) / Ctrl (Win) */
20 | shortcut: '$mod+e',
21 |
22 | /* Condition for allowing action */
23 | cond: ({ actionId, rootContext, dynamicContext }) => {
24 | const isAllowed = someLogic({ rootContext, dynamicContext });
25 | return isAllowed;
26 | },
27 |
28 | /* Code to execute when action is triggered */
29 | run: ({ actionId, rootContext, dynamicContext }) => {
30 | console.log('Run', actionId);
31 | rootContext.incrementCount();
32 | rootContext.deleteMessage(dynamicContext.messageId);
33 | },
34 | });
35 |
--------------------------------------------------------------------------------
/snippets/api-root.snippet.tsx:
--------------------------------------------------------------------------------
1 | import { Root } from 'solid-command-palette';
2 |
3 |
23 |
--------------------------------------------------------------------------------
/snippets/installation-define-actions.snippet.tsx:
--------------------------------------------------------------------------------
1 | import { defineAction } from 'solid-command-palette';
2 |
3 | const minimalAction = defineAction({
4 | id: 'minimal',
5 | title: 'Minimal Action',
6 | run: () => {
7 | console.log('ran minimal action');
8 | },
9 | });
10 |
11 | const incrementCounterAction = defineAction({
12 | id: 'increment-counter',
13 | title: 'Increment Counter by 1',
14 | subtitle: 'Press CMD + E to trigger this.',
15 | shortcut: '$mod+e', // $mod = Command on Mac & Control on Windows.
16 | run: ({ rootContext }) => {
17 | rootContext.increment();
18 | },
19 | });
20 |
21 | export const actions = {
22 | [minimalAction.id]: minimalAction,
23 | [incrementCounterAction.id]: incrementCounterAction,
24 | };
25 |
--------------------------------------------------------------------------------
/snippets/installation-integrate-components.snippet.tsx:
--------------------------------------------------------------------------------
1 | import { Root, CommandPalette } from 'solid-command-palette';
2 | import { actions } from './actions';
3 | import 'solid-command-palette/pkg-dist/style.css';
4 |
5 | const App = () => {
6 | const actionsContext = {
7 | increment() {
8 | console.log('increment count state by 1');
9 | },
10 | };
11 |
12 | return (
13 |
14 |
18 |
19 |
20 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/snippets/installation-packages.snippet.sh:
--------------------------------------------------------------------------------
1 | # Core Library
2 | npm install solid-command-palette
3 |
4 | # Peer Dependencies
5 | npm install solid-transition-group tinykeys fuse.js
6 |
--------------------------------------------------------------------------------
/src/app/App.tsx:
--------------------------------------------------------------------------------
1 | import { Component, lazy } from 'solid-js';
2 | import { RouteDefinition, useRoutes } from 'solid-app-router';
3 | import AppShellView from './views/app/AppShell.view';
4 | import DocsShellView from './views/docs/DocsShell/DocsShell.view';
5 | import DocsView from './views/docs/Docs.view';
6 | import ApiView from './views/docs/Api/Api.view';
7 | import HomeView from './views/home/Home.view';
8 |
9 | const routes: Array = [
10 | {
11 | path: '/',
12 | component: AppShellView,
13 | children: [
14 | {
15 | path: '/docs',
16 | component: DocsShellView,
17 | children: [
18 | {
19 | path: '/',
20 | component: DocsView,
21 | },
22 | {
23 | path: '/overview',
24 | component: lazy(() => import('./views/docs/introduction/Overview.view')),
25 | },
26 | {
27 | path: '/installation',
28 | component: lazy(() => import('./views/docs/introduction/Installation.view')),
29 | },
30 | {
31 | path: '/api',
32 | children: [
33 | { path: '/', component: ApiView },
34 | {
35 | path: '/define-action',
36 | component: lazy(() => import('./views/docs/Api/ApiDefineAction.view')),
37 | },
38 | { path: '/root', component: lazy(() => import('./views/docs/Api/ApiRoot.view')) },
39 | {
40 | path: '/command-palette',
41 | component: lazy(() => import('./views/docs/Api/ApiCommandPalette.view')),
42 | },
43 | ],
44 | },
45 | ],
46 | },
47 | {
48 | path: '/demo',
49 | component: lazy(() => import('./views/demo/Demo.view')),
50 | },
51 | {
52 | path: '/',
53 | component: HomeView,
54 | },
55 | ],
56 | },
57 | ];
58 |
59 | const App: Component = () => {
60 | const Routes = useRoutes(routes);
61 |
62 | return ;
63 | };
64 |
65 | export default App;
66 |
--------------------------------------------------------------------------------
/src/app/index.tsx:
--------------------------------------------------------------------------------
1 | import { render } from 'solid-js/web';
2 | import { Router } from 'solid-app-router';
3 |
4 | import App from './App';
5 |
6 | const appRender = () => {
7 | return (
8 |
9 |
10 |
11 | );
12 | };
13 |
14 | const rootElem = document.getElementById('root') as HTMLElement;
15 |
16 | render(appRender, rootElem);
17 |
--------------------------------------------------------------------------------
/src/app/shared/Loader/Loader.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | display: contents;
3 | }
4 |
5 | .loader {
6 | color: var(--brand-accent-color-5);
7 | animation-name: anim-rotating-loader;
8 | animation-duration: 600ms;
9 | animation-timing-function: linear;
10 | animation-iteration-count: infinite;
11 | animation-fill-mode: forwards;
12 | }
13 |
14 | .loader[data-size='normal'] {
15 | font-size: 2rem;
16 | }
17 |
18 | .loader[data-size='large'] {
19 | font-size: 4rem;
20 | }
21 |
22 | @keyframes anim-rotating-loader {
23 | from {
24 | transform: rotate(0turn);
25 | }
26 |
27 | to {
28 | transform: rotate(1turn);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/app/shared/Loader/Loader.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'solid-js';
2 | import utilStyles from '../../utils.module.css';
3 | import styles from './Loader.module.css';
4 |
5 | export interface LoaderProps {
6 | size?: 'normal' | 'large';
7 | }
8 |
9 | export const Loader: Component = (p) => {
10 | return (
11 |
15 |
24 |
30 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/app/utils.module.css:
--------------------------------------------------------------------------------
1 | .visuallyHidden {
2 | position: absolute !important;
3 | width: 1px !important;
4 | height: 1px !important;
5 | padding: 0 !important;
6 | margin: -1px !important;
7 | overflow: hidden !important;
8 | clip: rect(0, 0, 0, 0) !important;
9 | white-space: nowrap !important;
10 | word-wrap: normal !important;
11 | border: 0 !important;
12 | }
13 |
14 | .stripSpace {
15 | margin: 0;
16 | padding: 0;
17 | }
18 |
19 | .sizeIconWithFont {
20 | width: 1em;
21 | height: 1em;
22 | }
23 |
24 | .demoAction {
25 | display: inline-flex;
26 | align-items: center;
27 | gap: 10px;
28 | background-color: var(--brand-fg-color);
29 | color: var(--brand-bg-color);
30 | border: none;
31 | border-radius: 4px;
32 | box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
33 | font-size: 1rem;
34 | font-family: inherit;
35 | text-decoration: none;
36 | cursor: pointer;
37 | padding: 10px 20px;
38 | outline-offset: 3px;
39 | transition: all 0.2s ease-out;
40 | }
41 |
42 | .demoAction[data-size='large'] {
43 | gap: 15px;
44 | font-size: 2rem;
45 | padding: 10px 15px;
46 | border-radius: 6px;
47 | }
48 |
49 | .demoAction:hover {
50 | transform: scale(1.01);
51 | }
52 |
53 | .demoAction:active {
54 | background-color: var(--brand-accent-color-6);
55 | }
56 |
57 | .nonFlickerLoader {
58 | visibility: hidden;
59 | animation-name: anim-non-flicker-loader;
60 | animation-delay: 500ms;
61 | animation-fill-mode: forwards;
62 | }
63 |
64 | @keyframes anim-non-flicker-loader {
65 | to {
66 | visibility: visible;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/app/views/app/AppShell.module.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | :global html {
6 | overscroll-behavior: contain; /* disable Pull to Refresh in Firefox */
7 | }
8 |
9 | :global body {
10 | --brand-fg-color: #000;
11 | --brand-bg-color: #fff;
12 | --brand-accent-color-1: #fafafa;
13 | --brand-accent-color-2: #eaeaea;
14 | --brand-accent-color-3: #999;
15 | --brand-accent-color-4: #73768c;
16 | --brand-accent-color-5: #666;
17 | --brand-accent-color-6: #454754;
18 | --brand-accent-color-7: #333;
19 | --brand-accent-color-8: #111;
20 | --brand-body-font: 'Nunito', sans-serif;
21 |
22 | margin: 0;
23 | padding: 0;
24 | font-family: sans-serif;
25 | overscroll-behavior: contain; /* disable Pull to Refresh in Chrome */
26 | }
27 |
28 | :global #root {
29 | outline-color: var(--brand-accent-color-8);
30 | font-family: var(--brand-body-font);
31 | }
32 |
33 | .wrapper {
34 | display: flex;
35 | flex-direction: column;
36 | min-height: 100vh;
37 | background-color: var(--brand-bg-color);
38 | }
39 |
40 | .header {
41 | display: flex;
42 | align-items: center;
43 | gap: 40px;
44 | padding: 0 30px;
45 | }
46 |
47 | .heading {
48 | margin: 0;
49 | font-weight: 300;
50 | }
51 |
52 | .headingLink {
53 | display: flex;
54 | align-items: center;
55 | gap: 10px;
56 | color: var(--brand-accent-color-8);
57 | text-decoration: none;
58 | outline-offset: 6px;
59 | }
60 |
61 | .logoStamp {
62 | height: 50px;
63 | transition: all 0.2s ease-in;
64 | }
65 |
66 | .headingLink:hover .logoStamp {
67 | transform: scale(1.01);
68 | filter: drop-shadow(0 10px 8px rgb(0 0 0 / 0.04)) drop-shadow(0 4px 3px rgb(0 0 0 / 0.1));
69 | }
70 |
71 | .logoText {
72 | display: flex;
73 | flex-direction: column;
74 | gap: 20px;
75 | line-height: 0;
76 | }
77 |
78 | .logoTextPrimary {
79 | font-size: 1.3rem;
80 | color: var(--brand-accent-color-6);
81 | }
82 |
83 | .logoTextSecondary {
84 | font-size: 0.9rem;
85 | color: var(--brand-accent-color-4);
86 | padding-inline-start: 2px;
87 | }
88 |
89 | .navWrapper {
90 | margin-inline-start: auto;
91 | }
92 |
93 | .navList,
94 | .socialList {
95 | list-style: none;
96 | display: flex;
97 | gap: 20px;
98 | }
99 |
100 | .navLink {
101 | display: flex;
102 | align-items: center;
103 | justify-content: center;
104 | font-size: 1rem;
105 | text-decoration: none;
106 | padding-block: 15px;
107 | outline: none;
108 | }
109 |
110 | .navLinkContent {
111 | color: var(--brand-fg-color);
112 | padding: 10px 15px;
113 | border: 2px solid transparent;
114 | border-radius: 4px;
115 | transition: all 0.2s ease-out;
116 | }
117 |
118 | .navLink:not(.activeNavLink):hover .navLinkContent {
119 | background-color: var(--brand-accent-color-2);
120 | }
121 |
122 | .navLink:focus-visible .navLinkContent {
123 | border-color: var(--brand-accent-color-8);
124 | }
125 |
126 | .activeNavLink .navLinkContent {
127 | background-color: var(--brand-accent-color-7);
128 | color: var(--brand-bg-color);
129 | }
130 |
131 | .socialList {
132 | align-items: center;
133 | }
134 |
135 | .socialItem {
136 | line-height: 0;
137 | }
138 |
139 | .socialLink {
140 | display: block;
141 | border-radius: 100%;
142 | text-decoration: none;
143 | color: var(--brand-accent-color-5);
144 | font-size: 2rem;
145 | outline-offset: 5px;
146 | transition: all 0.3s ease-out;
147 | }
148 |
149 | .socialLink:hover {
150 | transform: scale(1.05);
151 | color: var(--brand-accent-color-6);
152 | }
153 |
154 | .main {
155 | display: flex;
156 | flex-direction: column;
157 | flex: 1;
158 | padding-block-end: 100px;
159 | padding-inline: 30px;
160 | border-block: 1px solid var(--brand-accent-color-2);
161 | background-color: var(--brand-accent-color-1);
162 | }
163 |
164 | .footer {
165 | padding: 20px;
166 | display: flex;
167 | justify-content: center;
168 | }
169 |
170 | .creditLink {
171 | color: var(--brand-accent-color-7);
172 | margin-inline-start: 5px;
173 | text-decoration: none;
174 | font-weight: bold;
175 | }
176 |
--------------------------------------------------------------------------------
/src/app/views/app/AppShell.view.tsx:
--------------------------------------------------------------------------------
1 | import { Component, Show } from 'solid-js';
2 | import { Link, NavLink, NavLinkProps, Outlet, useMatch, useNavigate } from 'solid-app-router';
3 | import { Root, CommandPalette } from '../../../lib';
4 | import { SocialIcon, socialsData } from './SocialIcons';
5 | import { actions } from './actions';
6 | import utilStyles from '../../utils.module.css';
7 | import styles from './AppShell.module.css';
8 |
9 | const HeaderNavLink: Component = (p) => {
10 | return (
11 |
16 | {p.children}
17 |
18 | );
19 | };
20 |
21 | const Main: Component = () => {
22 | const isDemo = useMatch(() => '/demo');
23 | const navigate = useNavigate();
24 |
25 | const actionsContext = {
26 | navigate,
27 | };
28 |
29 | return (
30 |
31 | }
34 | >
35 |
39 |
40 |
41 |
42 |
43 |
44 | );
45 | };
46 |
47 | const AppShellView: Component = () => {
48 | return (
49 |
107 | );
108 | };
109 |
110 | export default AppShellView;
111 |
--------------------------------------------------------------------------------
/src/app/views/app/SocialIcons.tsx:
--------------------------------------------------------------------------------
1 | import { Component, JSX, splitProps } from 'solid-js';
2 | import utilStyles from '../../utils.module.css';
3 |
4 | export const socialsData = {
5 | github: {
6 | href: 'https://github.com/itaditya/solid-command-palette',
7 | alt: 'Navigate to GitHub',
8 | icon: 'M12 .3a12 12 0 00-3.8 23.38c.6.12.83-.26.83-.57L9 21.07c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.08-.74.09-.73.09-.73 1.2.09 1.83 1.24 1.83 1.24 1.07 1.83 2.81 1.3 3.5 1 .1-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.14-.3-.54-1.52.1-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 016 0c2.28-1.55 3.29-1.23 3.29-1.23.64 1.66.24 2.88.12 3.18a4.65 4.65 0 011.23 3.22c0 4.61-2.8 5.63-5.48 5.92.42.36.81 1.1.81 2.22l-.01 3.29c0 .31.2.69.82.57A12 12 0 0012 .3',
9 | },
10 | twitter: {
11 | href: 'https://twitter.com/dev__adi',
12 | alt: 'Navigate to Twitter',
13 | icon: 'M12,0.1c-6.7,0-12,5.3-12,12s5.3,12,12,12s12-5.4,12-12S18.6,0.1,12,0.1z M17,9.4v0.4c0,3.8-2.6,8-7.5,8 c-1.5,0-2.9-0.5-4.1-1.3c0.2,0,0.4,0,0.7,0c1.2,0,2.3-0.5,3.3-1.2c-1.1,0-2.1-0.8-2.4-2c0.2,0.1,0.3,0.1,0.5,0.1 c0.2,0,0.5-0.1,0.7-0.1C6.9,13,6,11.9,6,10.5v-0.1c0.3,0.2,0.8,0.4,1.2,0.4c-0.7-0.5-1.2-1.4-1.2-2.3c0-0.5,0.1-1.1,0.3-1.4 c1.3,1.7,3.2,2.8,5.4,2.9c-0.1-0.2-0.1-0.4-0.1-0.6c0-1.6,1.2-2.8,2.7-2.8c0.8,0,1.4,0.3,1.9,0.9C17,7.3,17.6,7,18.1,6.7 c-0.2,0.7-0.6,1.2-1.1,1.6c0.5-0.1,1-0.2,1.5-0.4C18.1,8.4,17.6,8.9,17,9.4z',
14 | },
15 | };
16 |
17 | export interface Props extends JSX.HTMLAttributes {
18 | href: string;
19 | alt: string;
20 | icon: string;
21 | }
22 |
23 | export const SocialIcon: Component = (p) => {
24 | const [l, others] = splitProps(p, ['alt', 'icon']);
25 |
26 | return (
27 |
32 | {l.alt}
33 |
37 |
41 |
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/src/app/views/app/actions.ts:
--------------------------------------------------------------------------------
1 | import { defineAction } from '../../../lib';
2 |
3 | const homeAction = defineAction({
4 | id: 'navigate-home',
5 | title: 'Go to Home',
6 | subtitle: 'Navigate to the homepage.',
7 | run({ rootContext }) {
8 | if (typeof rootContext.navigate === 'function') {
9 | rootContext.navigate('/');
10 | }
11 | },
12 | });
13 |
14 | const docsAction = defineAction({
15 | id: 'navigate-docs',
16 | title: 'Go to Docs',
17 | subtitle: 'Read the documentation for Solid Command Palette',
18 | keywords: ['api', 'install', 'start', 'overview'],
19 | shortcut: 'g d',
20 | run({ rootContext }) {
21 | if (typeof rootContext.navigate === 'function') {
22 | rootContext.navigate('/docs');
23 | }
24 | },
25 | });
26 |
27 | const demoAction = defineAction({
28 | id: 'navigate-demo',
29 | title: 'Try the Demo',
30 | subtitle: 'Explore the demo which showcases all features.',
31 | run({ rootContext }) {
32 | if (typeof rootContext.navigate === 'function') {
33 | rootContext.navigate('/demo');
34 | }
35 | },
36 | });
37 |
38 | const githubAction = defineAction({
39 | id: 'navigate-github',
40 | title: 'Go to GitHub repo',
41 | keywords: ['oss', 'source', 'code'],
42 | shortcut: 'g h',
43 | run: () => {
44 | window.open(
45 | 'https://github.com/itaditya/solid-command-palette',
46 | '_blank',
47 | 'noopener noreferrer'
48 | );
49 | },
50 | });
51 |
52 | const npmAction = defineAction({
53 | id: 'navigate-npm',
54 | title: 'Go to npm package',
55 | keywords: ['oss', 'package', 'pkg'],
56 | shortcut: 'g n',
57 | run: () => {
58 | window.open(
59 | 'https://www.npmjs.com/package/solid-command-palette',
60 | '_blank',
61 | 'noopener noreferrer'
62 | );
63 | },
64 | });
65 |
66 | export const actions = {
67 | [homeAction.id]: homeAction,
68 | [docsAction.id]: docsAction,
69 | [demoAction.id]: demoAction,
70 | [githubAction.id]: githubAction,
71 | [npmAction.id]: npmAction,
72 | };
73 |
--------------------------------------------------------------------------------
/src/app/views/demo/CustomComponentsDemo/CustomComponentsDemo.module.css:
--------------------------------------------------------------------------------
1 | .resultContent {
2 | display: flex;
3 | align-items: center;
4 | justify-content: space-between;
5 | cursor: pointer;
6 | user-select: none;
7 | padding-block: 10px;
8 | padding-inline: var(--scp-gutter-space-inline);
9 | font-family: var(--brand-body-font);
10 | border-radius: 8px;
11 | }
12 |
13 | .resultContent.active {
14 | outline: 1px solid var(--brand-accent-color-8);
15 | outline-offset: -5px;
16 | }
17 |
18 | .resultTitle {
19 | color: var(--brand-accent-color-6);
20 | font-weight: 700;
21 | }
22 |
23 | .resultSubtitle {
24 | color: var(--brand-accent-color-5);
25 | font-size: 12px;
26 | font-weight: 400;
27 | margin-block-start: 5px;
28 | }
29 |
30 | .resultShortcut {
31 | font-size: 0.7rem;
32 | font-family: var(--brand-body-font);
33 | font-weight: 700;
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/views/demo/CustomComponentsDemo/CustomComponentsDemo.tsx:
--------------------------------------------------------------------------------
1 | import { Component, Show } from 'solid-js';
2 | import { KbdShortcut, ResultContentProps } from '../../../../lib';
3 | import utilStyles from '../../../utils.module.css';
4 | import styles from './CustomComponentsDemo.module.css';
5 |
6 | export const DemoResultContent: Component = (p) => {
7 | return (
8 |
14 |
15 |
{p.action.title}
16 |
17 | {p.action.subtitle}
18 |
19 |
20 |
21 |
22 | {(shortcut) => (
23 |
27 | )}
28 |
29 |
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/app/views/demo/CustomComponentsDemo/components.ts:
--------------------------------------------------------------------------------
1 | import { DemoResultContent } from './CustomComponentsDemo';
2 |
3 | export const components = {
4 | ResultContent: DemoResultContent,
5 | };
6 |
--------------------------------------------------------------------------------
/src/app/views/demo/Demo.module.css:
--------------------------------------------------------------------------------
1 | .demoWrapper {
2 | display: flex;
3 | flex-direction: column;
4 | gap: 100px;
5 | }
6 |
7 | .introSection {
8 | min-height: 40vh;
9 | display: flex;
10 | flex-direction: column;
11 | align-items: center;
12 | justify-content: center;
13 | gap: 30px;
14 | padding-block-start: 10vh;
15 | }
16 |
17 | .introHeading {
18 | display: contents;
19 | font-size: 3.5rem;
20 | font-weight: 300;
21 | text-align: center;
22 | color: var(--brand-accent-color-3);
23 | }
24 |
25 | .introShortcutKey {
26 | min-width: 85px;
27 | color: var(--brand-accent-color-4);
28 | font-weight: 600;
29 | padding: 0;
30 | }
31 |
32 | .countValue {
33 | font-size: 6rem;
34 | color: var(--brand-accent-color-3);
35 | font-variant-numeric: tabular-nums;
36 | }
37 |
38 | .muteLabel:focus-within {
39 | outline: 2px solid var(--brand-accent-color-8);
40 | outline-offset: 5px;
41 | }
42 |
--------------------------------------------------------------------------------
/src/app/views/demo/Demo.view.tsx:
--------------------------------------------------------------------------------
1 | import { Component, createSignal, Show } from 'solid-js';
2 | import { useSearchParams } from 'solid-app-router';
3 | import { Root, CommandPalette, KbdShortcut } from '../../../lib';
4 | import { actions } from './actions';
5 | import { NestedActionDemo } from './NestedActionDemo/NestedActionDemo';
6 | import { DynamicActionContextDemo } from './DynamicActionContextDemo/DynamicActionContextDemo';
7 | import { components } from './CustomComponentsDemo/components';
8 | import { Profile } from './types';
9 | import utilStyles from '../../utils.module.css';
10 | import demoStyles from './demoUtils.module.css';
11 | import styles from './Demo.module.css';
12 |
13 | const DemoView: Component = () => {
14 | const [count, setCount] = createSignal(0);
15 | const [muted, setMuted] = createSignal(false);
16 | const [profile, setProfile] = createSignal('personal');
17 | const [searchParams] = useSearchParams();
18 |
19 | const increment = () => {
20 | setCount((prev) => (prev += 1));
21 | };
22 |
23 | const unmuteAudio = () => {
24 | setMuted(false);
25 | };
26 |
27 | const handleMuteInput = () => {
28 | setMuted((old) => !old);
29 | };
30 |
31 | const handleProfileChange = (event: Event) => {
32 | const targetElem = event.currentTarget as HTMLSelectElement;
33 |
34 | const newProfile = targetElem.value as Profile;
35 | setProfile(newProfile);
36 | };
37 |
38 | const getCustomProps = () => {
39 | const features = searchParams.feat || [];
40 |
41 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
42 | const props: Record = {};
43 |
44 | if (features.includes('components')) {
45 | props.components = components;
46 | }
47 |
48 | if (features.includes('placeholder')) {
49 | props.placeholder = 'Search for an action...';
50 | }
51 |
52 | return props;
53 | };
54 |
55 | const actionsContext = {
56 | increment,
57 | muted,
58 | unmuteAudio,
59 | setProfile,
60 | };
61 |
62 | const customProps = getCustomProps();
63 |
64 | return (
65 |
70 |
71 |
72 |
73 |
74 | Bring it up by pressing
75 |
79 |
80 |
81 |
82 |
83 |
Controlling application state
84 |
85 | We have a count signal and an increment function to
86 | increase its value by 1.
87 |
88 |
You can trigger it by clicking on the button below it.
89 |
90 | We have also bound this increment function to our first action and a keyboard shortcut
91 |
92 |
93 |
94 |
99 | Current count is
100 | {count()}
101 |
102 |
106 | Increment Count
107 |
108 |
109 | Try holding
110 |
111 |
112 |
113 |
114 |
115 |
Conditionally enable actions
116 |
117 | The Unmute Audio action is only enabled when the muted signal has
118 | value true .
119 |
120 |
121 | The action's cond & run functions can use latest
122 | application state to enable actions or change behavior.
123 |
124 |
125 |
126 |
127 |
131 |
139 |
140 |
145 |
146 | (click to toggle)
147 |
148 |
149 |
150 |
151 | Press to unmute.
152 |
153 |
154 |
155 |
156 |
160 |
161 |
162 |
163 | );
164 | };
165 |
166 | export default DemoView;
167 |
--------------------------------------------------------------------------------
/src/app/views/demo/DynamicActionContextDemo/DynamicActionContextDemo.module.css:
--------------------------------------------------------------------------------
1 | .contactsWrapper {
2 | display: flex;
3 | }
4 |
5 | .contactList {
6 | margin: 0;
7 | padding: 0;
8 | list-style: none;
9 | }
10 |
11 | .contactList:focus-within {
12 | background-color: rgb(0 0 0 / 0.05);
13 | }
14 |
15 | .contactItem {
16 | user-select: none;
17 | position: relative;
18 | color: var(--brand-accent-color-7);
19 | border-inline-start: 2px solid transparent;
20 | transition: all 0.1s ease-out;
21 | }
22 |
23 | .contactItem:not(.active):hover {
24 | transform: translateX(5px);
25 | }
26 |
27 | .contactItem.active {
28 | border-color: var(--brand-accent-color-3);
29 | }
30 |
31 | .contactLabel {
32 | display: block;
33 | padding-block: 15px;
34 | padding-inline: 15px 30px;
35 | cursor: pointer;
36 | }
37 |
38 | .contactInput {
39 | opacity: 0;
40 | position: absolute;
41 | inset-block-start: 0;
42 | }
43 |
44 | .contactDetails {
45 | padding: 10px 20px;
46 | width: 25ch;
47 | }
48 |
--------------------------------------------------------------------------------
/src/app/views/demo/DynamicActionContextDemo/DynamicActionContextDemo.tsx:
--------------------------------------------------------------------------------
1 | import { Component, createMemo, createSignal, createUniqueId, For, Show } from 'solid-js';
2 | import { KbdShortcut, createSyncActionsContext } from '../../../../lib';
3 | import { ownContactId, contacts, contactActionId } from './data';
4 | import { InputEventHandler, ContactItemProps, ReceiverContactDetailsProps } from './types';
5 | import demoStyles from '../demoUtils.module.css';
6 | import utilStyles from '../../../utils.module.css';
7 | import styles from './DynamicActionContextDemo.module.css';
8 |
9 | const ContactItem: Component = (p) => {
10 | const inputId = createUniqueId();
11 |
12 | return (
13 |
19 |
23 | {p.contactData.label}
24 |
25 |
34 |
35 | );
36 | };
37 |
38 | const ReceiverContactDetails: Component = (p) => {
39 | createSyncActionsContext(contactActionId, () => {
40 | return {
41 | receiverContactId: p.contactId(),
42 | };
43 | });
44 |
45 | return (
46 |
47 |
Receiver Details
48 |
{p.contactData().details}
49 |
50 | );
51 | };
52 |
53 | export const DynamicActionContextDemo: Component = () => {
54 | const [activeContactId, setActiveContactId] = createSignal(ownContactId);
55 |
56 | const activeContactData = createMemo(() => {
57 | const activeContactIdValue = activeContactId();
58 | const activeContactData = contacts[activeContactIdValue];
59 |
60 | return activeContactData;
61 | });
62 |
63 | const handleInput: InputEventHandler = (event) => {
64 | const newValue = event.currentTarget.value;
65 | setActiveContactId(newValue);
66 | };
67 |
68 | function renderOwnDetails() {
69 | return (
70 |
71 |
Personal Details
72 |
{activeContactData().details}
73 |
74 | );
75 | }
76 |
77 | return (
78 |
79 |
80 |
Dynamically set action context
81 |
82 | When you select Andrew or Tobey, the ReceiverContactDetails is rendered.
83 |
84 |
85 | While this component is rendered, the profile details are shared using dynamic action
86 | context.
87 |
88 |
89 | Try the Send Message to Contact action once after selecting each profile.
90 |
91 |
92 | If you have Andrew's or Tobey's profile already opened, their contact id will already be
93 | taken and you'll skip one step.
94 |
95 |
96 |
97 |
124 |
125 | Try pressing
126 |
127 |
128 |
129 | );
130 | };
131 |
--------------------------------------------------------------------------------
/src/app/views/demo/DynamicActionContextDemo/data.ts:
--------------------------------------------------------------------------------
1 | import { ContactId, ContactsMap } from './types';
2 |
3 | export const ownContactId: ContactId = 'contact-tom';
4 |
5 | export const contacts: ContactsMap = {
6 | 'contact-tom': {
7 | label: 'Me',
8 | details: 'I am the friendly neighbor-hood Spider-Man who fought Thanos.',
9 | },
10 | 'contact-andrew': {
11 | label: 'Andrew',
12 | details: "He's the Amazing Spider-Man with deep regrets.",
13 | },
14 | 'contact-tobey': {
15 | label: 'Tobey',
16 | details: "He's the youth pastor who shoots webbing out of his hands.",
17 | },
18 | };
19 |
20 | export const contactActionId = 'message-to-contact-action';
21 |
--------------------------------------------------------------------------------
/src/app/views/demo/DynamicActionContextDemo/dynamicContextActions.ts:
--------------------------------------------------------------------------------
1 | import { defineAction } from '../../../../lib';
2 | import { contactActionId, contacts } from './data';
3 |
4 | export const contactAction = defineAction({
5 | id: contactActionId,
6 | title: 'Send Message to Contact',
7 | subtitle: `It'll not ask for Id if you're on a receiver's profile.`,
8 | shortcut: 'm',
9 | run: ({ dynamicContext }) => {
10 | let receiverContactId = dynamicContext.receiverContactId as string;
11 |
12 | if (!receiverContactId) {
13 | const contactId = prompt('Provide Contact Id of the receiver', '');
14 |
15 | if (contactId) {
16 | receiverContactId = contactId;
17 | }
18 | }
19 |
20 | const contactLabel = contacts[receiverContactId]?.label || receiverContactId;
21 | const message = prompt(`Type the message for ${contactLabel}`, 'Hello there!');
22 | alert(`${contactLabel} has been sent the following message:\n${message}`);
23 | },
24 | });
25 |
--------------------------------------------------------------------------------
/src/app/views/demo/DynamicActionContextDemo/types.ts:
--------------------------------------------------------------------------------
1 | import { JSX, Accessor } from 'solid-js';
2 |
3 | export type ContactId = string;
4 |
5 | export type ContactData = {
6 | label: string;
7 | details: string;
8 | };
9 |
10 | export type ContactsMap = Record;
11 |
12 | export type InputEventHandler = JSX.EventHandlerUnion;
13 |
14 | export type ContactItemProps = {
15 | contactId: ContactId;
16 | contactData: ContactData;
17 | isActive: boolean;
18 | onInput: InputEventHandler;
19 | };
20 |
21 | export type ReceiverContactDetailsProps = {
22 | contactId: Accessor;
23 | contactData: Accessor;
24 | };
25 |
--------------------------------------------------------------------------------
/src/app/views/demo/NestedActionDemo/NestedActionDemo.module.css:
--------------------------------------------------------------------------------
1 | .profileMenu {
2 | appearance: none;
3 | font-size: 1rem;
4 | min-width: 150px;
5 | padding-block: 5px;
6 | padding-inline-start: 20px;
7 | padding-inline-end: 10px;
8 | border-radius: 20px;
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/views/demo/NestedActionDemo/NestedActionDemo.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'solid-js';
2 | import { KbdShortcut } from '../../../../lib';
3 | import { Profile } from '../types';
4 | import demoStyles from '../demoUtils.module.css';
5 | import utilStyles from '../../../utils.module.css';
6 | import styles from './NestedActionDemo.module.css';
7 |
8 | export interface Props {
9 | profile: Profile;
10 | onProfileChange: (event: Event) => void;
11 | }
12 |
13 | export const NestedActionDemo: Component = (p) => {
14 | return (
15 |
16 |
17 |
Nested actions
18 |
19 | When user selects an action but wants to further choose an option in it, this comes in
20 | handy.
21 |
22 |
23 | After selecting Change Profile action, user can choose between Personal &
24 | Work profiles.
25 |
26 |
27 |
28 |
29 | Active profile is {p.profile}
30 |
31 |
40 |
41 | Try /
42 |
43 |
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/src/app/views/demo/NestedActionDemo/nestedActions.ts:
--------------------------------------------------------------------------------
1 | import { defineAction } from '../../../../lib';
2 |
3 | const setProfileAction = defineAction({
4 | id: 'set-profile',
5 | title: 'Set profile',
6 | subtitle: 'Select this and then choose one of the options',
7 | });
8 |
9 | const setToPersonalProfileAction = defineAction({
10 | id: 'set-personal-profile',
11 | parentActionId: setProfileAction.id,
12 | title: 'Set to Personal profile',
13 | shortcut: 'p p',
14 | run: ({ rootContext }) => {
15 | if (typeof rootContext.setProfile === 'function') {
16 | rootContext.setProfile('personal');
17 | }
18 | },
19 | });
20 |
21 | const setToWorkProfileAction = defineAction({
22 | id: 'set-work-profile',
23 | parentActionId: setProfileAction.id,
24 | title: 'Set to Work profile',
25 | shortcut: 'p w',
26 | run: ({ rootContext }) => {
27 | if (typeof rootContext.setProfile === 'function') {
28 | rootContext.setProfile('work');
29 | }
30 | },
31 | });
32 |
33 | const configureProfileAction = defineAction({
34 | id: 'configure-profile',
35 | title: 'Configure profile',
36 | subtitle: 'Select this to try 2 levels of nested actions',
37 | });
38 |
39 | const configurePersonalProfileAction = defineAction({
40 | id: 'configure-personal-profile',
41 | parentActionId: configureProfileAction.id,
42 | title: 'Configure Personal profile',
43 | });
44 |
45 | const toggleWifiPersonalAction = defineAction({
46 | id: 'toggle-wifi-personal',
47 | parentActionId: configurePersonalProfileAction.id,
48 | title: 'Toggle Wi-Fi',
49 | subtitle: 'Configure Wi-Fi settings in Personal profile',
50 | run: () => {
51 | alert('Wifi in Personal profile has been toggled!');
52 | },
53 | });
54 |
55 | const toggleAirplanePersonalAction = defineAction({
56 | id: 'toggle-airplane-personal',
57 | parentActionId: configurePersonalProfileAction.id,
58 | title: 'Toggle Airplane mode',
59 | subtitle: 'Configure Airplane mode settings in Personal profile',
60 | run: () => {
61 | alert('Airplane mode in Personal profile has been toggled!');
62 | },
63 | });
64 |
65 | const configureWorkProfileAction = defineAction({
66 | id: 'configure-work-profile',
67 | parentActionId: configureProfileAction.id,
68 | title: 'Configure Work profile',
69 | });
70 |
71 | const toggleWifiWorkAction = defineAction({
72 | id: 'toggle-wifi-work',
73 | parentActionId: configureWorkProfileAction.id,
74 | title: 'Toggle Wi-Fi',
75 | subtitle: 'Configure Wi-Fi settings in Work profile',
76 | run: () => {
77 | alert('Wifi in Work profile has been toggled!');
78 | },
79 | });
80 |
81 | const toggleAirplaneWorkAction = defineAction({
82 | id: 'toggle-airplane-work',
83 | parentActionId: configureWorkProfileAction.id,
84 | title: 'Toggle Airplane mode',
85 | subtitle: 'Configure Airplane mode settings in Work profile',
86 | run: () => {
87 | alert('Airplane mode in Work profile has been toggled!');
88 | },
89 | });
90 |
91 | export const nestedActionsConfig = {
92 | [setProfileAction.id]: setProfileAction,
93 | [setToPersonalProfileAction.id]: setToPersonalProfileAction,
94 | [setToWorkProfileAction.id]: setToWorkProfileAction,
95 | [configureProfileAction.id]: configureProfileAction,
96 | [configurePersonalProfileAction.id]: configurePersonalProfileAction,
97 | [toggleWifiPersonalAction.id]: toggleWifiPersonalAction,
98 | [toggleAirplanePersonalAction.id]: toggleAirplanePersonalAction,
99 | [configureWorkProfileAction.id]: configureWorkProfileAction,
100 | [toggleWifiWorkAction.id]: toggleWifiWorkAction,
101 | [toggleAirplaneWorkAction.id]: toggleAirplaneWorkAction,
102 | };
103 |
--------------------------------------------------------------------------------
/src/app/views/demo/actions.ts:
--------------------------------------------------------------------------------
1 | import { defineAction } from '../../../lib';
2 | import { contactAction } from './DynamicActionContextDemo/dynamicContextActions';
3 | import { nestedActionsConfig } from './NestedActionDemo/nestedActions';
4 |
5 | const incrementCounterAction = defineAction({
6 | id: 'increment-counter',
7 | title: 'Increment Counter by 1',
8 | subtitle: 'Hold the Command and E keys on Mac together to trigger this.',
9 | shortcut: '$mod+e',
10 | run: ({ rootContext }) => {
11 | if (typeof rootContext.increment === 'function') {
12 | rootContext.increment();
13 | }
14 | },
15 | });
16 |
17 | const loggerAction = defineAction({
18 | id: 'logger',
19 | title: 'Log a message in the console',
20 | keywords: ['logger', 'print'],
21 | run: () => {
22 | console.log('run logger action');
23 | },
24 | });
25 |
26 | const unmuteAudioAction = defineAction({
27 | id: 'unmute-audio',
28 | title: 'Unmute Audio',
29 | subtitle: 'Only shown when you have muted the audio',
30 | shortcut: '$mod+u',
31 | cond: ({ rootContext }) => {
32 | if (typeof rootContext.muted !== 'function') {
33 | return false;
34 | }
35 |
36 | return rootContext.muted();
37 | },
38 | run: ({ rootContext }) => {
39 | if (typeof rootContext.unmuteAudio === 'function') {
40 | rootContext.unmuteAudio();
41 | }
42 | },
43 | });
44 |
45 | const navigationAction = defineAction({
46 | id: 'navigate-github',
47 | title: 'Go to GitHub repo',
48 | subtitle: 'First press G then press H. No need to hold them together',
49 | keywords: ['oss', 'source', 'code'],
50 | shortcut: 'g h',
51 | run: () => {
52 | window.open(
53 | 'https://github.com/itaditya/solid-command-palette',
54 | '_blank',
55 | 'noopener noreferrer'
56 | );
57 | },
58 | });
59 |
60 | export const actions = {
61 | [incrementCounterAction.id]: incrementCounterAction,
62 | [loggerAction.id]: loggerAction,
63 | [unmuteAudioAction.id]: unmuteAudioAction,
64 | ...nestedActionsConfig,
65 | [contactAction.id]: contactAction,
66 | [navigationAction.id]: navigationAction,
67 | };
68 |
--------------------------------------------------------------------------------
/src/app/views/demo/demoUtils.module.css:
--------------------------------------------------------------------------------
1 | .demoSection {
2 | display: flex;
3 | align-items: center;
4 | justify-content: space-between;
5 | gap: 30px;
6 | }
7 |
8 | .demoInteraction {
9 | display: flex;
10 | flex-direction: column;
11 | align-items: center;
12 | gap: 20px;
13 | min-width: 300px;
14 | min-height: 150px;
15 | border: 1px solid var(--brand-accent-color-3);
16 | border-radius: 10px;
17 | padding-block: 30px;
18 | padding-inline: 50px;
19 | }
20 |
21 | .demoInteractionDesc {
22 | display: flex;
23 | align-items: center;
24 | gap: 10px;
25 | margin: 0;
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/views/demo/types.ts:
--------------------------------------------------------------------------------
1 | export type Profile = 'personal' | 'work';
2 |
--------------------------------------------------------------------------------
/src/app/views/docs/Api/Api.view.tsx:
--------------------------------------------------------------------------------
1 | import { Navigate } from 'solid-app-router';
2 | import { Component } from 'solid-js';
3 |
4 | const ApiView: Component = () => {
5 | return ;
6 | };
7 |
8 | export default ApiView;
9 |
--------------------------------------------------------------------------------
/src/app/views/docs/Api/ApiCommandPalette.view.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'solid-js';
2 | import { Snippet } from '../Snippet/Snippet';
3 | import docsStyles from '../docsUtils.module.css';
4 |
5 | const ApiCommandPaletteView: Component = () => {
6 | return (
7 |
8 | CommandPalette
9 |
10 | It renders the Command Palette in a portal where users can search for actions and trigger
11 | them.
12 |
13 | Render it inside Root
14 |
15 |
16 | );
17 | };
18 |
19 | export default ApiCommandPaletteView;
20 |
--------------------------------------------------------------------------------
/src/app/views/docs/Api/ApiDefineAction.view.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'solid-js';
2 | import { Link } from 'solid-app-router';
3 | import { Snippet } from '../Snippet/Snippet';
4 | import docsStyles from '../docsUtils.module.css';
5 |
6 | const ApiRootView: Component = () => {
7 | return (
8 |
9 | defineAction
10 | It helps you define an action with autocomplete suggestions.
11 |
12 | Related actions can be grouped in modules and exported. The combined map of actions is
13 | passed to Root
14 |
15 |
16 |
17 | );
18 | };
19 |
20 | export default ApiRootView;
21 |
--------------------------------------------------------------------------------
/src/app/views/docs/Api/ApiRoot.view.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'solid-js';
2 | import { Snippet } from '../Snippet/Snippet';
3 | import docsStyles from '../docsUtils.module.css';
4 |
5 | const ApiRootView: Component = () => {
6 | return (
7 |
8 | Root
9 |
10 | It's responsible for wiring up the state provider, event listeners etc to make the Command
11 | Palette work.
12 |
13 | Render it at the top of your application.
14 |
15 |
16 | );
17 | };
18 |
19 | export default ApiRootView;
20 |
--------------------------------------------------------------------------------
/src/app/views/docs/Docs.view.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'solid-js';
2 | import { Navigate } from 'solid-app-router';
3 |
4 | const DocsView: Component = () => {
5 | return ;
6 | };
7 |
8 | export default DocsView;
9 |
--------------------------------------------------------------------------------
/src/app/views/docs/DocsShell/DocsShell.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | display: flex;
3 | flex: 1;
4 | gap: 100px;
5 | padding-top: 30px;
6 | }
7 |
8 | .wrapper :is(h1, h2, h3, h4, h5, h6, p, ul, ol) {
9 | margin: 0;
10 | padding: 0;
11 | }
12 |
13 | .wrapper :is(ul, ol) {
14 | list-style: none;
15 | }
16 |
17 | .sidebar {
18 | position: sticky;
19 | top: 30px;
20 | display: flex;
21 | flex-direction: column;
22 | gap: 30px;
23 | height: 100%;
24 | min-width: 250px;
25 | }
26 |
27 | .sidebarNavGroup {
28 | display: flex;
29 | flex-direction: column;
30 | gap: 15px;
31 | }
32 |
33 | .sidebarNavList {
34 | display: flex;
35 | flex-direction: column;
36 | gap: 5px;
37 | }
38 |
39 | .navLink {
40 | text-decoration: none;
41 | display: block;
42 | color: var(--brand-accent-color-6);
43 | padding-block: 8px;
44 | padding-inline-start: 10px;
45 | border-radius: 6px;
46 | transition: background-color 200ms ease-in-out;
47 | }
48 |
49 | .navLink:not(.activeNavLink):hover {
50 | background-color: var(--brand-accent-color-2);
51 | }
52 |
53 | .activeNavLink {
54 | background-color: var(--brand-accent-color-7);
55 | color: var(--brand-bg-color);
56 | }
57 |
58 | .main {
59 | flex: 1;
60 | display: flex;
61 | align-items: center;
62 | justify-content: center;
63 | }
64 |
65 | .mainContent {
66 | flex: 1;
67 | height: 100%;
68 | width: 0; /* Ensures width not exceed parent */
69 | }
70 |
--------------------------------------------------------------------------------
/src/app/views/docs/DocsShell/DocsShell.view.tsx:
--------------------------------------------------------------------------------
1 | import { Component, Suspense } from 'solid-js';
2 | import { NavLink, NavLinkProps, Outlet, useIsRouting } from 'solid-app-router';
3 | import { Loader } from '../../../shared/Loader/Loader';
4 | import styles from './DocsShell.module.css';
5 |
6 | const SidebarNavLink: Component = (p) => {
7 | return (
8 |
13 | {p.children}
14 |
15 | );
16 | };
17 |
18 | const DocsShellView: Component = () => {
19 | const isRouting = useIsRouting();
20 |
21 | return (
22 |
23 |
53 |
54 | }>
55 |
56 |
57 |
58 |
59 |
60 |
61 | );
62 | };
63 |
64 | export default DocsShellView;
65 |
--------------------------------------------------------------------------------
/src/app/views/docs/Snippet/Snippet.module.css:
--------------------------------------------------------------------------------
1 | .snippet :global(.shiki) {
2 | max-width: 80ch;
3 | overflow: scroll;
4 | padding-block: 20px;
5 | padding-inline: 30px;
6 | margin: 0;
7 | border-radius: 6px;
8 | font-size: 1rem;
9 | }
10 |
11 | .snippet :global(.line) {
12 | display: inline-block;
13 | padding-block: 4px;
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/views/docs/Snippet/Snippet.tsx:
--------------------------------------------------------------------------------
1 | import { Component, createResource } from 'solid-js';
2 | import styles from './Snippet.module.css';
3 |
4 | type SnippetId = string;
5 | type SnippetContent = string;
6 |
7 | const snippetsCache = new Map();
8 |
9 | async function fetchSnippet(snippetId: SnippetId) {
10 | if (snippetsCache.has(snippetId)) {
11 | return snippetsCache.get(snippetId);
12 | }
13 |
14 | const apiUrl = `/snippets/${snippetId}.html`;
15 | const response = await fetch(apiUrl);
16 | const snippet = (await response.text()) as SnippetContent;
17 |
18 | snippetsCache.set(snippetId, snippet);
19 |
20 | return snippet;
21 | }
22 |
23 | export interface SnippetProps {
24 | snippetId: SnippetId;
25 | }
26 |
27 | export const Snippet: Component = (p) => {
28 | const [snippet] = createResource(p.snippetId, fetchSnippet);
29 |
30 | return (
31 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/src/app/views/docs/docsUtils.module.css:
--------------------------------------------------------------------------------
1 | .section {
2 | display: flex;
3 | flex-direction: column;
4 | gap: 20px;
5 | }
6 |
7 | .text {
8 | max-width: 70ch;
9 | font-size: 1.125rem;
10 | line-height: 1.5;
11 | }
12 |
13 | .embedWrapper iframe {
14 | border: none;
15 | background-color: #202327;
16 | }
17 |
--------------------------------------------------------------------------------
/src/app/views/docs/introduction/Installation.view.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'solid-js';
2 | import { Snippet } from '../Snippet/Snippet';
3 | import docsStyles from '../docsUtils.module.css';
4 |
5 | const InstallationView: Component = () => {
6 | return (
7 |
8 |
9 | Install Packages
10 |
11 |
12 |
13 |
14 | Define Actions
15 |
16 |
17 |
18 |
19 | Integrate with Components
20 |
21 |
22 |
23 | );
24 | };
25 |
26 | export default InstallationView;
27 |
--------------------------------------------------------------------------------
/src/app/views/docs/introduction/Overview.view.tsx:
--------------------------------------------------------------------------------
1 | import { Component, onMount } from 'solid-js';
2 | import sdk from '@stackblitz/sdk';
3 | import utilStyles from '../../../utils.module.css';
4 | import docsStyles from '../docsUtils.module.css';
5 |
6 | const embedExampleData = {
7 | projectId: 'solid-command-palette-example-lite',
8 | initialFile: 'src/App.tsx',
9 | };
10 |
11 | const OverviewView: Component = () => {
12 | let exampleElem: HTMLDivElement;
13 |
14 | onMount(() => {
15 | sdk.embedProjectId(exampleElem, embedExampleData.projectId, {
16 | view: 'editor',
17 | forceEmbedLayout: true,
18 | openFile: embedExampleData.initialFile,
19 | clickToLoad: true,
20 | height: 600,
21 | });
22 | });
23 |
24 | return (
25 |
26 | Overview
27 |
28 | Command Palette lets users perform tasks on your app with just keyboard. No need to drag the
29 | mouse around. Users can fuzzy search to find the action. If the action has a keyboard
30 | shortcut then they can trigger it from anywhere. This increases their productivity by 10x.
31 |
32 |
33 | Live Example
34 |
44 |
47 |
48 | );
49 | };
50 |
51 | export default OverviewView;
52 |
--------------------------------------------------------------------------------
/src/app/views/home/Home.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | display: flex;
3 | align-items: center;
4 | flex: 1;
5 | gap: 10px;
6 | }
7 |
8 | .main {
9 | flex: 1;
10 | }
11 |
12 | .aside {
13 | flex: 1;
14 | padding-top: 50px;
15 | }
16 |
17 | .title {
18 | font-size: 3rem;
19 | font-weight: 300;
20 | color: var(--brand-accent-color-5);
21 | line-height: 1.5;
22 | }
23 |
24 | .titleSecondary {
25 | display: flex;
26 | align-items: center;
27 | gap: 20px;
28 | }
29 |
30 | .demoShortcut {
31 | background-color: transparent;
32 | color: var(--brand-accent-color-1);
33 | padding: 3px 10px;
34 | font-size: 1.5rem;
35 | }
36 |
--------------------------------------------------------------------------------
/src/app/views/home/Home.view.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'solid-js';
2 | import { KbdShortcut, useControls } from '../../../lib';
3 | import { ExampleSlider } from './exampleSlider/ExampleSlider';
4 | import utilStyles from '../../utils.module.css';
5 | import styles from './Home.module.css';
6 |
7 | const HomeView: Component = () => {
8 | const { openPalette } = useControls();
9 |
10 | return (
11 |
12 |
13 |
14 | Make your tool lighting fast
15 |
16 | with
17 |
22 | cmd palette{' '}
23 |
27 |
28 |
29 |
30 |
31 |
34 |
35 | );
36 | };
37 |
38 | export default HomeView;
39 |
--------------------------------------------------------------------------------
/src/app/views/home/exampleSlider/ExampleSlider.module.css:
--------------------------------------------------------------------------------
1 | .slidesWrapper {
2 | display: flex;
3 | flex-direction: column;
4 | gap: 60px;
5 | overflow-y: scroll;
6 | height: 60vh;
7 | padding: 0 10px;
8 | scroll-snap-type: block mandatory;
9 | overscroll-behavior: contain;
10 | }
11 |
12 | .exampleWrapper {
13 | display: flex;
14 | flex-direction: column;
15 | gap: 20px;
16 | align-items: center;
17 | scroll-snap-align: start;
18 | }
19 |
20 | .exampleImage {
21 | width: 100%;
22 | height: auto;
23 | border-radius: 12px;
24 | box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
25 | }
26 |
27 | .exampleLink {
28 | color: var(--brand-accent-color-7);
29 | text-decoration: none;
30 | font-weight: bold;
31 | cursor: pointer;
32 | outline-offset: 2px;
33 | }
34 |
--------------------------------------------------------------------------------
/src/app/views/home/exampleSlider/ExampleSlider.tsx:
--------------------------------------------------------------------------------
1 | import { Component, For } from 'solid-js';
2 | import { slides } from './data';
3 | import utilStyles from '../../../utils.module.css';
4 | import styles from './ExampleSlider.module.css';
5 |
6 | export const ExampleSlider: Component = () => {
7 | return (
8 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/app/views/home/exampleSlider/data.ts:
--------------------------------------------------------------------------------
1 | export const slides = [
2 | {
3 | imgUrl: '/images/command-palette-examples/tailwind.png',
4 | featureContent: 'Navigation becomes faster in',
5 | productUrl: 'https://tailwindcss.com/',
6 | productName: 'Tailwind Docs',
7 | },
8 | {
9 | imgUrl: '/images/command-palette-examples/linear.png',
10 | featureContent: 'Keyboard Shortcuts can be used in',
11 | productUrl: 'https://linear.app/',
12 | productName: 'Linear',
13 | },
14 | {
15 | imgUrl: '/images/command-palette-examples/github.png',
16 | featureContent: 'Power-users can quickly perform tasks on',
17 | productUrl: 'https://docs.github.com/en/get-started/using-github/github-command-palette',
18 | productName: 'GitHub',
19 | },
20 | {
21 | imgUrl: '/images/command-palette-examples/devtools.png',
22 | featureContent: 'New features can be discovered in',
23 | productUrl: 'https://developer.chrome.com/docs/devtools/command-menu/',
24 | productName: 'Devtools',
25 | },
26 | ];
27 |
--------------------------------------------------------------------------------
/src/lib/CommandPalette.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | position: fixed;
3 | inset: 0;
4 | display: flex;
5 | justify-content: center;
6 | }
7 |
8 | .wrapper * {
9 | box-sizing: border-box;
10 | }
11 |
12 | .palette {
13 | position: relative;
14 | height: max-content;
15 | }
16 |
17 | .panel {
18 | --scp-gutter-space-inline: 20px;
19 |
20 | display: flex;
21 | flex-direction: column;
22 | background-color: #fff;
23 | width: min(100vw, 600px);
24 | height: 100vh;
25 | border: 0.5px solid #e2e8f0;
26 | border-radius: 8px;
27 | box-shadow: 0 0 50px -12px rgb(0 0 0 / 0.25);
28 | overflow: hidden;
29 | scrollbar-gutter: stable;
30 | font-family: inherit;
31 | transform-origin: 50%;
32 | }
33 |
34 | .animEnter .panel {
35 | opacity: 0;
36 | transform: translateY(50px) scaleX(0.95);
37 | }
38 |
39 | .animEnterActive .panel {
40 | opacity: 1;
41 | transition: opacity, transform 200ms;
42 | }
43 |
44 | .animExit .panel {
45 | opacity: 1;
46 | }
47 |
48 | .animExitActive .panel {
49 | opacity: 0;
50 | transform: translateY(70px) scaleX(0.8);
51 | transition: opacity, transform 300ms, 200ms;
52 | }
53 |
54 | .searchForm {
55 | display: grid;
56 | grid-template-columns: 1fr auto;
57 | align-items: center;
58 | border-block-end: 0.5px solid #e2e8f0;
59 | }
60 |
61 | .searchInput {
62 | grid-row: 1;
63 | grid-column: 1 / -1;
64 | appearance: none;
65 | width: 100%;
66 | border: none;
67 | padding-block: 20px;
68 | padding-inline-start: var(--scp-gutter-space-inline);
69 | padding-inline-end: 80px;
70 | font-size: 20px;
71 | font-family: inherit;
72 | outline: 0;
73 | }
74 |
75 | .searchInput::-webkit-search-cancel-button,
76 | .searchInput::-webkit-search-decoration,
77 | .searchInput::-webkit-search-results-button,
78 | .searchInput::-webkit-search-results-decoration {
79 | display: none;
80 | }
81 |
82 | .closeBtn {
83 | grid-row: 1;
84 | grid-column: 2;
85 | appearance: none;
86 | background: transparent;
87 | border: none;
88 | border-radius: 5px;
89 | padding: 0;
90 | margin-inline-end: var(--scp-gutter-space-inline);
91 | }
92 |
93 | .closeBtn:focus-visible {
94 | outline-offset: 3px;
95 | outline-color: #94a3b8;
96 | }
97 |
98 | @media (min-width: 640px) {
99 | .palette {
100 | margin-block-start: 100px;
101 | }
102 |
103 | .panel {
104 | height: max-content;
105 | max-height: 350px;
106 | }
107 | }
108 |
109 | @media (prefers-reduced-motion: reduce) {
110 | .animEnter .panel {
111 | transform: none;
112 | }
113 |
114 | .animExitActive .panel {
115 | transform: none;
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/src/lib/CommandPalette.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Component,
3 | createEffect,
4 | createSignal,
5 | createUniqueId,
6 | JSX,
7 | onMount,
8 | onCleanup,
9 | Show,
10 | } from 'solid-js';
11 | import { Transition } from 'solid-transition-group';
12 | import tinykeys from 'tinykeys';
13 | import { useStore } from './StoreContext';
14 | import { CommandPalettePortal } from './CommandPalettePortal';
15 | import { KbdShortcut } from './KbdShortcut/KbdShortcut';
16 | import { ScrollAssist } from './ScrollAssist/ScrollAssist';
17 | import { PanelResult } from './Panel/Result/Result';
18 | import { PanelFooter } from './Panel/Footer/Footer';
19 | import { createSearchResultList } from './createActionList';
20 | import { runAction } from './actionUtils/actionUtils';
21 | import { ActionId, WrappedAction } from './types';
22 | import utilStyles from './utils.module.css';
23 | import styles from './CommandPalette.module.css';
24 |
25 | type InputEventHandler = JSX.EventHandlerUnion;
26 | type ActiveItemId = null | ActionId;
27 | type UserInteraction =
28 | | 'idle'
29 | | 'search'
30 | | 'navigate-kbd'
31 | | 'navigate-mouse'
32 | | 'navigate-scroll-assist';
33 |
34 | export interface CommandPaletteProps {
35 | searchPlaceholder?: string;
36 | }
37 |
38 | const CommandPaletteInternal: Component = (p) => {
39 | const [state, storeMethods] = useStore();
40 | const { closePalette, setSearchText, revertParentAction } = storeMethods;
41 | const resultsList = createSearchResultList();
42 | const [activeItemId, setActiveItemId] = createSignal(null);
43 | const [userInteraction, setUserInteraction] = createSignal('idle');
44 | const searchLabelId = createUniqueId();
45 | const searchInputId = createUniqueId();
46 | const resultListId = createUniqueId();
47 |
48 | let wrapperElem: undefined | HTMLDivElement;
49 | let searchInputElem: undefined | HTMLInputElement;
50 | let closeBtnElem: undefined | HTMLButtonElement;
51 | let lastFocusedElem: null | HTMLElement;
52 |
53 | function triggerRun(action: WrappedAction) {
54 | runAction(action, state.actionsContext, storeMethods);
55 | }
56 |
57 | function activatePrevItem() {
58 | const actionsList = resultsList();
59 | const actionsCount = actionsList.length;
60 | const activeActionId = activeItemId();
61 |
62 | const currentActionIndex = actionsList.findIndex((action) => action.id === activeActionId);
63 |
64 | if (currentActionIndex < 0) {
65 | return;
66 | }
67 |
68 | const prevActionIndex = (actionsCount + currentActionIndex - 1) % actionsCount;
69 | const prevActionId = actionsList[prevActionIndex].id;
70 |
71 | setActiveItemId(prevActionId);
72 | }
73 |
74 | function activateNextItem() {
75 | const actionsList = resultsList();
76 | const actionsCount = actionsList.length;
77 | const activeActionId = activeItemId();
78 |
79 | const currentActionIndex = actionsList.findIndex((action) => action.id === activeActionId);
80 |
81 | if (currentActionIndex < 0) {
82 | return;
83 | }
84 |
85 | const nextActionIndex = (currentActionIndex + 1) % actionsCount;
86 | const nextActionId = actionsList[nextActionIndex].id;
87 |
88 | setActiveItemId(nextActionId);
89 | }
90 |
91 | function handleWrapperClick() {
92 | closePalette();
93 | }
94 |
95 | function handlePanelClick(event: MouseEvent) {
96 | event.stopPropagation();
97 | }
98 |
99 | const handleSearchInput: InputEventHandler = (event) => {
100 | const newValue = event.currentTarget.value;
101 |
102 | setUserInteraction('search');
103 | setSearchText(newValue);
104 | };
105 |
106 | function handleActionItemSelect(action: WrappedAction) {
107 | triggerRun(action);
108 | }
109 |
110 | function handleActionItemHover(action: WrappedAction) {
111 | setUserInteraction('navigate-mouse');
112 | setActiveItemId(action.id);
113 | }
114 |
115 | function handleKbdEnter(event: KeyboardEvent) {
116 | const targetElem = event.target as HTMLElement;
117 |
118 | if (closeBtnElem && closeBtnElem.contains(targetElem)) {
119 | return;
120 | }
121 |
122 | event.preventDefault();
123 |
124 | const activeActionId = activeItemId();
125 |
126 | if (!activeActionId) {
127 | return null;
128 | }
129 |
130 | const action = state.actions[activeActionId];
131 | triggerRun(action);
132 | }
133 |
134 | function handleKbdPrev(event: KeyboardEvent) {
135 | event.preventDefault();
136 |
137 | setUserInteraction('navigate-kbd');
138 | activatePrevItem();
139 | }
140 |
141 | function handleKbdNext(event: KeyboardEvent) {
142 | event.preventDefault();
143 |
144 | setUserInteraction('navigate-kbd');
145 | activateNextItem();
146 | }
147 |
148 | function handleKbdFirst(event: KeyboardEvent) {
149 | event.preventDefault();
150 |
151 | const actionsList = resultsList();
152 | const firstAction = actionsList[0];
153 |
154 | if (firstAction) {
155 | setUserInteraction('navigate-kbd');
156 | setActiveItemId(firstAction.id);
157 | }
158 | }
159 |
160 | function handleKbdLast(event: KeyboardEvent) {
161 | event.preventDefault();
162 |
163 | const actionsList = resultsList();
164 | const lastAction = actionsList.at(-1);
165 |
166 | if (lastAction) {
167 | setUserInteraction('navigate-kbd');
168 | setActiveItemId(lastAction.id);
169 | }
170 | }
171 |
172 | function handleKbdDelete() {
173 | const isSearchEmpty = state.searchText.length <= 0;
174 |
175 | if (isSearchEmpty) {
176 | revertParentAction();
177 | }
178 | }
179 |
180 | function handleScrollAssistPrev() {
181 | setUserInteraction('navigate-scroll-assist');
182 | activatePrevItem();
183 | }
184 |
185 | function handleScrollAssistNext() {
186 | setUserInteraction('navigate-scroll-assist');
187 | activateNextItem();
188 | }
189 |
190 | function handleScrollAssistStop() {
191 | setUserInteraction('idle');
192 | }
193 |
194 | function getScrollAssistStatus() {
195 | if (userInteraction() === 'navigate-mouse') {
196 | return 'available';
197 | }
198 |
199 | if (userInteraction() === 'navigate-scroll-assist') {
200 | return 'running';
201 | }
202 |
203 | return 'stopped';
204 | }
205 |
206 | onMount(() => {
207 | lastFocusedElem = document.activeElement as HTMLElement;
208 |
209 | if (searchInputElem) {
210 | searchInputElem.select();
211 | }
212 |
213 | if (wrapperElem) {
214 | tinykeys(wrapperElem, {
215 | Escape: (event) => {
216 | event.preventDefault();
217 | closePalette();
218 | },
219 | Enter: handleKbdEnter,
220 | ArrowUp: handleKbdPrev,
221 | ArrowDown: handleKbdNext,
222 | PageUp: handleKbdFirst,
223 | PageDown: handleKbdLast,
224 | Backspace: handleKbdDelete,
225 | });
226 | }
227 | });
228 |
229 | onCleanup(() => {
230 | if (lastFocusedElem) {
231 | lastFocusedElem.focus();
232 | }
233 |
234 | lastFocusedElem = null;
235 | });
236 |
237 | createEffect(() => {
238 | const actionsList = resultsList();
239 | const firstResultId = actionsList[0]?.id;
240 |
241 | if (firstResultId) {
242 | setActiveItemId(firstResultId);
243 | } else {
244 | setActiveItemId(null);
245 | }
246 | });
247 |
248 | return (
249 |
333 | );
334 | };
335 |
336 | export const CommandPalette: Component = (p) => {
337 | const [state] = useStore();
338 |
339 | return (
340 |
341 |
347 |
348 |
349 |
350 |
351 |
352 | );
353 | };
354 |
--------------------------------------------------------------------------------
/src/lib/CommandPalettePortal.tsx:
--------------------------------------------------------------------------------
1 | import { Component, createRenderEffect, onCleanup } from 'solid-js';
2 | import { Portal } from 'solid-js/web';
3 |
4 | type PortalElem = undefined | HTMLDivElement;
5 |
6 | export const CommandPalettePortal: Component = (p) => {
7 | let portalElem: PortalElem;
8 |
9 | createRenderEffect(() => {
10 | if (portalElem) {
11 | return;
12 | }
13 |
14 | const parent = document.body;
15 | const newPortalElem = document.createElement('div');
16 | newPortalElem.classList.add('command-palette-portal');
17 | parent.appendChild(newPortalElem);
18 | portalElem = newPortalElem;
19 | });
20 |
21 | onCleanup(() => {
22 | if (portalElem) {
23 | portalElem.remove();
24 | portalElem = undefined;
25 | }
26 | });
27 |
28 | return {p.children} ;
29 | };
30 |
--------------------------------------------------------------------------------
/src/lib/KbdShortcut/KbdShortcut.module.css:
--------------------------------------------------------------------------------
1 | .kbdShortcut {
2 | display: flex;
3 | gap: 12px;
4 | }
5 |
6 | .kbdGroup {
7 | display: flex;
8 | gap: 5px;
9 | }
10 |
11 | .kbdKey {
12 | background-color: #f1f5f9;
13 | color: #1e293b;
14 | border: 1px solid #cbd5e1;
15 | border-radius: 5px;
16 | font-family: var(--scp-kbd-font-family, sans-serif);
17 | font-size: inherit;
18 | text-transform: capitalize;
19 | padding: 5px 8px;
20 | }
21 |
--------------------------------------------------------------------------------
/src/lib/KbdShortcut/KbdShortcut.tsx:
--------------------------------------------------------------------------------
1 | import { JSX, Component, For, splitProps } from 'solid-js';
2 | import { getFormattedShortcut } from './utils';
3 | import { ActionShortcut } from '../types';
4 | import styles from './KbdShortcut.module.css';
5 |
6 | export interface KbdShortcutProps extends JSX.HTMLAttributes {
7 | shortcut: ActionShortcut;
8 | }
9 |
10 | export const KbdShortcut: Component = (p) => {
11 | const [l, others] = splitProps(p, ['shortcut', 'class']);
12 |
13 | const formattedShortcut = getFormattedShortcut(l.shortcut);
14 |
15 | const keyClasses = [styles.kbdKey, l.class].join(' ');
16 |
17 | return (
18 |
22 |
23 | {(group) => (
24 |
25 | {(key) => {key} }
26 |
27 | )}
28 |
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/src/lib/KbdShortcut/types.ts:
--------------------------------------------------------------------------------
1 | import { ActionShortcut } from '../types';
2 |
3 | export type GetFormattedShortcut = (shortcut: ActionShortcut) => Array>;
4 |
--------------------------------------------------------------------------------
/src/lib/KbdShortcut/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test, expect } from 'vitest';
2 | import { getFormattedShortcut } from './utils';
3 |
4 | describe('Test KbdShortcut on Mac', () => {
5 | test('should format combination shortcut correctly', () => {
6 | const formattedShortcut = getFormattedShortcut('Shift+Alt+k');
7 | expect(formattedShortcut).toMatchInlineSnapshot(`
8 | [
9 | [
10 | "Shift",
11 | "Alt",
12 | "k",
13 | ],
14 | ]
15 | `);
16 | });
17 |
18 | test('should format sequence shortcut correctly', () => {
19 | const formattedShortcut = getFormattedShortcut('g h');
20 | expect(formattedShortcut).toMatchInlineSnapshot(`
21 | [
22 | [
23 | "g",
24 | ],
25 | [
26 | "h",
27 | ],
28 | ]
29 | `);
30 | });
31 |
32 | test('should format combined shortcut correctly', () => {
33 | const formattedShortcut = getFormattedShortcut('Control+k f');
34 | expect(formattedShortcut).toMatchInlineSnapshot(`
35 | [
36 | [
37 | "Ctrl",
38 | "k",
39 | ],
40 | [
41 | "f",
42 | ],
43 | ]
44 | `);
45 | });
46 |
47 | test('should format Escape shortcut correctly', () => {
48 | const formattedShortcut = getFormattedShortcut('Escape q');
49 | expect(formattedShortcut).toMatchInlineSnapshot(`
50 | [
51 | [
52 | "Esc",
53 | ],
54 | [
55 | "q",
56 | ],
57 | ]
58 | `);
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/src/lib/KbdShortcut/utils.ts:
--------------------------------------------------------------------------------
1 | import { parseKeybinding } from 'tinykeys';
2 | import { GetFormattedShortcut } from './types';
3 |
4 | function getFormattedKey(key: string) {
5 | if (key === 'Meta') {
6 | return '⌘';
7 | }
8 |
9 | if (key === 'Control') {
10 | return 'Ctrl';
11 | }
12 |
13 | if (key === 'Escape') {
14 | return 'Esc';
15 | }
16 |
17 | return key;
18 | }
19 |
20 | export const getFormattedShortcut: GetFormattedShortcut = (shortcut) => {
21 | const parsedShortcut = parseKeybinding(shortcut);
22 |
23 | const formattedShortcut = parsedShortcut.map((group) => {
24 | const flatGroup = group.flat();
25 | const formattedGroup = flatGroup.map(getFormattedKey);
26 | return formattedGroup;
27 | });
28 |
29 | return formattedShortcut;
30 | };
31 |
--------------------------------------------------------------------------------
/src/lib/KbdShortcut/utilsMac.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test, vi, expect, beforeAll, afterAll } from 'vitest';
2 | import { GetFormattedShortcut } from './types';
3 |
4 | describe('Test KbdShortcut on Mac', () => {
5 | const originalNavigator = window.navigator;
6 | const platformSpy = vi.spyOn(window, 'navigator', 'get');
7 | let getFormattedShortcut: GetFormattedShortcut;
8 |
9 | beforeAll(async () => {
10 | platformSpy.mockImplementation(() => {
11 | return {
12 | ...originalNavigator,
13 | platform: 'MacIntel',
14 | };
15 | });
16 | const utils = await import('./utils');
17 | getFormattedShortcut = utils.getFormattedShortcut;
18 | });
19 |
20 | afterAll(() => {
21 | platformSpy.mockRestore();
22 | });
23 |
24 | test('should format $mod as Command key', () => {
25 | const formattedShortcut = getFormattedShortcut('$mod+s');
26 | expect(formattedShortcut).toMatchInlineSnapshot(`
27 | [
28 | [
29 | "⌘",
30 | "s",
31 | ],
32 | ]
33 | `);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/lib/KbdShortcut/utilsWin.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test, vi, expect, beforeAll, afterAll } from 'vitest';
2 | import { GetFormattedShortcut } from './types';
3 |
4 | describe('Test KbdShortcut on Windows', () => {
5 | const originalNavigator = window.navigator;
6 | const platformSpy = vi.spyOn(window, 'navigator', 'get');
7 | let getFormattedShortcut: GetFormattedShortcut;
8 |
9 | beforeAll(async () => {
10 | platformSpy.mockImplementation(() => {
11 | return {
12 | ...originalNavigator,
13 | platform: 'Win32',
14 | };
15 | });
16 | const utils = await import('./utils');
17 | getFormattedShortcut = utils.getFormattedShortcut;
18 | });
19 |
20 | afterAll(() => {
21 | platformSpy.mockRestore();
22 | });
23 |
24 | test('should format $mod as Control key', () => {
25 | const formattedShortcut = getFormattedShortcut('$mod+s');
26 | expect(formattedShortcut).toMatchInlineSnapshot(`
27 | [
28 | [
29 | "Ctrl",
30 | "s",
31 | ],
32 | ]
33 | `);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/lib/Panel/Footer/Footer.module.css:
--------------------------------------------------------------------------------
1 | .footer {
2 | display: flex;
3 | gap: 15px;
4 | color: #64748b;
5 | font-size: 0.85rem;
6 | border-block-start: 0.5px solid #e2e8f0;
7 | padding-block: 10px;
8 | padding-inline: var(--scp-gutter-space-inline);
9 | }
10 |
11 | .group {
12 | display: inline-flex;
13 | align-items: center;
14 | gap: 5px;
15 | }
16 |
17 | .shortcut {
18 | background-color: #f1f5f9;
19 | color: #0f172a;
20 | border: 1px solid #cbd5e1;
21 | border-radius: 3px;
22 | font-size: 0.75rem;
23 | padding: 3px 2px;
24 | line-height: 0;
25 | }
26 |
27 | .runShortcut {
28 | padding-inline: 5px;
29 | }
30 |
31 | .icon {
32 | width: 1em;
33 | height: 1em;
34 | }
35 |
36 | .iconArrow[data-arrow-direction='down'] {
37 | transform: rotateX(180deg);
38 | }
39 |
40 | .iconReturn {
41 | transform: rotateX(180deg);
42 | }
43 |
--------------------------------------------------------------------------------
/src/lib/Panel/Footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'solid-js';
2 | import styles from './Footer.module.css';
3 |
4 | export interface IconArrowProps {
5 | direction?: 'up' | 'down';
6 | }
7 |
8 | const IconArrow: Component = (p) => {
9 | return (
10 |
17 |
23 |
24 | );
25 | };
26 |
27 | const IconReturn: Component = () => {
28 | return (
29 |
35 |
41 |
42 | );
43 | };
44 |
45 | export const PanelFooter: Component = () => {
46 | return (
47 |
64 | );
65 | };
66 |
--------------------------------------------------------------------------------
/src/lib/Panel/Result/Result.module.css:
--------------------------------------------------------------------------------
1 | .resultWrapper {
2 | flex: 1;
3 | overflow: auto;
4 | overscroll-behavior: contain;
5 | }
6 |
7 | .resultList {
8 | list-style: none;
9 | }
10 |
11 | .resultContent {
12 | display: flex;
13 | align-items: center;
14 | justify-content: space-between;
15 | cursor: pointer;
16 | user-select: none;
17 | padding-block: 15px;
18 | padding-inline: var(--scp-gutter-space-inline);
19 | }
20 |
21 | .resultContent.active {
22 | background-color: #f1f5f9;
23 | }
24 |
25 | .resultTitle {
26 | color: #1e293b;
27 | font-weight: normal;
28 | }
29 |
30 | .resultSubtitle {
31 | color: #64748b;
32 | font-size: 13px;
33 | font-weight: 300;
34 | margin-block-start: 7px;
35 | }
36 |
37 | .resultShortcut {
38 | font-size: 0.75rem;
39 | }
40 |
--------------------------------------------------------------------------------
/src/lib/Panel/Result/Result.tsx:
--------------------------------------------------------------------------------
1 | import { Component, For, Show, createEffect } from 'solid-js';
2 | import { useStore } from '../../StoreContext';
3 | import { KbdShortcut } from '../../KbdShortcut/KbdShortcut';
4 | import { ActionId, WrappedAction, WrappedActionList, ResultContentProps } from '../../types';
5 | import utilStyles from '../../utils.module.css';
6 | import styles from './Result.module.css';
7 | import { Dynamic } from 'solid-js/web';
8 |
9 | const ResultContent: Component = (p) => {
10 | return (
11 |
17 |
18 |
{p.action.title}
19 |
20 | {p.action.subtitle}
21 |
22 |
23 |
24 |
25 | {(shortcut) => (
26 |
30 | )}
31 |
32 |
33 |
34 | );
35 | };
36 |
37 | type ActiveItemId = null | ActionId;
38 | type ResultItemElem = undefined | HTMLLIElement;
39 |
40 | interface ResultItemProps {
41 | action: WrappedAction;
42 | activeItemId: ActiveItemId;
43 | onActionItemSelect: (action: WrappedAction) => void;
44 | onActionItemHover: (action: WrappedAction) => void;
45 | }
46 |
47 | const ResultItem: Component = (p) => {
48 | let resultItemElem: ResultItemElem;
49 | let isMoving = false;
50 |
51 | const [state] = useStore();
52 | const ResultContentComponent = state.components?.ResultContent || ResultContent;
53 |
54 | function isActive() {
55 | return p.action.id === p.activeItemId;
56 | }
57 |
58 | function handleMouseMove(action: WrappedAction) {
59 | if (isMoving) {
60 | return;
61 | }
62 |
63 | isMoving = true;
64 | p.onActionItemHover(action);
65 | }
66 |
67 | function handleMouseLeave() {
68 | isMoving = false;
69 | }
70 |
71 | function handleMouseDown(event: MouseEvent) {
72 | // don't take focus away from search field when item is clicked.
73 | event.preventDefault();
74 | }
75 |
76 | createEffect(() => {
77 | if (isActive() && resultItemElem) {
78 | resultItemElem.scrollIntoView({
79 | behavior: 'smooth',
80 | block: 'nearest',
81 | });
82 | }
83 | });
84 |
85 | return (
86 |
96 |
101 |
102 | );
103 | };
104 |
105 | export interface PanelResultProps {
106 | activeItemId: ActiveItemId;
107 | resultsList: WrappedActionList;
108 | resultListId: string;
109 | searchLabelId: string;
110 | onActionItemSelect: (action: WrappedAction) => void;
111 | onActionItemHover: (action: WrappedAction) => void;
112 | }
113 |
114 | export const PanelResult: Component = (p) => {
115 | return (
116 |
117 |
123 |
127 |
128 | Couldn't find any matching actions
129 |
130 |
131 | }
132 | >
133 | {(action) => (
134 |
140 | )}
141 |
142 |
143 |
144 | );
145 | };
146 |
--------------------------------------------------------------------------------
/src/lib/Root.tsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'solid-js';
2 | import { createStore, produce } from 'solid-js/store';
3 | import { createKbdShortcuts } from './createKbdShortcuts';
4 | import { getActiveParentAction } from './actionUtils/actionUtils';
5 | import { rootParentActionId } from './constants';
6 | import { Provider } from './StoreContext';
7 | import { RootProps, StoreState, StoreMethods, StoreContext, DynamicContextMap } from './types';
8 |
9 | const RootInternal: Component = () => {
10 | createKbdShortcuts();
11 |
12 | return null;
13 | };
14 |
15 | export const Root: Component = (p) => {
16 | const initialActions = p.actions || {};
17 | const initialActionsContext = p.actionsContext || {};
18 |
19 | const [state, setState] = createStore({
20 | visibility: 'closed',
21 | searchText: '',
22 | activeParentActionIdList: [rootParentActionId],
23 | actions: initialActions,
24 | actionsContext: {
25 | root: initialActionsContext,
26 | dynamic: {},
27 | },
28 | components: p.components,
29 | });
30 |
31 | const storeMethods: StoreMethods = {
32 | setSearchText(newValue) {
33 | setState('searchText', newValue);
34 | },
35 | setActionsContext(actionId, newData) {
36 | // @ts-expect-error need to figure out nested store setters.
37 | setState('actionsContext', 'dynamic', actionId, newData);
38 | },
39 | resetActionsContext(actionId) {
40 | setState(
41 | 'actionsContext',
42 | 'dynamic',
43 | produce((dynamicContext) => {
44 | delete dynamicContext[actionId];
45 | })
46 | );
47 | },
48 | openPalette() {
49 | setState('visibility', 'opened');
50 | },
51 | closePalette() {
52 | setState('visibility', 'closed');
53 |
54 | const hasActiveParent = state.activeParentActionIdList.length > 1;
55 |
56 | if (hasActiveParent) {
57 | storeMethods.setSearchText('');
58 | storeMethods.resetParentAction();
59 | }
60 | },
61 | togglePalette() {
62 | setState('visibility', (prev) => (prev === 'opened' ? 'closed' : 'opened'));
63 | },
64 | selectParentAction(parentActionId) {
65 | if (parentActionId === rootParentActionId) {
66 | return;
67 | }
68 |
69 | setState('activeParentActionIdList', (old) => {
70 | return [...old, parentActionId];
71 | });
72 | storeMethods.setSearchText('');
73 | },
74 | revertParentAction() {
75 | setState('activeParentActionIdList', (old) => {
76 | const { isRoot } = getActiveParentAction(old);
77 | if (isRoot) {
78 | return old;
79 | }
80 |
81 | const copiedList = [...old];
82 | copiedList.pop();
83 |
84 | return copiedList;
85 | });
86 | },
87 | resetParentAction() {
88 | setState('activeParentActionIdList', [rootParentActionId]);
89 | },
90 | };
91 |
92 | const store: StoreContext = [state, storeMethods];
93 |
94 | return (
95 |
96 |
97 | {p.children}
98 |
99 | );
100 | };
101 |
--------------------------------------------------------------------------------
/src/lib/ScrollAssist/ScrollAssist.module.css:
--------------------------------------------------------------------------------
1 | .scrollShape {
2 | --scp-scroll-shape-height: 60px;
3 |
4 | width: 35%;
5 | height: var(--scp-scroll-shape-height);
6 | position: absolute;
7 | inset-inline-start: 50%;
8 | transform: translateX(-50%);
9 | }
10 |
11 | .scrollShape[data-direction='up'] {
12 | inset-block-start: calc(-1 * var(--scp-scroll-shape-height));
13 | }
14 |
15 | .scrollShape[data-direction='down'] {
16 | inset-block-start: 100%;
17 | }
18 |
19 | .scrollShape[data-direction='up'][data-status='running'] {
20 | cursor: n-resize;
21 | }
22 |
23 | .scrollShape[data-direction='down'][data-status='running'] {
24 | cursor: s-resize;
25 | }
26 |
--------------------------------------------------------------------------------
/src/lib/ScrollAssist/ScrollAssist.tsx:
--------------------------------------------------------------------------------
1 | import { Component, createEffect } from 'solid-js';
2 | import styles from './ScrollAssist.module.css';
3 |
4 | type IntervalId = null | ReturnType;
5 |
6 | type ScrollAssistProps = {
7 | direction: 'up' | 'down';
8 | status: 'available' | 'running' | 'stopped';
9 | onNavigate: () => void;
10 | onStop: () => void;
11 | };
12 |
13 | export const ScrollAssist: Component = (p) => {
14 | let intervalId: IntervalId = null;
15 |
16 | function triggerNavigation() {
17 | p.onNavigate();
18 | }
19 |
20 | function startSelecting() {
21 | triggerNavigation();
22 | intervalId = setInterval(() => {
23 | if (p.status === 'running') {
24 | triggerNavigation();
25 | }
26 | }, 500);
27 | }
28 |
29 | function stopSelecting() {
30 | if (intervalId) {
31 | clearInterval(intervalId);
32 | }
33 |
34 | intervalId = null;
35 | }
36 |
37 | function handleMouseEnter() {
38 | if (p.status === 'available') {
39 | startSelecting();
40 | }
41 | }
42 |
43 | function handleMouseMove(event: MouseEvent) {
44 | if (p.status !== 'running') {
45 | return;
46 | }
47 |
48 | let shouldStop = false;
49 |
50 | if (p.direction === 'up' && event.movementY > 0) {
51 | shouldStop = true;
52 | }
53 |
54 | if (p.direction === 'down' && event.movementY < 0) {
55 | shouldStop = true;
56 | }
57 |
58 | if (shouldStop) {
59 | p.onStop();
60 | }
61 | }
62 |
63 | function handleMouseLeave() {
64 | p.onStop();
65 | }
66 |
67 | createEffect(() => {
68 | if (p.status === 'stopped') {
69 | stopSelecting();
70 | }
71 | });
72 |
73 | return (
74 |
82 | );
83 | };
84 |
--------------------------------------------------------------------------------
/src/lib/StoreContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'solid-js';
2 | import { StoreContext } from './types';
3 |
4 | const storeContext = createContext();
5 |
6 | export const Provider = storeContext.Provider;
7 |
8 | export function useStore() {
9 | const store = useContext(storeContext);
10 |
11 | if (!store) {
12 | throw new Error('Please use it inside Root component');
13 | }
14 |
15 | return store;
16 | }
17 |
--------------------------------------------------------------------------------
/src/lib/actionUtils/actionUtils.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test, expect, vi, afterEach, afterAll } from 'vitest';
2 | import { defineAction } from '../defineAction';
3 | import { checkActionAllowed, runAction } from './actionUtils';
4 |
5 | describe('Test Action Utils', () => {
6 | describe('Test checkActionAllowed util', () => {
7 | const baseAction = {
8 | id: 'test-action',
9 | title: 'Test Action',
10 | run: () => {
11 | console.log('test ran');
12 | },
13 | };
14 |
15 | test('should allow action if no condition defined', () => {
16 | const action = defineAction(baseAction);
17 |
18 | const actionsContext = {
19 | root: {},
20 | dynamic: {},
21 | };
22 |
23 | const isAllowed = checkActionAllowed(action, actionsContext);
24 | expect(isAllowed).toBe(true);
25 | });
26 |
27 | test('should allow action based on root context', () => {
28 | const action = defineAction({
29 | ...baseAction,
30 | cond: ({ rootContext }) => {
31 | return rootContext.profile === 'work';
32 | },
33 | });
34 |
35 | const failingActionsContext = {
36 | root: {
37 | profile: 'personal',
38 | },
39 | dynamic: {},
40 | };
41 | expect(checkActionAllowed(action, failingActionsContext), 'action is not allowed').toBe(
42 | false
43 | );
44 |
45 | const passingActionsContext = {
46 | root: {
47 | profile: 'work',
48 | },
49 | dynamic: {},
50 | };
51 | expect(checkActionAllowed(action, passingActionsContext), 'action is allowed').toBe(true);
52 | });
53 |
54 | test('should allow action based on dynamic context', () => {
55 | const action = defineAction({
56 | ...baseAction,
57 | cond: ({ dynamicContext }) => {
58 | return dynamicContext.isActive === true;
59 | },
60 | });
61 |
62 | const failingActionsContext = {
63 | root: {},
64 | dynamic: {
65 | 'test-action': {
66 | isActive: false,
67 | },
68 | 'other-action': {
69 | isActive: true,
70 | },
71 | },
72 | };
73 | expect(checkActionAllowed(action, failingActionsContext), 'action is not allowed').toBe(
74 | false
75 | );
76 |
77 | const passingActionsContext = {
78 | root: {},
79 | dynamic: {
80 | 'test-action': {
81 | isActive: true,
82 | },
83 | 'other-action': {
84 | isActive: false,
85 | },
86 | },
87 | };
88 | expect(checkActionAllowed(action, passingActionsContext), 'action is allowed').toBe(true);
89 | });
90 | });
91 |
92 | describe('Test runAction util', () => {
93 | const runMock = vi.fn();
94 | const selectParentActionMock = vi.fn();
95 | const closePaletteMock = vi.fn();
96 |
97 | const baseAction = {
98 | id: 'test-action',
99 | title: 'Test Action',
100 | run: runMock,
101 | };
102 |
103 | const baseStoreMethods = {
104 | selectParentAction: selectParentActionMock,
105 | closePalette: closePaletteMock,
106 | };
107 |
108 | afterEach(() => {
109 | runMock.mockClear();
110 | selectParentActionMock.mockClear();
111 | closePaletteMock.mockClear();
112 | });
113 |
114 | afterAll(() => {
115 | runMock.mockReset();
116 | selectParentActionMock.mockReset();
117 | closePaletteMock.mockReset();
118 | });
119 |
120 | test('should trigger run callback of the action correctly', () => {
121 | const action = defineAction(baseAction);
122 |
123 | const actionsContext = {
124 | root: {
125 | profile: 'work',
126 | },
127 | dynamic: {
128 | 'test-action': {
129 | isActive: true,
130 | },
131 | 'other-action': {
132 | isActive: false,
133 | },
134 | },
135 | };
136 |
137 | runAction(action, actionsContext, baseStoreMethods);
138 |
139 | expect(runMock).toBeCalledWith({
140 | actionId: 'test-action',
141 | rootContext: {
142 | profile: 'work',
143 | },
144 | dynamicContext: {
145 | isActive: true,
146 | },
147 | });
148 | expect(selectParentActionMock).not.toBeCalled();
149 | expect(closePaletteMock).toBeCalled();
150 | });
151 |
152 | test('should setup nested actions correctly', () => {
153 | const action = defineAction({
154 | ...baseAction,
155 | id: 'parent-test-action',
156 | run: undefined,
157 | });
158 |
159 | const actionsContext = {
160 | root: {},
161 | dynamic: {},
162 | };
163 |
164 | runAction(action, actionsContext, baseStoreMethods);
165 |
166 | expect(selectParentActionMock).toBeCalledWith('parent-test-action');
167 | expect(runMock).not.toBeCalled();
168 | expect(closePaletteMock).not.toBeCalled();
169 | });
170 | });
171 | });
172 |
--------------------------------------------------------------------------------
/src/lib/actionUtils/actionUtils.ts:
--------------------------------------------------------------------------------
1 | import { KeyBindingMap } from 'tinykeys';
2 | import { rootParentActionId } from '../constants';
3 | import { ActionId, ActionsContext, StoreMethods, WrappedAction, WrappedActionList } from '../types';
4 |
5 | type RunStoreMethods = {
6 | selectParentAction: StoreMethods['selectParentAction'];
7 | closePalette: StoreMethods['closePalette'];
8 | };
9 |
10 | function getActionContext(action: WrappedAction, actionsContext: ActionsContext) {
11 | const rootContext = actionsContext.root;
12 | const dynamicContext = actionsContext.dynamic[action.id] || {};
13 |
14 | return {
15 | rootContext,
16 | dynamicContext,
17 | };
18 | }
19 |
20 | export function checkActionAllowed(action: WrappedAction, actionsContext: ActionsContext) {
21 | if (!action.cond) {
22 | return true;
23 | }
24 |
25 | const { rootContext, dynamicContext } = getActionContext(action, actionsContext);
26 |
27 | const isAllowed = action.cond({ actionId: action.id, rootContext, dynamicContext });
28 | return isAllowed;
29 | }
30 |
31 | export function runAction(
32 | action: WrappedAction,
33 | actionsContext: ActionsContext,
34 | storeMethods: RunStoreMethods
35 | ) {
36 | const { id, run } = action;
37 |
38 | if (!run) {
39 | storeMethods.selectParentAction(id);
40 | return;
41 | }
42 |
43 | const { rootContext, dynamicContext } = getActionContext(action, actionsContext);
44 | run({ actionId: id, rootContext, dynamicContext });
45 | storeMethods.closePalette();
46 | }
47 |
48 | export function getShortcutHandlersMap(
49 | actionsList: WrappedActionList,
50 | actionsContext: ActionsContext,
51 | storeMethods: StoreMethods
52 | ) {
53 | const shortcutMap: KeyBindingMap = {};
54 |
55 | actionsList.forEach((action) => {
56 | const actionHandler = (event: KeyboardEvent) => {
57 | const targetElem = event.target as HTMLElement;
58 | const shortcutsAttr = targetElem.dataset.cpKbdShortcuts;
59 |
60 | if (shortcutsAttr === 'disabled') {
61 | return;
62 | }
63 |
64 | const isAllowed = checkActionAllowed(action, actionsContext);
65 |
66 | if (!isAllowed) {
67 | return;
68 | }
69 |
70 | event.preventDefault();
71 | runAction(action, actionsContext, storeMethods);
72 | };
73 |
74 | const shortcut = action.shortcut;
75 | if (shortcut) {
76 | shortcutMap[shortcut] = actionHandler;
77 | }
78 | });
79 |
80 | return shortcutMap;
81 | }
82 |
83 | type ActiveParentActionIdListArg = Readonly>;
84 |
85 | export function getActiveParentAction(activeParentActionIdList: ActiveParentActionIdListArg) {
86 | const activeId = activeParentActionIdList.at(-1) || rootParentActionId;
87 | const isRoot = activeId === rootParentActionId;
88 |
89 | return {
90 | activeId,
91 | isRoot,
92 | };
93 | }
94 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | export const rootParentActionId = '__ROOT__';
2 |
--------------------------------------------------------------------------------
/src/lib/createActionList.ts:
--------------------------------------------------------------------------------
1 | import { createMemo, createEffect } from 'solid-js';
2 | import Fuse from 'fuse.js';
3 | import { useStore } from './StoreContext';
4 | import { checkActionAllowed, getActiveParentAction } from './actionUtils/actionUtils';
5 | import { WrappedAction } from './types';
6 |
7 | export function createActionList() {
8 | const [state] = useStore();
9 |
10 | const actionsList = createMemo(() => {
11 | return Object.values(state.actions);
12 | });
13 |
14 | return actionsList;
15 | }
16 |
17 | export function createNestedActionList() {
18 | const actionsList = createActionList();
19 | const [state] = useStore();
20 |
21 | function nestedActionFilter(action: WrappedAction) {
22 | const { activeId, isRoot } = getActiveParentAction(state.activeParentActionIdList);
23 |
24 | const isAllowed = isRoot || action.parentActionId === activeId;
25 | return isAllowed;
26 | }
27 |
28 | const nestedActionsList = createMemo(() => {
29 | const nestedActionsList = actionsList().filter(nestedActionFilter);
30 | return nestedActionsList;
31 | });
32 |
33 | return nestedActionsList;
34 | }
35 |
36 | export function createConditionalActionList() {
37 | const [state] = useStore();
38 | const nestedActionsList = createNestedActionList();
39 |
40 | function conditionalActionFilter(action: WrappedAction) {
41 | const isAllowed = checkActionAllowed(action, state.actionsContext);
42 | return isAllowed;
43 | }
44 |
45 | const conditionalActionList = createMemo(() => {
46 | const conditionalActionList = nestedActionsList().filter(conditionalActionFilter);
47 | return conditionalActionList;
48 | });
49 |
50 | return conditionalActionList;
51 | }
52 |
53 | export function createSearchResultList() {
54 | const [state] = useStore();
55 | const conditionalActionList = createConditionalActionList();
56 |
57 | const fuse = new Fuse(conditionalActionList(), {
58 | keys: [
59 | {
60 | name: 'title',
61 | weight: 1,
62 | },
63 | {
64 | name: 'subtitle',
65 | weight: 0.7,
66 | },
67 | {
68 | name: 'keywords',
69 | weight: 0.5,
70 | },
71 | ],
72 | });
73 |
74 | const resultsList = createMemo(() => {
75 | if (state.searchText.length === 0) {
76 | return conditionalActionList();
77 | }
78 |
79 | const searchResults = fuse.search(state.searchText);
80 |
81 | const resultsList = searchResults.map((result) => result.item);
82 | return resultsList;
83 | });
84 |
85 | createEffect(() => {
86 | fuse.setCollection(conditionalActionList());
87 | });
88 |
89 | return resultsList;
90 | }
91 |
--------------------------------------------------------------------------------
/src/lib/createKbdShortcuts.ts:
--------------------------------------------------------------------------------
1 | import { onMount, onCleanup } from 'solid-js';
2 | import tinykeys from 'tinykeys';
3 | import { useStore } from './StoreContext';
4 | import { createActionList } from './createActionList';
5 | import { getShortcutHandlersMap } from './actionUtils/actionUtils';
6 |
7 | type Unsubscribe = null | ReturnType;
8 |
9 | export function createKbdShortcuts() {
10 | const [state, storeMethods] = useStore();
11 | const { togglePalette } = storeMethods;
12 | const actionsList = createActionList();
13 |
14 | let unsubscribe: Unsubscribe = null;
15 |
16 | onMount(() => {
17 | const shortcutMap = getShortcutHandlersMap(actionsList(), state.actionsContext, storeMethods);
18 |
19 | const commandPaletteHandler = (event: KeyboardEvent) => {
20 | event.preventDefault();
21 | togglePalette();
22 | };
23 |
24 | unsubscribe = tinykeys(window, {
25 | ...shortcutMap,
26 | '$mod+k': commandPaletteHandler,
27 | });
28 | });
29 |
30 | onCleanup(() => {
31 | if (unsubscribe) {
32 | unsubscribe();
33 | }
34 | });
35 | }
36 |
--------------------------------------------------------------------------------
/src/lib/createSyncActionsContext.ts:
--------------------------------------------------------------------------------
1 | import { createEffect, onCleanup } from 'solid-js';
2 | import { useStore } from './StoreContext';
3 | import { CreateSyncActionsContext } from './types';
4 |
5 | export const createSyncActionsContext: CreateSyncActionsContext = (actionId, callback) => {
6 | const [_state, { setActionsContext, resetActionsContext }] = useStore();
7 |
8 | createEffect(() => {
9 | const data = callback();
10 | setActionsContext(actionId, data);
11 | });
12 |
13 | onCleanup(() => {
14 | resetActionsContext(actionId);
15 | });
16 | };
17 |
--------------------------------------------------------------------------------
/src/lib/defineAction.ts:
--------------------------------------------------------------------------------
1 | import { Action, PartialAction } from './types';
2 |
3 | export const defineAction = (partialAction: PartialAction): Action => {
4 | const id = partialAction.id || Math.random().toString();
5 | const parentActionId = partialAction.parentActionId || null;
6 | const title = partialAction.title;
7 | const subtitle = partialAction.subtitle || null;
8 | const keywords = partialAction.keywords || [];
9 | const shortcut = partialAction.shortcut || null;
10 | const run = partialAction.run;
11 |
12 | const normalizedAction = {
13 | id,
14 | parentActionId,
15 | title,
16 | subtitle,
17 | keywords,
18 | shortcut,
19 | cond: partialAction.cond,
20 | run,
21 | };
22 |
23 | return normalizedAction;
24 | };
25 |
--------------------------------------------------------------------------------
/src/lib/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Root';
2 | export * from './CommandPalette';
3 | export * from './KbdShortcut/KbdShortcut';
4 | export * from './createSyncActionsContext';
5 | export * from './defineAction';
6 | export * from './useControls';
7 | export * from './types';
8 |
--------------------------------------------------------------------------------
/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | import { Component } from 'solid-js';
2 | import { DeepReadonly, Store } from 'solid-js/store';
3 |
4 | export type ActionId = string;
5 | export type ParentActionId = null | ActionId;
6 | export type ActionShortcut = string;
7 |
8 | export type ActionContext = Record;
9 |
10 | export type DynamicContextMap = Record;
11 |
12 | export interface ActionsContext {
13 | root: ActionContext;
14 | dynamic: DynamicContextMap;
15 | }
16 |
17 | export interface RunArgs {
18 | actionId: ActionId;
19 | rootContext: ActionContext;
20 | dynamicContext: ActionContext;
21 | }
22 |
23 | export interface Action {
24 | id: ActionId;
25 | parentActionId: ParentActionId;
26 | title: string;
27 | subtitle: null | string;
28 | keywords: Array;
29 | /**
30 | * Keyboard Shortcut like `$mod+e`, `Shift+p`.
31 | */
32 | shortcut: null | ActionShortcut;
33 | /**
34 | * Enable the action conditionally.
35 | */
36 | cond?: (args: RunArgs) => boolean;
37 | run?: (args: RunArgs) => void;
38 | }
39 |
40 | export type PartialAction = Partial & {
41 | id: ActionId;
42 | title: Action['title'];
43 | };
44 |
45 | export type Actions = Record;
46 | export type ActionsList = Array;
47 | export type WrappedAction = DeepReadonly;
48 | export type WrappedActionList = Array;
49 |
50 | export interface ResultContentProps {
51 | action: WrappedAction;
52 | isActive: boolean;
53 | }
54 |
55 | export interface Components {
56 | ResultContent: Component;
57 | }
58 |
59 | export interface RootProps {
60 | actions: Actions;
61 | actionsContext: ActionContext;
62 | components?: Components;
63 | }
64 |
65 | export interface StoreState {
66 | visibility: 'opened' | 'closed';
67 | searchText: string;
68 | activeParentActionIdList: Array;
69 | actions: Actions;
70 | actionsContext: ActionsContext;
71 | components?: Components;
72 | }
73 |
74 | export type StoreStateWrapped = Store;
75 |
76 | export interface StoreMethods {
77 | setSearchText: (newValue: string) => void;
78 | setActionsContext: (actionId: ActionId, newData: ActionContext) => void;
79 | resetActionsContext: (actionId: ActionId) => void;
80 | openPalette: () => void;
81 | closePalette: () => void;
82 | togglePalette: () => void;
83 | selectParentAction: (parentActionId: ActionId) => void;
84 | revertParentAction: () => void;
85 | resetParentAction: () => void;
86 | }
87 |
88 | export type StoreContext = [StoreStateWrapped, StoreMethods];
89 |
90 | type CreateSyncActionsContextCallback = () => ActionContext;
91 |
92 | export type CreateSyncActionsContext = (
93 | actionId: ActionId,
94 | callback: CreateSyncActionsContextCallback
95 | ) => void;
96 |
--------------------------------------------------------------------------------
/src/lib/useControls.ts:
--------------------------------------------------------------------------------
1 | import { useStore } from './StoreContext';
2 |
3 | export function useControls() {
4 | const [_state, storeMethods] = useStore();
5 |
6 | return {
7 | openPalette: storeMethods.openPalette,
8 | closePalette: storeMethods.closePalette,
9 | togglePalette: storeMethods.togglePalette,
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/utils.module.css:
--------------------------------------------------------------------------------
1 | .visuallyHidden {
2 | position: absolute !important;
3 | width: 1px !important;
4 | height: 1px !important;
5 | padding: 0 !important;
6 | margin: -1px !important;
7 | overflow: hidden !important;
8 | clip: rect(0, 0, 0, 0) !important;
9 | white-space: nowrap !important;
10 | word-wrap: normal !important;
11 | border: 0 !important;
12 | }
13 |
14 | .stripSpace {
15 | margin: 0;
16 | padding: 0;
17 | }
18 |
--------------------------------------------------------------------------------
/tsconfig-node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "module": "CommonJS",
5 | "moduleResolution": "node",
6 | "esModuleInterop": true,
7 | "types": ["node"]
8 | },
9 | "include": ["./scripts"]
10 | }
11 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "target": "ESNext",
5 | "module": "ESNext",
6 | "moduleResolution": "node",
7 | "allowSyntheticDefaultImports": true,
8 | "esModuleInterop": true,
9 | "jsx": "preserve",
10 | "jsxImportSource": "solid-js",
11 | "types": ["vite/client"],
12 | "declaration": true,
13 | "outDir": "pkg-dist/types"
14 | },
15 | "include": ["./src"],
16 | "exclude": ["./src/app", "./src/**/*.test.*"]
17 | }
18 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "rewrites": [{ "source": "/(.*)", "destination": "/" }],
3 | "github": {
4 | "silent": true
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/vite-pkg.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { defineConfig } from 'vite';
3 | import solidPlugin from 'vite-plugin-solid';
4 |
5 | export default defineConfig({
6 | plugins: [solidPlugin()],
7 | publicDir: false,
8 | build: {
9 | outDir: 'pkg-dist',
10 | lib: {
11 | entry: path.resolve(__dirname, 'src/lib/index.ts'),
12 | formats: ['es', 'cjs'],
13 | fileName: (format) => `solid-command-palette.${format}.js`,
14 | },
15 | rollupOptions: {
16 | external: ['solid-js', 'tinykeys', 'fuse.js'],
17 | },
18 | },
19 | });
20 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { defineConfig } from 'vite';
4 | import solidPlugin from 'vite-plugin-solid';
5 |
6 | export default defineConfig({
7 | plugins: [solidPlugin()],
8 | build: {
9 | target: 'esnext',
10 | polyfillDynamicImport: false,
11 | },
12 | test: {
13 | environment: 'happy-dom',
14 | clearMocks: true,
15 | },
16 | });
17 |
--------------------------------------------------------------------------------