@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.
@@ -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]">&uarr;&darr;</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]">&crarr;</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
+ }
@@ -0,0 +1,7 @@
1
+ export { SnyCommandPaletteComponent } from './command-palette.component';
2
+ export { SnyCommandPaletteService } from './command-palette.service';
3
+ export type {
4
+ Command,
5
+ CommandGroup,
6
+ CommandPaletteConfig,
7
+ } from './command-palette.types';
@@ -0,0 +1,2 @@
1
+ export { SnyOtpInputComponent } from './otp-input.component';
2
+ export { otpCellVariants, type OtpInputSize, type OtpInputType } from './otp-input.variants';