@sonny-ui/core 0.1.0-alpha.7 → 0.1.0-alpha.9

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.
Files changed (94) hide show
  1. package/package.json +1 -1
  2. package/schematics/ng-generate/component/index.js +1 -1
  3. package/src/lib/accordion/accordion.directives.spec.ts +95 -0
  4. package/src/lib/accordion/accordion.directives.ts +104 -0
  5. package/src/lib/accordion/index.ts +8 -0
  6. package/src/lib/avatar/avatar.component.spec.ts +75 -0
  7. package/src/lib/avatar/avatar.component.ts +43 -0
  8. package/src/lib/avatar/avatar.variants.ts +26 -0
  9. package/src/lib/avatar/index.ts +2 -0
  10. package/src/lib/badge/badge.directive.spec.ts +74 -0
  11. package/src/lib/badge/badge.directive.ts +18 -0
  12. package/src/lib/badge/badge.variants.ts +29 -0
  13. package/src/lib/badge/index.ts +2 -0
  14. package/src/lib/breadcrumb/breadcrumb.directives.spec.ts +80 -0
  15. package/src/lib/breadcrumb/breadcrumb.directives.ts +84 -0
  16. package/src/lib/breadcrumb/index.ts +8 -0
  17. package/src/lib/button/button.directive.spec.ts +92 -0
  18. package/src/lib/button/button.directive.ts +29 -0
  19. package/src/lib/button/button.variants.ts +30 -0
  20. package/src/lib/button/index.ts +2 -0
  21. package/src/lib/button-group/button-group.directive.spec.ts +46 -0
  22. package/src/lib/button-group/button-group.directive.ts +20 -0
  23. package/src/lib/button-group/button-group.variants.ts +18 -0
  24. package/src/lib/button-group/index.ts +2 -0
  25. package/src/lib/card/card.directives.spec.ts +104 -0
  26. package/src/lib/card/card.directives.ts +78 -0
  27. package/src/lib/card/card.variants.ts +28 -0
  28. package/src/lib/card/index.ts +9 -0
  29. package/src/lib/checkbox/checkbox.directive.spec.ts +57 -0
  30. package/src/lib/checkbox/checkbox.directive.ts +17 -0
  31. package/src/lib/checkbox/checkbox.variants.ts +19 -0
  32. package/src/lib/checkbox/index.ts +2 -0
  33. package/src/lib/combobox/combobox.component.spec.ts +93 -0
  34. package/src/lib/combobox/combobox.component.ts +236 -0
  35. package/src/lib/combobox/combobox.variants.ts +19 -0
  36. package/src/lib/combobox/index.ts +2 -0
  37. package/src/lib/input/index.ts +3 -0
  38. package/src/lib/input/input.directive.spec.ts +103 -0
  39. package/src/lib/input/input.directive.ts +26 -0
  40. package/src/lib/input/input.variants.ts +42 -0
  41. package/src/lib/input/label.directive.ts +17 -0
  42. package/src/lib/loader/index.ts +2 -0
  43. package/src/lib/loader/loader.component.spec.ts +58 -0
  44. package/src/lib/loader/loader.component.ts +47 -0
  45. package/src/lib/loader/loader.variants.ts +21 -0
  46. package/src/lib/modal/dialog-ref.ts +19 -0
  47. package/src/lib/modal/dialog.directives.ts +90 -0
  48. package/src/lib/modal/dialog.service.spec.ts +52 -0
  49. package/src/lib/modal/dialog.service.ts +61 -0
  50. package/src/lib/modal/dialog.types.ts +16 -0
  51. package/src/lib/modal/index.ts +11 -0
  52. package/src/lib/radio/index.ts +2 -0
  53. package/src/lib/radio/radio.directive.spec.ts +46 -0
  54. package/src/lib/radio/radio.directive.ts +17 -0
  55. package/src/lib/radio/radio.variants.ts +19 -0
  56. package/src/lib/select/index.ts +2 -0
  57. package/src/lib/select/select.component.spec.ts +56 -0
  58. package/src/lib/select/select.component.ts +206 -0
  59. package/src/lib/select/select.variants.ts +19 -0
  60. package/src/lib/sheet/index.ts +10 -0
  61. package/src/lib/sheet/sheet-ref.ts +18 -0
  62. package/src/lib/sheet/sheet.component.spec.ts +67 -0
  63. package/src/lib/sheet/sheet.directives.ts +75 -0
  64. package/src/lib/sheet/sheet.service.ts +100 -0
  65. package/src/lib/sheet/sheet.types.ts +23 -0
  66. package/src/lib/skeleton/index.ts +2 -0
  67. package/src/lib/skeleton/skeleton.directive.spec.ts +55 -0
  68. package/src/lib/skeleton/skeleton.directive.ts +18 -0
  69. package/src/lib/skeleton/skeleton.variants.ts +27 -0
  70. package/src/lib/slider/index.ts +2 -0
  71. package/src/lib/slider/slider.component.spec.ts +55 -0
  72. package/src/lib/slider/slider.component.ts +141 -0
  73. package/src/lib/slider/slider.variants.ts +25 -0
  74. package/src/lib/switch/index.ts +2 -0
  75. package/src/lib/switch/switch.component.spec.ts +50 -0
  76. package/src/lib/switch/switch.component.ts +43 -0
  77. package/src/lib/switch/switch.variants.ts +31 -0
  78. package/src/lib/table/index.ts +12 -0
  79. package/src/lib/table/table.directives.spec.ts +111 -0
  80. package/src/lib/table/table.directives.ts +134 -0
  81. package/src/lib/table/table.variants.ts +36 -0
  82. package/src/lib/tabs/index.ts +8 -0
  83. package/src/lib/tabs/tabs.directives.spec.ts +66 -0
  84. package/src/lib/tabs/tabs.directives.ts +91 -0
  85. package/src/lib/tabs/tabs.variants.ts +17 -0
  86. package/src/lib/toast/index.ts +3 -0
  87. package/src/lib/toast/toast.service.spec.ts +71 -0
  88. package/src/lib/toast/toast.service.ts +60 -0
  89. package/src/lib/toast/toast.variants.ts +38 -0
  90. package/src/lib/toast/toaster.component.ts +80 -0
  91. package/src/lib/toggle/index.ts +2 -0
  92. package/src/lib/toggle/toggle.directive.spec.ts +52 -0
  93. package/src/lib/toggle/toggle.directive.ts +27 -0
  94. package/src/lib/toggle/toggle.variants.ts +25 -0
@@ -0,0 +1,90 @@
1
+ import { Directive, computed, input, inject } from '@angular/core';
2
+ import { DialogRef } from '@angular/cdk/dialog';
3
+ import { cn } from '../core/utils/cn';
4
+
5
+ @Directive({
6
+ selector: '[snyDialogHeader]',
7
+ standalone: true,
8
+ host: { '[class]': 'computedClass()' },
9
+ })
10
+ export class SnyDialogHeaderDirective {
11
+ readonly class = input<string>('');
12
+ protected readonly computedClass = computed(() =>
13
+ cn('flex flex-col space-y-1.5 text-center sm:text-left', this.class())
14
+ );
15
+ }
16
+
17
+ @Directive({
18
+ selector: '[snyDialogTitle]',
19
+ standalone: true,
20
+ host: { '[class]': 'computedClass()' },
21
+ })
22
+ export class SnyDialogTitleDirective {
23
+ readonly class = input<string>('');
24
+ protected readonly computedClass = computed(() =>
25
+ cn('text-lg font-semibold leading-none tracking-tight', this.class())
26
+ );
27
+ }
28
+
29
+ @Directive({
30
+ selector: '[snyDialogDescription]',
31
+ standalone: true,
32
+ host: { '[class]': 'computedClass()' },
33
+ })
34
+ export class SnyDialogDescriptionDirective {
35
+ readonly class = input<string>('');
36
+ protected readonly computedClass = computed(() =>
37
+ cn('text-sm text-muted-foreground', this.class())
38
+ );
39
+ }
40
+
41
+ @Directive({
42
+ selector: '[snyDialogContent]',
43
+ standalone: true,
44
+ host: { '[class]': 'computedClass()' },
45
+ })
46
+ export class SnyDialogContentDirective {
47
+ readonly class = input<string>('');
48
+ protected readonly computedClass = computed(() =>
49
+ cn(
50
+ 'relative bg-background rounded-sm border border-border shadow-lg p-6 w-full max-w-lg mx-auto',
51
+ this.class()
52
+ )
53
+ );
54
+ }
55
+
56
+ @Directive({
57
+ selector: '[snyDialogFooter]',
58
+ standalone: true,
59
+ host: { '[class]': 'computedClass()' },
60
+ })
61
+ export class SnyDialogFooterDirective {
62
+ readonly class = input<string>('');
63
+ protected readonly computedClass = computed(() =>
64
+ cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', this.class())
65
+ );
66
+ }
67
+
68
+ @Directive({
69
+ selector: '[snyDialogClose]',
70
+ standalone: true,
71
+ host: {
72
+ '[class]': 'computedClass()',
73
+ '(click)': 'onClick()',
74
+ },
75
+ })
76
+ export class SnyDialogCloseDirective {
77
+ readonly class = input<string>('');
78
+ protected readonly computedClass = computed(() =>
79
+ cn(
80
+ 'absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none',
81
+ this.class()
82
+ )
83
+ );
84
+
85
+ private readonly dialogRef = inject(DialogRef, { optional: true });
86
+
87
+ onClick(): void {
88
+ this.dialogRef?.close();
89
+ }
90
+ }
@@ -0,0 +1,52 @@
1
+ import { Component } from '@angular/core';
2
+ import { TestBed } from '@angular/core/testing';
3
+ import { DialogModule } from '@angular/cdk/dialog';
4
+ import { SnyDialogService } from './dialog.service';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ template: `<div>Dialog Content</div>`,
9
+ })
10
+ class TestDialogComponent {}
11
+
12
+ describe('SnyDialogService', () => {
13
+ let service: SnyDialogService;
14
+
15
+ beforeEach(() => {
16
+ TestBed.configureTestingModule({
17
+ imports: [DialogModule],
18
+ });
19
+ service = TestBed.inject(SnyDialogService);
20
+ });
21
+
22
+ afterEach(() => {
23
+ service.closeAll();
24
+ });
25
+
26
+ it('should open a dialog', () => {
27
+ const ref = service.open(TestDialogComponent);
28
+ expect(ref).toBeTruthy();
29
+ ref.close();
30
+ });
31
+
32
+ it('should close a dialog', async () => {
33
+ const ref = service.open(TestDialogComponent);
34
+ let closed = false;
35
+ ref.closed.subscribe(() => (closed = true));
36
+ ref.close();
37
+ await new Promise(resolve => setTimeout(resolve, 50));
38
+ expect(closed).toBe(true);
39
+ });
40
+
41
+ it('should close all dialogs', () => {
42
+ service.open(TestDialogComponent);
43
+ service.open(TestDialogComponent);
44
+ service.closeAll();
45
+ });
46
+
47
+ it('should accept width config', () => {
48
+ const ref = service.open(TestDialogComponent, { width: '600px' });
49
+ expect(ref).toBeTruthy();
50
+ ref.close();
51
+ });
52
+ });
@@ -0,0 +1,61 @@
1
+ import { Injectable, inject, InjectionToken } from '@angular/core';
2
+ import { Dialog, DialogRef as CdkDialogRef } from '@angular/cdk/dialog';
3
+ import type { ComponentType } from '@angular/cdk/overlay';
4
+ import { SnyDialogRef } from './dialog-ref';
5
+ import { DEFAULT_DIALOG_CONFIG, type SnyDialogConfig } from './dialog.types';
6
+
7
+ export const SNY_DIALOG_DATA = new InjectionToken<unknown>('SNY_DIALOG_DATA');
8
+
9
+ @Injectable({ providedIn: 'root' })
10
+ export class SnyDialogService {
11
+ private readonly cdkDialog = inject(Dialog);
12
+
13
+ open<T, R = unknown>(
14
+ component: ComponentType<T>,
15
+ config: SnyDialogConfig = {}
16
+ ): SnyDialogRef<R> {
17
+ const merged = { ...DEFAULT_DIALOG_CONFIG, ...config };
18
+
19
+ // CDK's disableClose controls both backdrop and ESC together.
20
+ // To support independent closeOnBackdrop / closeOnEsc, we disable both
21
+ // at the CDK level and handle them manually.
22
+ const disableClose = !merged.closeOnBackdrop || !merged.closeOnEsc;
23
+
24
+ const cdkRef: CdkDialogRef<R, T> = this.cdkDialog.open(component, {
25
+ width: merged.width,
26
+ maxWidth: merged.maxWidth,
27
+ disableClose,
28
+ hasBackdrop: true,
29
+ backdropClass: 'sny-dialog-backdrop',
30
+ panelClass: 'sny-dialog-panel',
31
+ ariaLabelledBy: merged.ariaLabelledBy,
32
+ ariaDescribedBy: merged.ariaDescribedBy,
33
+ data: merged.data,
34
+ providers: merged.data != null
35
+ ? [{ provide: SNY_DIALOG_DATA, useValue: merged.data }]
36
+ : [],
37
+ });
38
+
39
+ // When CDK disableClose is true, manually handle backdrop/ESC based on config
40
+ if (disableClose) {
41
+ if (merged.closeOnBackdrop) {
42
+ const sub = cdkRef.backdropClick.subscribe(() => cdkRef.close());
43
+ cdkRef.closed.subscribe(() => sub.unsubscribe());
44
+ }
45
+ if (merged.closeOnEsc) {
46
+ const sub = cdkRef.keydownEvents.subscribe(event => {
47
+ if (event.key === 'Escape') {
48
+ cdkRef.close();
49
+ }
50
+ });
51
+ cdkRef.closed.subscribe(() => sub.unsubscribe());
52
+ }
53
+ }
54
+
55
+ return new SnyDialogRef<R>(cdkRef);
56
+ }
57
+
58
+ closeAll(): void {
59
+ this.cdkDialog.closeAll();
60
+ }
61
+ }
@@ -0,0 +1,16 @@
1
+ export interface SnyDialogConfig {
2
+ width?: string;
3
+ maxWidth?: string;
4
+ closeOnBackdrop?: boolean;
5
+ closeOnEsc?: boolean;
6
+ data?: unknown;
7
+ ariaLabelledBy?: string;
8
+ ariaDescribedBy?: string;
9
+ }
10
+
11
+ export const DEFAULT_DIALOG_CONFIG: SnyDialogConfig = {
12
+ width: '28rem',
13
+ maxWidth: '90vw',
14
+ closeOnBackdrop: true,
15
+ closeOnEsc: true,
16
+ };
@@ -0,0 +1,11 @@
1
+ export { SnyDialogService, SNY_DIALOG_DATA } from './dialog.service';
2
+ export { SnyDialogRef } from './dialog-ref';
3
+ export {
4
+ SnyDialogHeaderDirective,
5
+ SnyDialogTitleDirective,
6
+ SnyDialogDescriptionDirective,
7
+ SnyDialogContentDirective,
8
+ SnyDialogFooterDirective,
9
+ SnyDialogCloseDirective,
10
+ } from './dialog.directives';
11
+ export { type SnyDialogConfig } from './dialog.types';
@@ -0,0 +1,2 @@
1
+ export { SnyRadioDirective } from './radio.directive';
2
+ export { radioVariants, type RadioSize } from './radio.variants';
@@ -0,0 +1,46 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import { SnyRadioDirective } from './radio.directive';
4
+ import type { RadioSize } from './radio.variants';
5
+
6
+ @Component({
7
+ standalone: true,
8
+ imports: [SnyRadioDirective],
9
+ template: `<input type="radio" snyRadio [size]="size()" />`,
10
+ })
11
+ class TestHostComponent {
12
+ size = signal<RadioSize>('md');
13
+ }
14
+
15
+ describe('SnyRadioDirective', () => {
16
+ let fixture: ComponentFixture<TestHostComponent>;
17
+ let el: HTMLInputElement;
18
+
19
+ beforeEach(async () => {
20
+ await TestBed.configureTestingModule({
21
+ imports: [TestHostComponent],
22
+ }).compileComponents();
23
+
24
+ fixture = TestBed.createComponent(TestHostComponent);
25
+ fixture.detectChanges();
26
+ el = fixture.nativeElement.querySelector('input');
27
+ });
28
+
29
+ it('should apply default classes', () => {
30
+ expect(el.className).toContain('appearance-none');
31
+ expect(el.className).toContain('rounded-full');
32
+ expect(el.className).toContain('h-4');
33
+ });
34
+
35
+ it('should apply sm size', () => {
36
+ fixture.componentInstance.size.set('sm');
37
+ fixture.detectChanges();
38
+ expect(el.className).toContain('h-3.5');
39
+ });
40
+
41
+ it('should apply lg size', () => {
42
+ fixture.componentInstance.size.set('lg');
43
+ fixture.detectChanges();
44
+ expect(el.className).toContain('h-5');
45
+ });
46
+ });
@@ -0,0 +1,17 @@
1
+ import { Directive, computed, input } from '@angular/core';
2
+ import { cn } from '../core/utils/cn';
3
+ import { radioVariants, type RadioSize } from './radio.variants';
4
+
5
+ @Directive({
6
+ selector: 'input[type="radio"][snyRadio]',
7
+ standalone: true,
8
+ host: { '[class]': 'computedClass()' },
9
+ })
10
+ export class SnyRadioDirective {
11
+ readonly size = input<RadioSize>('md');
12
+ readonly class = input<string>('');
13
+
14
+ protected readonly computedClass = computed(() =>
15
+ cn(radioVariants({ size: this.size() }), this.class())
16
+ );
17
+ }
@@ -0,0 +1,19 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const radioVariants = cva(
4
+ 'appearance-none rounded-full border border-border bg-background transition-colors checked:border-primary checked:shadow-[inset_0_0_0_3px] checked:shadow-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
5
+ {
6
+ variants: {
7
+ size: {
8
+ sm: 'h-3.5 w-3.5',
9
+ md: 'h-4 w-4',
10
+ lg: 'h-5 w-5',
11
+ },
12
+ },
13
+ defaultVariants: {
14
+ size: 'md',
15
+ },
16
+ }
17
+ );
18
+
19
+ export type RadioSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,2 @@
1
+ export { SnySelectComponent, type SelectOption } from './select.component';
2
+ export { selectTriggerVariants, type SelectSize } from './select.variants';
@@ -0,0 +1,56 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import { TestBed, ComponentFixture } from '@angular/core/testing';
3
+ import { SnySelectComponent, type SelectOption } from './select.component';
4
+
5
+ @Component({
6
+ standalone: true,
7
+ imports: [SnySelectComponent],
8
+ template: `<sny-select [options]="options" [(value)]="value" [placeholder]="placeholder()" />`,
9
+ })
10
+ class TestHostComponent {
11
+ options: SelectOption[] = [
12
+ { value: 'a', label: 'Option A' },
13
+ { value: 'b', label: 'Option B' },
14
+ { value: 'c', label: 'Option C' },
15
+ ];
16
+ value = signal('');
17
+ placeholder = signal('Pick one...');
18
+ }
19
+
20
+ describe('SnySelectComponent', () => {
21
+ let fixture: ComponentFixture<TestHostComponent>;
22
+ let trigger: HTMLButtonElement;
23
+
24
+ beforeEach(async () => {
25
+ await TestBed.configureTestingModule({
26
+ imports: [TestHostComponent],
27
+ }).compileComponents();
28
+
29
+ fixture = TestBed.createComponent(TestHostComponent);
30
+ fixture.detectChanges();
31
+ trigger = fixture.nativeElement.querySelector('button');
32
+ });
33
+
34
+ it('should show placeholder when no value', () => {
35
+ expect(trigger.textContent).toContain('Pick one...');
36
+ });
37
+
38
+ it('should have combobox role', () => {
39
+ expect(trigger.getAttribute('role')).toBe('combobox');
40
+ expect(trigger.getAttribute('aria-expanded')).toBe('false');
41
+ });
42
+
43
+ it('should open dropdown on click', () => {
44
+ trigger.click();
45
+ fixture.detectChanges();
46
+ expect(trigger.getAttribute('aria-expanded')).toBe('true');
47
+ const options = fixture.nativeElement.querySelectorAll('[role="option"]');
48
+ expect(options.length).toBe(3);
49
+ });
50
+
51
+ it('should show selected label', () => {
52
+ fixture.componentInstance.value.set('b');
53
+ fixture.detectChanges();
54
+ expect(trigger.textContent).toContain('Option B');
55
+ });
56
+ });
@@ -0,0 +1,206 @@
1
+ import {
2
+ Component,
3
+ computed,
4
+ ElementRef,
5
+ HostListener,
6
+ inject,
7
+ input,
8
+ model,
9
+ OnDestroy,
10
+ signal,
11
+ viewChild,
12
+ } from '@angular/core';
13
+ import { cn } from '../core/utils/cn';
14
+ import { selectTriggerVariants, type SelectSize } from './select.variants';
15
+
16
+ export interface SelectOption {
17
+ value: string;
18
+ label: string;
19
+ }
20
+
21
+ @Component({
22
+ selector: 'sny-select',
23
+ standalone: true,
24
+ host: {
25
+ class: 'relative inline-block w-full',
26
+ },
27
+ template: `
28
+ <button
29
+ #triggerEl
30
+ type="button"
31
+ role="combobox"
32
+ [attr.aria-expanded]="open()"
33
+ aria-haspopup="listbox"
34
+ [disabled]="disabled()"
35
+ [class]="triggerClass()"
36
+ (click)="toggle()"
37
+ (keydown)="onTriggerKeydown($event)"
38
+ >
39
+ <span [class]="selectedLabel() ? '' : 'text-muted-foreground'">
40
+ {{ selectedLabel() || placeholder() }}
41
+ </span>
42
+ <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="shrink-0 opacity-50"><path d="m6 9 6 6 6-6"/></svg>
43
+ </button>
44
+
45
+ @if (open()) {
46
+ <div
47
+ #dropdownEl
48
+ class="fixed z-50 rounded-sm border border-border bg-popover text-popover-foreground shadow-md"
49
+ >
50
+ <ul role="listbox" class="max-h-60 overflow-auto p-1">
51
+ @for (opt of options(); track opt.value; let i = $index) {
52
+ <li
53
+ role="option"
54
+ [attr.aria-selected]="value() === opt.value"
55
+ [class]="optionClass(i)"
56
+ (mousedown)="select(opt); $event.preventDefault()"
57
+ (mouseenter)="activeIndex.set(i)"
58
+ >
59
+ <svg
60
+ xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
61
+ fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
62
+ [class]="value() === opt.value ? 'mr-2 shrink-0 opacity-100' : 'mr-2 shrink-0 opacity-0'"
63
+ ><path d="M20 6 9 17l-5-5"/></svg>
64
+ {{ opt.label }}
65
+ </li>
66
+ }
67
+ </ul>
68
+ </div>
69
+ }
70
+ `,
71
+ })
72
+ export class SnySelectComponent implements OnDestroy {
73
+ readonly options = input<SelectOption[]>([]);
74
+ readonly placeholder = input('Select...');
75
+ readonly size = input<SelectSize>('md');
76
+ readonly disabled = input(false);
77
+ readonly class = input<string>('');
78
+ readonly value = model<string>('');
79
+
80
+ readonly open = signal(false);
81
+ readonly activeIndex = signal(0);
82
+
83
+ private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('triggerEl');
84
+ private readonly dropdownRef = viewChild<ElementRef<HTMLDivElement>>('dropdownEl');
85
+ private readonly elRef = inject(ElementRef);
86
+
87
+ private scrollHandler: (() => void) | null = null;
88
+ private resizeHandler: (() => void) | null = null;
89
+
90
+ readonly selectedLabel = computed(() => {
91
+ const v = this.value();
92
+ if (!v) return '';
93
+ const opt = this.options().find(o => o.value === v);
94
+ return opt?.label ?? '';
95
+ });
96
+
97
+ protected readonly triggerClass = computed(() =>
98
+ cn(selectTriggerVariants({ size: this.size() }), this.class())
99
+ );
100
+
101
+ optionClass(index: number): string {
102
+ const base = 'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors';
103
+ const active = index === this.activeIndex() ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50';
104
+ return cn(base, active);
105
+ }
106
+
107
+ private updateDropdownPosition(): void {
108
+ const trigger = this.triggerRef()?.nativeElement;
109
+ if (!trigger) return;
110
+ const rect = trigger.getBoundingClientRect();
111
+ const dropdown = this.dropdownRef()?.nativeElement;
112
+ if (dropdown) {
113
+ dropdown.style.top = `${rect.bottom + 4}px`;
114
+ dropdown.style.left = `${rect.left}px`;
115
+ dropdown.style.width = `${rect.width}px`;
116
+ }
117
+ }
118
+
119
+ private addGlobalListeners(): void {
120
+ this.removeGlobalListeners();
121
+ this.scrollHandler = () => {
122
+ requestAnimationFrame(() => this.updateDropdownPosition());
123
+ };
124
+ this.resizeHandler = () => {
125
+ requestAnimationFrame(() => this.updateDropdownPosition());
126
+ };
127
+ document.addEventListener('scroll', this.scrollHandler, { capture: true, passive: true });
128
+ window.addEventListener('resize', this.resizeHandler, { passive: true });
129
+ }
130
+
131
+ private removeGlobalListeners(): void {
132
+ if (this.scrollHandler) {
133
+ document.removeEventListener('scroll', this.scrollHandler, { capture: true } as EventListenerOptions);
134
+ this.scrollHandler = null;
135
+ }
136
+ if (this.resizeHandler) {
137
+ window.removeEventListener('resize', this.resizeHandler);
138
+ this.resizeHandler = null;
139
+ }
140
+ }
141
+
142
+ ngOnDestroy(): void {
143
+ this.removeGlobalListeners();
144
+ }
145
+
146
+ toggle(): void {
147
+ if (this.open()) {
148
+ this.close();
149
+ } else {
150
+ this.open.set(true);
151
+ this.activeIndex.set(
152
+ Math.max(0, this.options().findIndex(o => o.value === this.value()))
153
+ );
154
+ this.addGlobalListeners();
155
+ setTimeout(() => this.updateDropdownPosition());
156
+ }
157
+ }
158
+
159
+ close(): void {
160
+ this.open.set(false);
161
+ this.removeGlobalListeners();
162
+ }
163
+
164
+ select(opt: SelectOption): void {
165
+ this.value.set(opt.value);
166
+ this.close();
167
+ }
168
+
169
+ onTriggerKeydown(event: KeyboardEvent): void {
170
+ const items = this.options();
171
+ if (!this.open()) {
172
+ if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
173
+ event.preventDefault();
174
+ this.toggle();
175
+ }
176
+ return;
177
+ }
178
+ switch (event.key) {
179
+ case 'ArrowDown':
180
+ event.preventDefault();
181
+ this.activeIndex.update(i => Math.min(i + 1, items.length - 1));
182
+ break;
183
+ case 'ArrowUp':
184
+ event.preventDefault();
185
+ this.activeIndex.update(i => Math.max(i - 1, 0));
186
+ break;
187
+ case 'Enter':
188
+ case ' ':
189
+ event.preventDefault();
190
+ if (items[this.activeIndex()]) {
191
+ this.select(items[this.activeIndex()]);
192
+ }
193
+ break;
194
+ case 'Escape':
195
+ this.close();
196
+ break;
197
+ }
198
+ }
199
+
200
+ @HostListener('document:click', ['$event'])
201
+ onDocumentClick(event: MouseEvent): void {
202
+ if (!this.elRef.nativeElement.contains(event.target)) {
203
+ this.close();
204
+ }
205
+ }
206
+ }
@@ -0,0 +1,19 @@
1
+ import { cva } from 'class-variance-authority';
2
+
3
+ export const selectTriggerVariants = cva(
4
+ 'inline-flex w-full items-center justify-between 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: {
14
+ size: 'md',
15
+ },
16
+ }
17
+ );
18
+
19
+ export type SelectSize = 'sm' | 'md' | 'lg';
@@ -0,0 +1,10 @@
1
+ export { SnySheetService, SNY_SHEET_DATA } from './sheet.service';
2
+ export { SnySheetRef } from './sheet-ref';
3
+ export {
4
+ SnySheetHeaderDirective,
5
+ SnySheetTitleDirective,
6
+ SnySheetDescriptionDirective,
7
+ SnySheetContentDirective,
8
+ SnySheetCloseDirective,
9
+ } from './sheet.directives';
10
+ export { type SnySheetConfig, type SheetSide } from './sheet.types';
@@ -0,0 +1,18 @@
1
+ import type { Observable } from 'rxjs';
2
+
3
+ interface CdkDialogRefLike<R> {
4
+ close(result?: R): void;
5
+ readonly closed: Observable<R | undefined>;
6
+ }
7
+
8
+ export class SnySheetRef<R = unknown> {
9
+ constructor(private readonly cdkRef: CdkDialogRefLike<R>) {}
10
+
11
+ close(result?: R): void {
12
+ this.cdkRef.close(result);
13
+ }
14
+
15
+ get closed(): Observable<R | undefined> {
16
+ return this.cdkRef.closed;
17
+ }
18
+ }