@sonny-ui/core 0.1.0-alpha.15 → 0.1.0-alpha.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fesm2022/sonny-ui-core.mjs +1389 -2
- package/fesm2022/sonny-ui-core.mjs.map +1 -1
- package/package.json +1 -1
- package/src/lib/color-picker/color-picker.component.spec.ts +328 -0
- package/src/lib/color-picker/color-picker.component.ts +537 -0
- package/src/lib/color-picker/color-picker.types.ts +24 -0
- package/src/lib/color-picker/color-picker.utils.ts +183 -0
- package/src/lib/color-picker/color-picker.variants.ts +17 -0
- package/src/lib/color-picker/index.ts +20 -0
- package/src/lib/command-palette/command-palette.component.spec.ts +178 -0
- package/src/lib/command-palette/command-palette.component.ts +195 -0
- package/src/lib/command-palette/command-palette.service.ts +36 -0
- package/src/lib/command-palette/command-palette.types.ts +23 -0
- package/src/lib/command-palette/index.ts +7 -0
- package/src/lib/otp-input/index.ts +2 -0
- package/src/lib/otp-input/otp-input.component.spec.ts +252 -0
- package/src/lib/otp-input/otp-input.component.ts +275 -0
- package/src/lib/otp-input/otp-input.variants.ts +18 -0
- package/types/sonny-ui-core.d.ts +206 -3
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { RGB, HSL, HSV, ColorFormat } from './color-picker.types';
|
|
2
|
+
|
|
3
|
+
export function hexToRgb(hex: string): RGB | null {
|
|
4
|
+
const clean = hex.replace(/^#/, '');
|
|
5
|
+
if (!/^[0-9a-fA-F]+$/.test(clean)) return null;
|
|
6
|
+
if (clean.length === 3) {
|
|
7
|
+
const r = parseInt(clean[0] + clean[0], 16);
|
|
8
|
+
const g = parseInt(clean[1] + clean[1], 16);
|
|
9
|
+
const b = parseInt(clean[2] + clean[2], 16);
|
|
10
|
+
return { r, g, b };
|
|
11
|
+
}
|
|
12
|
+
if (clean.length === 6) {
|
|
13
|
+
const r = parseInt(clean.slice(0, 2), 16);
|
|
14
|
+
const g = parseInt(clean.slice(2, 4), 16);
|
|
15
|
+
const b = parseInt(clean.slice(4, 6), 16);
|
|
16
|
+
return { r, g, b };
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function rgbToHex(rgb: RGB): string {
|
|
22
|
+
const toHex = (n: number) => Math.round(Math.max(0, Math.min(255, n))).toString(16).padStart(2, '0');
|
|
23
|
+
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function rgbToHsl(rgb: RGB): HSL {
|
|
27
|
+
const r = rgb.r / 255;
|
|
28
|
+
const g = rgb.g / 255;
|
|
29
|
+
const b = rgb.b / 255;
|
|
30
|
+
const max = Math.max(r, g, b);
|
|
31
|
+
const min = Math.min(r, g, b);
|
|
32
|
+
const l = (max + min) / 2;
|
|
33
|
+
let h = 0;
|
|
34
|
+
let s = 0;
|
|
35
|
+
|
|
36
|
+
if (max !== min) {
|
|
37
|
+
const d = max - min;
|
|
38
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
39
|
+
switch (max) {
|
|
40
|
+
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
|
41
|
+
case g: h = ((b - r) / d + 2) / 6; break;
|
|
42
|
+
case b: h = ((r - g) / d + 4) / 6; break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
h: Math.round(h * 360),
|
|
48
|
+
s: Math.round(s * 100),
|
|
49
|
+
l: Math.round(l * 100),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function hslToRgb(hsl: HSL): RGB {
|
|
54
|
+
const h = hsl.h / 360;
|
|
55
|
+
const s = hsl.s / 100;
|
|
56
|
+
const l = hsl.l / 100;
|
|
57
|
+
|
|
58
|
+
if (s === 0) {
|
|
59
|
+
const v = Math.round(l * 255);
|
|
60
|
+
return { r: v, g: v, b: v };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const hue2rgb = (p: number, q: number, t: number): number => {
|
|
64
|
+
if (t < 0) t += 1;
|
|
65
|
+
if (t > 1) t -= 1;
|
|
66
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
67
|
+
if (t < 1 / 2) return q;
|
|
68
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
69
|
+
return p;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
73
|
+
const p = 2 * l - q;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
|
|
77
|
+
g: Math.round(hue2rgb(p, q, h) * 255),
|
|
78
|
+
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function rgbToHsv(rgb: RGB): HSV {
|
|
83
|
+
const r = rgb.r / 255;
|
|
84
|
+
const g = rgb.g / 255;
|
|
85
|
+
const b = rgb.b / 255;
|
|
86
|
+
const max = Math.max(r, g, b);
|
|
87
|
+
const min = Math.min(r, g, b);
|
|
88
|
+
const d = max - min;
|
|
89
|
+
let h = 0;
|
|
90
|
+
const s = max === 0 ? 0 : d / max;
|
|
91
|
+
const v = max;
|
|
92
|
+
|
|
93
|
+
if (max !== min) {
|
|
94
|
+
switch (max) {
|
|
95
|
+
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
|
|
96
|
+
case g: h = ((b - r) / d + 2) / 6; break;
|
|
97
|
+
case b: h = ((r - g) / d + 4) / 6; break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { h: Math.round(h * 360), s, v };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function hsvToRgb(hsv: HSV): RGB {
|
|
105
|
+
const h = hsv.h / 360;
|
|
106
|
+
const s = hsv.s;
|
|
107
|
+
const v = hsv.v;
|
|
108
|
+
const i = Math.floor(h * 6);
|
|
109
|
+
const f = h * 6 - i;
|
|
110
|
+
const p = v * (1 - s);
|
|
111
|
+
const q = v * (1 - f * s);
|
|
112
|
+
const t = v * (1 - (1 - f) * s);
|
|
113
|
+
|
|
114
|
+
let r: number, g: number, b: number;
|
|
115
|
+
switch (i % 6) {
|
|
116
|
+
case 0: r = v; g = t; b = p; break;
|
|
117
|
+
case 1: r = q; g = v; b = p; break;
|
|
118
|
+
case 2: r = p; g = v; b = t; break;
|
|
119
|
+
case 3: r = p; g = q; b = v; break;
|
|
120
|
+
case 4: r = t; g = p; b = v; break;
|
|
121
|
+
default: r = v; g = p; b = q; break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
r: Math.round(r * 255),
|
|
126
|
+
g: Math.round(g * 255),
|
|
127
|
+
b: Math.round(b * 255),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function parseColor(input: string): RGB | null {
|
|
132
|
+
const trimmed = input.trim().toLowerCase();
|
|
133
|
+
|
|
134
|
+
// HEX
|
|
135
|
+
if (trimmed.startsWith('#')) {
|
|
136
|
+
return hexToRgb(trimmed);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// rgb(r, g, b)
|
|
140
|
+
const rgbMatch = trimmed.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
|
|
141
|
+
if (rgbMatch) {
|
|
142
|
+
return {
|
|
143
|
+
r: Math.min(255, parseInt(rgbMatch[1])),
|
|
144
|
+
g: Math.min(255, parseInt(rgbMatch[2])),
|
|
145
|
+
b: Math.min(255, parseInt(rgbMatch[3])),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// hsl(h, s%, l%)
|
|
150
|
+
const hslMatch = trimmed.match(/^hsl\(\s*(\d{1,3})\s*,\s*(\d{1,3})%\s*,\s*(\d{1,3})%\s*\)$/);
|
|
151
|
+
if (hslMatch) {
|
|
152
|
+
return hslToRgb({
|
|
153
|
+
h: Math.min(360, parseInt(hslMatch[1])),
|
|
154
|
+
s: Math.min(100, parseInt(hslMatch[2])),
|
|
155
|
+
l: Math.min(100, parseInt(hslMatch[3])),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function formatColor(rgb: RGB, format: ColorFormat, alpha?: number): string {
|
|
163
|
+
switch (format) {
|
|
164
|
+
case 'hex':
|
|
165
|
+
return rgbToHex(rgb);
|
|
166
|
+
case 'rgb':
|
|
167
|
+
if (alpha !== undefined && alpha < 1) {
|
|
168
|
+
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha.toFixed(2)})`;
|
|
169
|
+
}
|
|
170
|
+
return `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`;
|
|
171
|
+
case 'hsl': {
|
|
172
|
+
const hsl = rgbToHsl(rgb);
|
|
173
|
+
if (alpha !== undefined && alpha < 1) {
|
|
174
|
+
return `hsla(${hsl.h}, ${hsl.s}%, ${hsl.l}%, ${alpha.toFixed(2)})`;
|
|
175
|
+
}
|
|
176
|
+
return `hsl(${hsl.h}, ${hsl.s}%, ${hsl.l}%)`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function isValidColor(input: string): boolean {
|
|
182
|
+
return parseColor(input) !== null;
|
|
183
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
export const colorPickerTriggerVariants = cva(
|
|
4
|
+
'inline-flex w-full items-center gap-2 whitespace-nowrap rounded-sm border border-border bg-background px-3 py-2 text-sm ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
5
|
+
{
|
|
6
|
+
variants: {
|
|
7
|
+
size: {
|
|
8
|
+
sm: 'h-9 text-xs',
|
|
9
|
+
md: 'h-10 text-sm',
|
|
10
|
+
lg: 'h-11 text-base',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
defaultVariants: { size: 'md' },
|
|
14
|
+
}
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export type ColorPickerSize = 'sm' | 'md' | 'lg';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { SnyColorPickerComponent } from './color-picker.component';
|
|
2
|
+
export { colorPickerTriggerVariants, type ColorPickerSize } from './color-picker.variants';
|
|
3
|
+
export type {
|
|
4
|
+
ColorFormat,
|
|
5
|
+
RGB,
|
|
6
|
+
HSL,
|
|
7
|
+
HSV,
|
|
8
|
+
ColorPickerPreset,
|
|
9
|
+
} from './color-picker.types';
|
|
10
|
+
export {
|
|
11
|
+
hexToRgb,
|
|
12
|
+
rgbToHex,
|
|
13
|
+
rgbToHsl,
|
|
14
|
+
hslToRgb,
|
|
15
|
+
rgbToHsv,
|
|
16
|
+
hsvToRgb,
|
|
17
|
+
parseColor,
|
|
18
|
+
formatColor,
|
|
19
|
+
isValidColor,
|
|
20
|
+
} from './color-picker.utils';
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Component, inject } from '@angular/core';
|
|
2
|
+
import { TestBed, type ComponentFixture } from '@angular/core/testing';
|
|
3
|
+
import { SnyCommandPaletteService } from './command-palette.service';
|
|
4
|
+
import { SnyDialogService } from '../modal/dialog.service';
|
|
5
|
+
import type { Command } from './command-palette.types';
|
|
6
|
+
|
|
7
|
+
function createCommands(): Command[] {
|
|
8
|
+
return [
|
|
9
|
+
{ id: 'light', label: 'Set Light Theme', group: 'Theme', action: vi.fn() },
|
|
10
|
+
{ id: 'dark', label: 'Set Dark Theme', group: 'Theme', action: vi.fn() },
|
|
11
|
+
{ id: 'home', label: 'Go to Home', group: 'Navigation', action: vi.fn(), shortcut: 'Ctrl+H' },
|
|
12
|
+
{ id: 'settings', label: 'Open Settings', group: 'Navigation', action: vi.fn(), description: 'Open the settings page' },
|
|
13
|
+
{ id: 'copy', label: 'Copy URL', action: vi.fn(), keywords: ['clipboard', 'link'] },
|
|
14
|
+
{ id: 'disabled', label: 'Disabled Command', disabled: true, action: vi.fn() },
|
|
15
|
+
];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@Component({
|
|
19
|
+
standalone: true,
|
|
20
|
+
template: `<button (click)="open()">Open</button>`,
|
|
21
|
+
})
|
|
22
|
+
class TestHostComponent {
|
|
23
|
+
readonly service = inject(SnyCommandPaletteService);
|
|
24
|
+
commands = createCommands();
|
|
25
|
+
|
|
26
|
+
open(): void {
|
|
27
|
+
this.service.open({ commands: this.commands });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('SnyCommandPaletteComponent', () => {
|
|
32
|
+
let fixture: ComponentFixture<TestHostComponent>;
|
|
33
|
+
let host: TestHostComponent;
|
|
34
|
+
|
|
35
|
+
beforeEach(async () => {
|
|
36
|
+
await TestBed.configureTestingModule({
|
|
37
|
+
imports: [TestHostComponent],
|
|
38
|
+
providers: [SnyDialogService, SnyCommandPaletteService],
|
|
39
|
+
}).compileComponents();
|
|
40
|
+
fixture = TestBed.createComponent(TestHostComponent);
|
|
41
|
+
host = fixture.componentInstance;
|
|
42
|
+
fixture.detectChanges();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function openPalette(): void {
|
|
46
|
+
host.open();
|
|
47
|
+
fixture.detectChanges();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getPaletteEl(): HTMLElement | null {
|
|
51
|
+
return document.querySelector('sny-command-palette');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getButtons(): HTMLButtonElement[] {
|
|
55
|
+
const el = getPaletteEl();
|
|
56
|
+
return el ? Array.from(el.querySelectorAll('button[data-cmd-idx]')) as HTMLButtonElement[] : [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getInput(): HTMLInputElement | null {
|
|
60
|
+
const el = getPaletteEl();
|
|
61
|
+
return el ? el.querySelector('input') : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
host.service.close();
|
|
66
|
+
fixture.detectChanges();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should open the palette via service', () => {
|
|
70
|
+
openPalette();
|
|
71
|
+
expect(getPaletteEl()).not.toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should prevent double open', () => {
|
|
75
|
+
openPalette();
|
|
76
|
+
host.open();
|
|
77
|
+
fixture.detectChanges();
|
|
78
|
+
const palettes = document.querySelectorAll('sny-command-palette');
|
|
79
|
+
expect(palettes.length).toBe(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should render search input with placeholder', () => {
|
|
83
|
+
openPalette();
|
|
84
|
+
const input = getInput();
|
|
85
|
+
expect(input).not.toBeNull();
|
|
86
|
+
expect(input?.placeholder).toBe('Type a command...');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should render commands (excluding disabled)', () => {
|
|
90
|
+
openPalette();
|
|
91
|
+
const buttons = getButtons();
|
|
92
|
+
// 6 commands minus 1 disabled = 5
|
|
93
|
+
expect(buttons.length).toBe(5);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should render group headers', () => {
|
|
97
|
+
openPalette();
|
|
98
|
+
const el = getPaletteEl()!;
|
|
99
|
+
const groups = el.querySelectorAll('.uppercase');
|
|
100
|
+
const groupNames = Array.from(groups).map((g) => g.textContent?.trim());
|
|
101
|
+
expect(groupNames).toContain('Theme');
|
|
102
|
+
expect(groupNames).toContain('Navigation');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('should filter commands by query', () => {
|
|
106
|
+
openPalette();
|
|
107
|
+
const input = getInput()!;
|
|
108
|
+
input.value = 'dark';
|
|
109
|
+
input.dispatchEvent(new Event('input'));
|
|
110
|
+
fixture.detectChanges();
|
|
111
|
+
|
|
112
|
+
const buttons = getButtons();
|
|
113
|
+
expect(buttons.length).toBe(1);
|
|
114
|
+
expect(buttons[0].textContent).toContain('Set Dark Theme');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should filter by keywords', () => {
|
|
118
|
+
openPalette();
|
|
119
|
+
const input = getInput()!;
|
|
120
|
+
input.value = 'clipboard';
|
|
121
|
+
input.dispatchEvent(new Event('input'));
|
|
122
|
+
fixture.detectChanges();
|
|
123
|
+
|
|
124
|
+
const buttons = getButtons();
|
|
125
|
+
expect(buttons.length).toBe(1);
|
|
126
|
+
expect(buttons[0].textContent).toContain('Copy URL');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('should show empty state when no results', () => {
|
|
130
|
+
openPalette();
|
|
131
|
+
const input = getInput()!;
|
|
132
|
+
input.value = 'xyznonexistent';
|
|
133
|
+
input.dispatchEvent(new Event('input'));
|
|
134
|
+
fixture.detectChanges();
|
|
135
|
+
|
|
136
|
+
const el = getPaletteEl()!;
|
|
137
|
+
expect(el.textContent).toContain('No results found.');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should render shortcuts', () => {
|
|
141
|
+
openPalette();
|
|
142
|
+
const el = getPaletteEl()!;
|
|
143
|
+
const kbds = el.querySelectorAll('kbd');
|
|
144
|
+
const texts = Array.from(kbds).map((k) => k.textContent?.trim());
|
|
145
|
+
expect(texts).toContain('Ctrl+H');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should render descriptions', () => {
|
|
149
|
+
openPalette();
|
|
150
|
+
const el = getPaletteEl()!;
|
|
151
|
+
expect(el.textContent).toContain('Open the settings page');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should execute command on click and close', () => {
|
|
155
|
+
openPalette();
|
|
156
|
+
const buttons = getButtons();
|
|
157
|
+
buttons[0].click();
|
|
158
|
+
fixture.detectChanges();
|
|
159
|
+
|
|
160
|
+
expect(host.commands[0].action).toHaveBeenCalled();
|
|
161
|
+
// Palette should be closed
|
|
162
|
+
setTimeout(() => {
|
|
163
|
+
expect(getPaletteEl()).toBeNull();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should navigate with arrow keys', () => {
|
|
168
|
+
openPalette();
|
|
169
|
+
const el = getPaletteEl()!;
|
|
170
|
+
|
|
171
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
172
|
+
fixture.detectChanges();
|
|
173
|
+
|
|
174
|
+
const buttons = getButtons();
|
|
175
|
+
// Second button should be active
|
|
176
|
+
expect(buttons[1].className).toContain('bg-accent');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterNextRender,
|
|
3
|
+
ChangeDetectionStrategy,
|
|
4
|
+
Component,
|
|
5
|
+
computed,
|
|
6
|
+
ElementRef,
|
|
7
|
+
inject,
|
|
8
|
+
signal,
|
|
9
|
+
viewChild,
|
|
10
|
+
} from '@angular/core';
|
|
11
|
+
import { DialogRef } from '@angular/cdk/dialog';
|
|
12
|
+
import { SnyInputDirective } from '../input/input.directive';
|
|
13
|
+
import { SNY_DIALOG_DATA } from '../modal/dialog.service';
|
|
14
|
+
import type { Command, CommandGroup, CommandPaletteConfig } from './command-palette.types';
|
|
15
|
+
|
|
16
|
+
@Component({
|
|
17
|
+
selector: 'sny-command-palette',
|
|
18
|
+
standalone: true,
|
|
19
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
20
|
+
imports: [SnyInputDirective],
|
|
21
|
+
host: {
|
|
22
|
+
'(keydown)': 'onKeydown($event)',
|
|
23
|
+
'class': 'block',
|
|
24
|
+
},
|
|
25
|
+
styles: `
|
|
26
|
+
:host {
|
|
27
|
+
display: block;
|
|
28
|
+
background-color: var(--sny-background);
|
|
29
|
+
border: 1px solid var(--sny-border);
|
|
30
|
+
border-radius: 0.5rem;
|
|
31
|
+
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
}
|
|
34
|
+
`,
|
|
35
|
+
template: `
|
|
36
|
+
<!-- Search -->
|
|
37
|
+
<div class="flex items-center gap-2 border-b border-border px-4 py-3">
|
|
38
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground shrink-0"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
|
39
|
+
<input
|
|
40
|
+
#searchInput
|
|
41
|
+
snyInput
|
|
42
|
+
class="border-none shadow-none h-8 px-0 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
43
|
+
[placeholder]="config.placeholder ?? 'Type a command...'"
|
|
44
|
+
[value]="query()"
|
|
45
|
+
(input)="onQueryChange(searchInput.value)"
|
|
46
|
+
/>
|
|
47
|
+
<kbd class="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground shrink-0">Esc</kbd>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<!-- Results -->
|
|
51
|
+
<div class="max-h-[300px] overflow-y-auto p-2 sny-scrollbar">
|
|
52
|
+
@if (flatResults().length === 0) {
|
|
53
|
+
<p class="py-6 text-center text-sm text-muted-foreground">
|
|
54
|
+
{{ config.emptyText ?? 'No results found.' }}
|
|
55
|
+
</p>
|
|
56
|
+
} @else {
|
|
57
|
+
@for (group of filteredGroups(); track group.name) {
|
|
58
|
+
@if (group.name) {
|
|
59
|
+
<p class="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">{{ group.name }}</p>
|
|
60
|
+
}
|
|
61
|
+
@for (cmd of group.commands; track cmd.id) {
|
|
62
|
+
@let idx = flatIndex(cmd);
|
|
63
|
+
<button
|
|
64
|
+
[attr.data-cmd-idx]="idx"
|
|
65
|
+
[class]="
|
|
66
|
+
'w-full text-left rounded-sm px-3 py-2 text-sm transition-colors flex items-center gap-3 cursor-pointer ' +
|
|
67
|
+
(idx === activeIndex()
|
|
68
|
+
? 'bg-accent text-accent-foreground'
|
|
69
|
+
: 'text-foreground hover:bg-accent/50')
|
|
70
|
+
"
|
|
71
|
+
(click)="execute(cmd)"
|
|
72
|
+
(mouseenter)="activeIndex.set(idx)"
|
|
73
|
+
>
|
|
74
|
+
@if (cmd.icon) {
|
|
75
|
+
<span class="shrink-0 w-5 text-center text-muted-foreground" [innerHTML]="cmd.icon"></span>
|
|
76
|
+
}
|
|
77
|
+
<div class="flex-1 min-w-0">
|
|
78
|
+
<span class="block truncate font-medium">{{ cmd.label }}</span>
|
|
79
|
+
@if (cmd.description) {
|
|
80
|
+
<span class="block text-xs text-muted-foreground truncate">{{ cmd.description }}</span>
|
|
81
|
+
}
|
|
82
|
+
</div>
|
|
83
|
+
@if (cmd.shortcut) {
|
|
84
|
+
<kbd class="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground shrink-0">{{ cmd.shortcut }}</kbd>
|
|
85
|
+
}
|
|
86
|
+
</button>
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<!-- Footer -->
|
|
93
|
+
<div class="border-t border-border px-4 py-2">
|
|
94
|
+
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
|
95
|
+
<span class="flex items-center gap-1">
|
|
96
|
+
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-mono text-[10px]">↑↓</kbd>
|
|
97
|
+
Navigate
|
|
98
|
+
</span>
|
|
99
|
+
<span class="flex items-center gap-1">
|
|
100
|
+
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-mono text-[10px]">↵</kbd>
|
|
101
|
+
Execute
|
|
102
|
+
</span>
|
|
103
|
+
<span class="flex items-center gap-1">
|
|
104
|
+
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-mono text-[10px]">Esc</kbd>
|
|
105
|
+
Close
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
`,
|
|
110
|
+
})
|
|
111
|
+
export class SnyCommandPaletteComponent {
|
|
112
|
+
readonly config = inject<CommandPaletteConfig>(SNY_DIALOG_DATA);
|
|
113
|
+
private readonly dialogRef = inject(DialogRef);
|
|
114
|
+
private readonly searchInput = viewChild<ElementRef<HTMLInputElement>>('searchInput');
|
|
115
|
+
|
|
116
|
+
readonly query = signal('');
|
|
117
|
+
readonly activeIndex = signal(0);
|
|
118
|
+
|
|
119
|
+
readonly filteredGroups = computed<CommandGroup[]>(() => {
|
|
120
|
+
const q = this.query().toLowerCase().trim();
|
|
121
|
+
const commands = this.config.commands.filter((cmd) => {
|
|
122
|
+
if (cmd.disabled) return false;
|
|
123
|
+
if (!q) return true;
|
|
124
|
+
return (
|
|
125
|
+
cmd.label.toLowerCase().includes(q) ||
|
|
126
|
+
cmd.description?.toLowerCase().includes(q) ||
|
|
127
|
+
cmd.keywords?.some((k) => k.toLowerCase().includes(q))
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const groups = new Map<string, Command[]>();
|
|
132
|
+
for (const cmd of commands) {
|
|
133
|
+
const group = cmd.group ?? '';
|
|
134
|
+
if (!groups.has(group)) groups.set(group, []);
|
|
135
|
+
groups.get(group)!.push(cmd);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return [...groups.entries()].map(([name, cmds]) => ({ name, commands: cmds }));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
readonly flatResults = computed(() =>
|
|
142
|
+
this.filteredGroups().flatMap((g) => g.commands)
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
constructor() {
|
|
146
|
+
afterNextRender(() => {
|
|
147
|
+
this.searchInput()?.nativeElement.focus();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
onQueryChange(value: string): void {
|
|
152
|
+
this.query.set(value);
|
|
153
|
+
this.activeIndex.set(0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
flatIndex(cmd: Command): number {
|
|
157
|
+
return this.flatResults().indexOf(cmd);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
execute(cmd: Command): void {
|
|
161
|
+
this.dialogRef.close();
|
|
162
|
+
cmd.action();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
onKeydown(event: KeyboardEvent): void {
|
|
166
|
+
const results = this.flatResults();
|
|
167
|
+
const len = results.length;
|
|
168
|
+
if (len === 0 && event.key !== 'Escape') return;
|
|
169
|
+
|
|
170
|
+
switch (event.key) {
|
|
171
|
+
case 'ArrowDown':
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
this.activeIndex.update((i) => (i + 1) % len);
|
|
174
|
+
this.scrollActiveIntoView();
|
|
175
|
+
break;
|
|
176
|
+
case 'ArrowUp':
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
this.activeIndex.update((i) => (i - 1 + len) % len);
|
|
179
|
+
this.scrollActiveIntoView();
|
|
180
|
+
break;
|
|
181
|
+
case 'Enter':
|
|
182
|
+
event.preventDefault();
|
|
183
|
+
const active = results[this.activeIndex()];
|
|
184
|
+
if (active) this.execute(active);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private scrollActiveIntoView(): void {
|
|
190
|
+
requestAnimationFrame(() => {
|
|
191
|
+
const el = document.querySelector(`[data-cmd-idx="${this.activeIndex()}"]`);
|
|
192
|
+
el?.scrollIntoView({ block: 'nearest' });
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Injectable, inject } from '@angular/core';
|
|
2
|
+
import { SnyDialogService } from '../modal/dialog.service';
|
|
3
|
+
import type { SnyDialogRef } from '../modal/dialog-ref';
|
|
4
|
+
import { SnyCommandPaletteComponent } from './command-palette.component';
|
|
5
|
+
import type { CommandPaletteConfig } from './command-palette.types';
|
|
6
|
+
|
|
7
|
+
@Injectable({ providedIn: 'root' })
|
|
8
|
+
export class SnyCommandPaletteService {
|
|
9
|
+
private readonly dialogService = inject(SnyDialogService);
|
|
10
|
+
private isOpen = false;
|
|
11
|
+
|
|
12
|
+
open(config: CommandPaletteConfig): SnyDialogRef<void> | null {
|
|
13
|
+
if (this.isOpen) return null;
|
|
14
|
+
this.isOpen = true;
|
|
15
|
+
|
|
16
|
+
const ref = this.dialogService.open<SnyCommandPaletteComponent, void>(
|
|
17
|
+
SnyCommandPaletteComponent,
|
|
18
|
+
{
|
|
19
|
+
width: config.width ?? '32rem',
|
|
20
|
+
data: config,
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
ref.closed.subscribe(() => {
|
|
25
|
+
this.isOpen = false;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
return ref;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
close(): void {
|
|
32
|
+
if (this.isOpen) {
|
|
33
|
+
this.dialogService.closeAll();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface Command {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
icon?: string;
|
|
6
|
+
group?: string;
|
|
7
|
+
keywords?: string[];
|
|
8
|
+
shortcut?: string;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
action: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CommandGroup {
|
|
14
|
+
name: string;
|
|
15
|
+
commands: Command[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CommandPaletteConfig {
|
|
19
|
+
commands: Command[];
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
emptyText?: string;
|
|
22
|
+
width?: string;
|
|
23
|
+
}
|