@tolle_/tolle-ui 18.2.23 → 18.2.25
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/esm2022/lib/accordion-item.component.mjs +17 -5
- package/esm2022/lib/alert-dialog-dynamic.component.mjs +1 -1
- package/esm2022/lib/alert-dialog.component.mjs +48 -10
- package/esm2022/lib/alert-dialog.service.mjs +13 -2
- package/esm2022/lib/alert.component.mjs +5 -5
- package/esm2022/lib/avatar.component.mjs +30 -9
- package/esm2022/lib/badge.component.mjs +41 -15
- package/esm2022/lib/breadcrumb-link.component.mjs +5 -5
- package/esm2022/lib/button.component.mjs +14 -3
- package/esm2022/lib/calendar.component.mjs +99 -17
- package/esm2022/lib/card.component.mjs +29 -9
- package/esm2022/lib/carousel/carousel.component.mjs +19 -3
- package/esm2022/lib/checkbox.component.mjs +44 -21
- package/esm2022/lib/collapsible.component.mjs +12 -4
- package/esm2022/lib/country-selector.component.mjs +140 -20
- package/esm2022/lib/data-table.component.mjs +27 -16
- package/esm2022/lib/date-picker.component.mjs +60 -25
- package/esm2022/lib/date-range-picker.component.mjs +64 -26
- package/esm2022/lib/dropdown-item.component.mjs +19 -5
- package/esm2022/lib/dropdown-label.component.mjs +3 -3
- package/esm2022/lib/dropdown-menu.component.mjs +3 -3
- package/esm2022/lib/dropdown-separator.component.mjs +3 -3
- package/esm2022/lib/dropdown-trigger.directive.mjs +7 -3
- package/esm2022/lib/empty-state.component.mjs +3 -3
- package/esm2022/lib/hover-card.component.mjs +4 -2
- package/esm2022/lib/input.component.mjs +7 -5
- package/esm2022/lib/label.component.mjs +2 -2
- package/esm2022/lib/masked-input.component.mjs +3 -3
- package/esm2022/lib/modal.component.mjs +58 -22
- package/esm2022/lib/modal.service.mjs +13 -1
- package/esm2022/lib/multi-select.component.mjs +228 -20
- package/esm2022/lib/otp.component.mjs +6 -3
- package/esm2022/lib/pagination.component.mjs +19 -9
- package/esm2022/lib/progress.component.mjs +8 -5
- package/esm2022/lib/radio-group.component.mjs +51 -3
- package/esm2022/lib/radio-item.component.mjs +46 -8
- package/esm2022/lib/radio-service.mjs +60 -1
- package/esm2022/lib/range-calendar.component.mjs +317 -98
- package/esm2022/lib/resizable-panel.component.mjs +5 -4
- package/esm2022/lib/scroll-area.component.mjs +5 -5
- package/esm2022/lib/segment.component.mjs +73 -7
- package/esm2022/lib/select-item.component.mjs +20 -2
- package/esm2022/lib/select-separator.component.mjs +3 -3
- package/esm2022/lib/select.component.mjs +221 -14
- package/esm2022/lib/separator.component.mjs +12 -6
- package/esm2022/lib/sheet-ref.mjs +8 -1
- package/esm2022/lib/sheet-wrapper.component.mjs +27 -11
- package/esm2022/lib/sheet.component.mjs +57 -16
- package/esm2022/lib/sheet.service.mjs +5 -1
- package/esm2022/lib/sidebar.component.mjs +73 -15
- package/esm2022/lib/skeleton.component.mjs +19 -5
- package/esm2022/lib/switch.component.mjs +59 -53
- package/esm2022/lib/tabs.component.mjs +75 -13
- package/esm2022/lib/tag-input.component.mjs +6 -3
- package/esm2022/lib/textarea.component.mjs +9 -7
- package/esm2022/lib/theme.service.mjs +114 -138
- package/esm2022/lib/toaster.component.mjs +19 -9
- package/esm2022/lib/toggle-group.component.mjs +76 -4
- package/esm2022/lib/tolle-config.mjs +1 -1
- package/esm2022/lib/tooltip.directive.mjs +41 -17
- package/esm2022/lib/utils/color.mjs +324 -0
- package/esm2022/public-api.mjs +2 -1
- package/fesm2022/tolle-ui.mjs +2715 -682
- package/fesm2022/tolle-ui.mjs.map +1 -1
- package/lib/alert-dialog.component.d.ts +10 -1
- package/lib/avatar.component.d.ts +16 -3
- package/lib/badge.component.d.ts +17 -5
- package/lib/button.component.d.ts +8 -1
- package/lib/calendar.component.d.ts +21 -3
- package/lib/card.component.d.ts +6 -0
- package/lib/carousel/carousel.component.d.ts +1 -0
- package/lib/checkbox.component.d.ts +12 -4
- package/lib/country-selector.component.d.ts +11 -2
- package/lib/date-picker.component.d.ts +2 -1
- package/lib/date-range-picker.component.d.ts +5 -2
- package/lib/dropdown-item.component.d.ts +4 -2
- package/lib/dropdown-trigger.directive.d.ts +1 -1
- package/lib/modal.component.d.ts +6 -0
- package/lib/multi-select.component.d.ts +20 -0
- package/lib/otp.component.d.ts +1 -0
- package/lib/progress.component.d.ts +1 -0
- package/lib/radio-group.component.d.ts +8 -0
- package/lib/radio-item.component.d.ts +10 -4
- package/lib/radio-service.d.ts +29 -0
- package/lib/range-calendar.component.d.ts +33 -4
- package/lib/segment.component.d.ts +13 -0
- package/lib/select-item.component.d.ts +4 -1
- package/lib/select.component.d.ts +20 -0
- package/lib/separator.component.d.ts +2 -0
- package/lib/sheet-wrapper.component.d.ts +4 -0
- package/lib/sheet.component.d.ts +10 -1
- package/lib/sidebar.component.d.ts +2 -0
- package/lib/skeleton.component.d.ts +9 -3
- package/lib/switch.component.d.ts +13 -20
- package/lib/tabs.component.d.ts +11 -1
- package/lib/theme.service.d.ts +21 -6
- package/lib/toaster.component.d.ts +2 -2
- package/lib/toggle-group.component.d.ts +20 -2
- package/lib/tolle-config.d.ts +8 -0
- package/lib/tooltip.directive.d.ts +3 -0
- package/lib/utils/color.d.ts +87 -0
- package/package.json +4 -1
- package/preset.js +28 -0
- package/public-api.d.ts +1 -0
- package/registry/docs-content.json +4214 -0
- package/registry/llms-full.txt +1599 -0
- package/registry/llms.txt +83 -0
- package/registry/manifest.json +4925 -0
- package/registry/r/accordion-item.json +21 -0
- package/registry/r/accordion.json +23 -0
- package/registry/r/alert-dialog-dynamic.json +31 -0
- package/registry/r/alert-dialog.json +26 -0
- package/registry/r/alert.json +23 -0
- package/registry/r/aspect-ratio.json +21 -0
- package/registry/r/avatar-fallback.json +16 -0
- package/registry/r/avatar.json +23 -0
- package/registry/r/badge.json +23 -0
- package/registry/r/breadcrumb-item.json +16 -0
- package/registry/r/breadcrumb-link.json +16 -0
- package/registry/r/breadcrumb-separator.json +16 -0
- package/registry/r/breadcrumb.json +21 -0
- package/registry/r/button-group.json +21 -0
- package/registry/r/button.json +23 -0
- package/registry/r/calendar.json +23 -0
- package/registry/r/card.json +21 -0
- package/registry/r/carousel.json +18 -0
- package/registry/r/checkbox.json +23 -0
- package/registry/r/collapsible.json +21 -0
- package/registry/r/context-menu-trigger.json +23 -0
- package/registry/r/context-menu.json +28 -0
- package/registry/r/country-selector.json +31 -0
- package/registry/r/data-table.json +38 -0
- package/registry/r/date-picker.json +27 -0
- package/registry/r/date-range-picker.json +32 -0
- package/registry/r/dropdown-item.json +21 -0
- package/registry/r/dropdown-label.json +16 -0
- package/registry/r/dropdown-menu.json +16 -0
- package/registry/r/dropdown-separator.json +16 -0
- package/registry/r/dropdown-trigger.json +20 -0
- package/registry/r/empty-state.json +23 -0
- package/registry/r/hover-card.json +23 -0
- package/registry/r/input.json +21 -0
- package/registry/r/label.json +21 -0
- package/registry/r/masked-input.json +21 -0
- package/registry/r/modal.json +36 -0
- package/registry/r/multi-select.json +33 -0
- package/registry/r/otp-group.json +16 -0
- package/registry/r/otp-slot.json +21 -0
- package/registry/r/otp.json +21 -0
- package/registry/r/pagination.json +32 -0
- package/registry/r/phone-number-input.json +34 -0
- package/registry/r/popover-content.json +21 -0
- package/registry/r/popover.json +18 -0
- package/registry/r/progress.json +21 -0
- package/registry/r/radio-group.json +26 -0
- package/registry/r/radio-item.json +26 -0
- package/registry/r/range-calendar.json +28 -0
- package/registry/r/resizable-panel-item.json +23 -0
- package/registry/r/resizable-panel.json +23 -0
- package/registry/r/resizable.json +21 -0
- package/registry/r/scroll-area.json +21 -0
- package/registry/r/segment.json +21 -0
- package/registry/r/select-group.json +21 -0
- package/registry/r/select-item.json +26 -0
- package/registry/r/select-separator.json +21 -0
- package/registry/r/select.json +31 -0
- package/registry/r/separator.json +21 -0
- package/registry/r/sheet-wrapper.json +31 -0
- package/registry/r/sheet.json +21 -0
- package/registry/r/sidebar.json +21 -0
- package/registry/r/skeleton.json +23 -0
- package/registry/r/slider.json +21 -0
- package/registry/r/switch.json +23 -0
- package/registry/r/tabs.json +23 -0
- package/registry/r/tag-input.json +25 -0
- package/registry/r/textarea.json +21 -0
- package/registry/r/toaster.json +26 -0
- package/registry/r/toggle-group.json +25 -0
- package/registry/r/toggle.json +23 -0
- package/registry/r/tolle-cell.json +16 -0
- package/registry/r/tooltip.json +23 -0
- package/registry/registry.json +1540 -0
- package/theme.css +60 -5
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "popover-content",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Popover Content",
|
|
5
|
+
"description": "Popover Content component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "popover-content.component.ts",
|
|
12
|
+
"target": "ui/popover-content.component.ts",
|
|
13
|
+
"content": "import {\n Component,\n Input,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-popover-content',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n [class]=\"cn(\n 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none',\n 'animate-in fade-in zoom-in-95 duration-200',\n class\n )\"\n role=\"dialog\"\n >\n <ng-content></ng-content>\n </div>\n `\n})\nexport class PopoverContentComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "popover",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Popover",
|
|
5
|
+
"description": "Popover component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"@floating-ui/dom"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "popover.component.ts",
|
|
14
|
+
"target": "ui/popover.component.ts",
|
|
15
|
+
"content": "\nimport { CommonModule } from '@angular/common';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { Component, ElementRef, EventEmitter, Input, OnDestroy, Output, ViewChild } from '@angular/core';\n\n@Component({\n selector: 'tolle-popover',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div class=\"inline-block\" #trigger (click)=\"toggle()\">\n <ng-content select=\"[trigger]\"></ng-content>\n </div>\n\n <div\n *ngIf=\"isOpen\"\n #popover\n class=\"fixed top-0 left-0 w-max z-[9999]\"\n style=\"visibility: hidden\"\n >\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class PopoverComponent implements OnDestroy {\n @Input() placement: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end' = 'bottom';\n @Output() onOpen = new EventEmitter<void>();\n @Output() onClose = new EventEmitter<void>();\n\n @ViewChild('trigger') triggerEl!: ElementRef;\n @ViewChild('popover') popoverEl!: ElementRef;\n\n isOpen = false;\n private cleanup?: () => void;\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.triggerEl.nativeElement.contains(event.target) && !this.popoverEl?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n toggle() {\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n this.isOpen = true;\n this.onOpen.emit();\n setTimeout(() => {\n this.updatePosition();\n document.addEventListener('mousedown', this._outsideClickHandler);\n });\n }\n\n close() {\n this.isOpen = false;\n this.onClose.emit();\n if (this.cleanup) this.cleanup();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n private updatePosition() {\n if (!this.triggerEl || !this.popoverEl) return;\n\n this.cleanup = autoUpdate(\n this.triggerEl.nativeElement,\n this.popoverEl.nativeElement,\n () => {\n computePosition(this.triggerEl.nativeElement, this.popoverEl.nativeElement, {\n placement: this.placement,\n strategy: 'fixed',\n middleware: [offset(8), flip(), shift({ padding: 8 })],\n }).then(({ x, y }) => {\n Object.assign(this.popoverEl.nativeElement.style, {\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n });\n }\n );\n }\n\n ngOnDestroy() {\n if (this.cleanup) this.cleanup();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n}\n"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "progress",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Progress",
|
|
5
|
+
"description": "Progress component.",
|
|
6
|
+
"category": "feedback",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "progress.component.ts",
|
|
12
|
+
"target": "ui/progress.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-progress',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n [class]=\"computedClass\"\n role=\"progressbar\"\n [attr.aria-valuemin]=\"0\"\n [attr.aria-valuemax]=\"100\"\n [attr.aria-valuenow]=\"clampedValue\"\n >\n <div\n class=\"h-full w-full flex-1 bg-primary transition-all duration-300 ease-in-out\"\n [style.transform]=\"'translateX(-' + (100 - clampedValue) + '%)'\"\n ></div>\n </div>\n `\n})\nexport class ProgressComponent {\n @Input() value: number | null = 0;\n @Input() class: string = '';\n\n get clampedValue(): number {\n return Math.min(100, Math.max(0, this.value ?? 0));\n }\n\n get computedClass() {\n return cn(\n \"relative h-2 w-full overflow-hidden rounded-full bg-primary/20\",\n this.class\n );\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "radio-group",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Radio Group",
|
|
5
|
+
"description": "Radio Group component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "radio-group.component.ts",
|
|
12
|
+
"target": "ui/radio-group.component.ts",
|
|
13
|
+
"content": "import {Component, Input, forwardRef, AfterContentInit, OnChanges} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport {RadioService} from './radio-service';\n\n@Component({\n selector: 'tolle-radio-group',\n standalone: true,\n imports: [CommonModule],\n providers: [\n RadioService,\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => RadioGroupComponent),\n multi: true\n }\n ],\n host: {\n '(keydown)': 'onKeydown($event)'\n },\n template: `\n <div [class]=\"cn('grid gap-2', class)\" role=\"radiogroup\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class RadioGroupComponent implements ControlValueAccessor, OnChanges {\n @Input() class = '';\n @Input() disabled = false;\n @Input() name = `radio-group-${Math.random().toString(36).substring(2, 9)}`;\n\n value: any;\n onChange: any = () => {};\n onTouched: any = () => {};\n\n /** Suppresses onChange while applying a programmatic writeValue(). */\n private suppress = false;\n\n constructor(private radioService: RadioService) {\n this.radioService.selectedValue$.subscribe(val => {\n this.value = val;\n if (!this.suppress) {\n this.onChange(val);\n this.onTouched();\n }\n });\n }\n\n setDisabledState?(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.radioService.setDisabled(isDisabled);\n }\n\n ngOnChanges() {\n this.radioService.setDisabled(this.disabled);\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.suppress = true;\n this.radioService.select(value);\n this.suppress = false;\n }\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n /**\n * WAI-ARIA radio group keyboard nav: Arrow Down/Right select+focus the next\n * enabled item, Arrow Up/Left the previous. Wraps around and skips disabled\n * items; moving selection also moves focus.\n */\n onKeydown(event: KeyboardEvent): void {\n if (this.disabled) return;\n\n const key = event.key;\n const forward = key === 'ArrowDown' || key === 'ArrowRight';\n const backward = key === 'ArrowUp' || key === 'ArrowLeft';\n if (!forward && !backward) return;\n\n const items = this.radioService.getEnabledItems();\n if (!items.length) return;\n\n event.preventDefault();\n\n const target = event.target as HTMLElement | null;\n let current = target\n ? items.findIndex(i => i.hostElement === target || i.hostElement.contains(target))\n : -1;\n if (current < 0) {\n // Fall back to the currently selected item.\n current = items.findIndex(i => i.value === this.value);\n }\n\n let next: number;\n if (current < 0) {\n next = forward ? 0 : items.length - 1;\n } else {\n next = forward\n ? (current + 1) % items.length\n : (current - 1 + items.length) % items.length;\n }\n\n const targetItem = items[next];\n this.radioService.select(targetItem.value);\n targetItem.focusItem();\n }\n\n protected readonly cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "radio-service.ts",
|
|
17
|
+
"target": "ui/radio-service.ts",
|
|
18
|
+
"content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/**\n * Contract implemented by `tolle-radio-item` so the group/service can drive\n * roving tabindex + arrow-key navigation without importing the component\n * (which would create a circular dependency).\n */\nexport interface RovingRadioItem {\n value: any;\n isItemDisabled(): boolean;\n hostElement: HTMLElement;\n focusItem(): void;\n}\n\n@Injectable()\nexport class RadioService {\n private selectedValueSource = new BehaviorSubject<any>(null);\n selectedValue$ = this.selectedValueSource.asObservable();\n\n private disabledSource = new BehaviorSubject<boolean>(false);\n disabled$ = this.disabledSource.asObservable();\n\n /**\n * The single value that is allowed in the tab order (roving tabindex).\n * It is the selected value, or - when nothing is selected - the first\n * enabled item's value. Recomputed asynchronously so the registry is\n * complete before any item reads it (avoids partial-registry glitches).\n */\n private tabbableValueSource = new BehaviorSubject<any>(undefined);\n tabbableValue$ = this.tabbableValueSource.asObservable();\n\n /** Registered items in DOM (registration) order. */\n private items: RovingRadioItem[] = [];\n private recomputeScheduled = false;\n\n get value() {\n return this.selectedValueSource.value;\n }\n\n select(value: any) {\n if (!this.disabledSource.value) {\n this.selectedValueSource.next(value);\n this.scheduleRecompute();\n }\n }\n\n setDisabled(isDisabled: boolean) {\n this.disabledSource.next(isDisabled);\n this.scheduleRecompute();\n }\n\n register(item: RovingRadioItem) {\n if (this.items.indexOf(item) === -1) {\n this.items.push(item);\n this.scheduleRecompute();\n }\n }\n\n unregister(item: RovingRadioItem) {\n const i = this.items.indexOf(item);\n if (i > -1) {\n this.items.splice(i, 1);\n this.scheduleRecompute();\n }\n }\n\n /** Ordered, non-disabled items - used for arrow-key navigation. */\n getEnabledItems(): RovingRadioItem[] {\n return this.items.filter(i => !i.isItemDisabled());\n }\n\n private scheduleRecompute() {\n if (this.recomputeScheduled) return;\n this.recomputeScheduled = true;\n // Defer to a microtask so all items have registered (and the current\n // change-detection pass has completed) before the tabbable value updates.\n Promise.resolve().then(() => {\n this.recomputeScheduled = false;\n this.recomputeTabbable();\n });\n }\n\n private recomputeTabbable() {\n const selected = this.selectedValueSource.value;\n const selectedItem = this.items.find(i => i.value === selected && !i.isItemDisabled());\n\n let tabbable: any;\n if (selectedItem) {\n tabbable = selectedItem.value;\n } else {\n const firstEnabled = this.items.find(i => !i.isItemDisabled());\n tabbable = firstEnabled ? firstEnabled.value : undefined;\n }\n\n if (tabbable !== this.tabbableValueSource.value) {\n this.tabbableValueSource.next(tabbable);\n }\n }\n}\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "utils/cn.ts",
|
|
22
|
+
"target": "ui/utils/cn.ts",
|
|
23
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "radio-item",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Radio Item",
|
|
5
|
+
"description": "Radio Item component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "radio-item.component.ts",
|
|
12
|
+
"target": "ui/radio-item.component.ts",
|
|
13
|
+
"content": "import { Component, Input, OnInit, OnDestroy, ChangeDetectorRef, ElementRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport { RadioService, RovingRadioItem } from './radio-service';\nimport { Subscription } from 'rxjs';\n\n@Component({\n selector: 'tolle-radio-item',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n (click)=\"!isEffectiveDisabled && select()\"\n [class]=\"cn(\n 'flex items-center space-x-2 group py-1',\n isEffectiveDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'\n )\"\n >\n <div\n role=\"radio\"\n [attr.aria-checked]=\"isSelected\"\n [attr.aria-disabled]=\"isEffectiveDisabled || null\"\n [attr.data-state]=\"isSelected ? 'checked' : 'unchecked'\"\n [attr.tabindex]=\"isEffectiveDisabled ? -1 : (isTabbable ? 0 : -1)\"\n (keydown.enter)=\"isEffectiveDisabled ? null : select()\"\n (keydown.space)=\"isEffectiveDisabled ? null : select(); $event.preventDefault()\"\n [class]=\"cn(\n 'aspect-square h-4 w-4 rounded-full border border-primary text-primary transition-all flex items-center justify-center',\n 'outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n !isEffectiveDisabled && 'group-hover:border-primary/70',\n isSelected ? 'bg-background' : 'bg-transparent',\n class\n )\">\n <div\n *ngIf=\"isSelected\"\n class=\"h-2.5 w-2.5 rounded-full bg-primary animate-in zoom-in-50 duration-200\"\n ></div>\n </div>\n\n <label class=\"text-sm font-medium leading-none select-none\" [class.cursor-pointer]=\"!isEffectiveDisabled\">\n <ng-content></ng-content>\n </label>\n </div>\n `\n})\nexport class RadioItemComponent implements OnInit, OnDestroy, RovingRadioItem {\n @Input() value: any;\n @Input() disabled = false;\n @Input() class = '';\n\n isSelected = false;\n groupDisabled = false;\n /** Whether this item is the single roving tab stop for the group. */\n isTabbable = false;\n\n private sub = new Subscription();\n\n get isEffectiveDisabled(): boolean {\n return this.disabled || this.groupDisabled;\n }\n\n // Inject ChangeDetectorRef to ensure UI updates when service emits\n constructor(\n private radioService: RadioService,\n private cdr: ChangeDetectorRef,\n private el: ElementRef<HTMLElement>\n ) {}\n\n // --- RovingRadioItem contract (used by the group/service for keyboard nav) ---\n get hostElement(): HTMLElement {\n return this.el.nativeElement;\n }\n\n isItemDisabled(): boolean {\n return this.isEffectiveDisabled;\n }\n\n focusItem(): void {\n const radioEl = this.el.nativeElement.querySelector('[role=\"radio\"]') as HTMLElement | null;\n radioEl?.focus();\n }\n\n ngOnInit() {\n // Listen for selection changes\n this.sub.add(\n this.radioService.selectedValue$.subscribe(val => {\n this.isSelected = (val === this.value);\n this.cdr.markForCheck(); // Trigger UI refresh\n })\n );\n\n // Listen for group-level disabled state\n this.sub.add(\n this.radioService.disabled$.subscribe(dis => {\n this.groupDisabled = dis;\n this.cdr.markForCheck(); // Trigger UI refresh\n })\n );\n\n // Listen for roving tabindex changes (which single item is tab-reachable)\n this.sub.add(\n this.radioService.tabbableValue$.subscribe(val => {\n this.isTabbable = (val === this.value);\n this.cdr.markForCheck(); // Trigger UI refresh\n })\n );\n\n this.radioService.register(this);\n }\n\n select() {\n this.radioService.select(this.value);\n }\n\n ngOnDestroy() {\n this.radioService.unregister(this);\n this.sub.unsubscribe(); // Clean up subscriptions\n }\n\n protected cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "radio-service.ts",
|
|
17
|
+
"target": "ui/radio-service.ts",
|
|
18
|
+
"content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/**\n * Contract implemented by `tolle-radio-item` so the group/service can drive\n * roving tabindex + arrow-key navigation without importing the component\n * (which would create a circular dependency).\n */\nexport interface RovingRadioItem {\n value: any;\n isItemDisabled(): boolean;\n hostElement: HTMLElement;\n focusItem(): void;\n}\n\n@Injectable()\nexport class RadioService {\n private selectedValueSource = new BehaviorSubject<any>(null);\n selectedValue$ = this.selectedValueSource.asObservable();\n\n private disabledSource = new BehaviorSubject<boolean>(false);\n disabled$ = this.disabledSource.asObservable();\n\n /**\n * The single value that is allowed in the tab order (roving tabindex).\n * It is the selected value, or - when nothing is selected - the first\n * enabled item's value. Recomputed asynchronously so the registry is\n * complete before any item reads it (avoids partial-registry glitches).\n */\n private tabbableValueSource = new BehaviorSubject<any>(undefined);\n tabbableValue$ = this.tabbableValueSource.asObservable();\n\n /** Registered items in DOM (registration) order. */\n private items: RovingRadioItem[] = [];\n private recomputeScheduled = false;\n\n get value() {\n return this.selectedValueSource.value;\n }\n\n select(value: any) {\n if (!this.disabledSource.value) {\n this.selectedValueSource.next(value);\n this.scheduleRecompute();\n }\n }\n\n setDisabled(isDisabled: boolean) {\n this.disabledSource.next(isDisabled);\n this.scheduleRecompute();\n }\n\n register(item: RovingRadioItem) {\n if (this.items.indexOf(item) === -1) {\n this.items.push(item);\n this.scheduleRecompute();\n }\n }\n\n unregister(item: RovingRadioItem) {\n const i = this.items.indexOf(item);\n if (i > -1) {\n this.items.splice(i, 1);\n this.scheduleRecompute();\n }\n }\n\n /** Ordered, non-disabled items - used for arrow-key navigation. */\n getEnabledItems(): RovingRadioItem[] {\n return this.items.filter(i => !i.isItemDisabled());\n }\n\n private scheduleRecompute() {\n if (this.recomputeScheduled) return;\n this.recomputeScheduled = true;\n // Defer to a microtask so all items have registered (and the current\n // change-detection pass has completed) before the tabbable value updates.\n Promise.resolve().then(() => {\n this.recomputeScheduled = false;\n this.recomputeTabbable();\n });\n }\n\n private recomputeTabbable() {\n const selected = this.selectedValueSource.value;\n const selectedItem = this.items.find(i => i.value === selected && !i.isItemDisabled());\n\n let tabbable: any;\n if (selectedItem) {\n tabbable = selectedItem.value;\n } else {\n const firstEnabled = this.items.find(i => !i.isItemDisabled());\n tabbable = firstEnabled ? firstEnabled.value : undefined;\n }\n\n if (tabbable !== this.tabbableValueSource.value) {\n this.tabbableValueSource.next(tabbable);\n }\n }\n}\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "utils/cn.ts",
|
|
22
|
+
"target": "ui/utils/cn.ts",
|
|
23
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "range-calendar",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Range Calendar",
|
|
5
|
+
"description": "Range Calendar component.",
|
|
6
|
+
"category": "date-time",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"date-fns"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "range-calendar.component.ts",
|
|
14
|
+
"target": "ui/range-calendar.component.ts",
|
|
15
|
+
"content": "import { Component, Input, OnInit, OnChanges, SimpleChanges, forwardRef, Output, EventEmitter, ChangeDetectorRef, ElementRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport {\n addDays, addMonths, subMonths, startOfMonth, endOfMonth,\n startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth,\n isSameDay, isToday, setMonth, setYear, addYears, subYears,\n isBefore, startOfDay, isWithinInterval, format\n} from 'date-fns';\nimport { cn } from './utils/cn';\nimport {DateRange} from './types/date-range';\n\n@Component({\n selector: 'tolle-range-calendar',\n standalone: true,\n imports: [CommonModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => RangeCalendarComponent),\n multi: true\n }\n ],\n template: `\n <div [class]=\"cn('text-popover-foreground inline-block min-w-fit', bordered ? 'p-3 border rounded-md bg-background shadow-sm' : '', class)\">\n\n <!-- ===== Multi-month view (side-by-side, shared range) ===== -->\n <div *ngIf=\"numberOfMonths > 1\" class=\"flex flex-col gap-4 sm:flex-row\">\n <div *ngFor=\"let m of visibleMonths; let idx = index\" class=\"space-y-2\">\n <div class=\"flex items-center justify-between pt-1 pb-2\">\n <button *ngIf=\"idx === 0\" type=\"button\" (click)=\"prev()\" [class]=\"navBtnClass\" aria-label=\"Previous month\">\n <i class=\"ri-arrow-left-s-line text-lg\"></i>\n </button>\n <span *ngIf=\"idx !== 0\" class=\"h-7 w-7\"></span>\n\n <div class=\"text-sm font-semibold\">{{ m.label }}</div>\n\n <button *ngIf=\"idx === visibleMonths.length - 1\" type=\"button\" (click)=\"next()\" [class]=\"navBtnClass\" aria-label=\"Next month\">\n <i class=\"ri-arrow-right-s-line text-lg\"></i>\n </button>\n <span *ngIf=\"idx !== visibleMonths.length - 1\" class=\"h-7 w-7\"></span>\n </div>\n\n <div class=\"grid grid-cols-7 gap-y-1\">\n <span *ngFor=\"let day of weekDays\" class=\"text-[0.8rem] text-muted-foreground font-normal text-center w-9\">{{ day }}</span>\n </div>\n <div role=\"grid\" class=\"grid grid-cols-7 gap-y-1\" (keydown)=\"onGridKeydown($event)\">\n <ng-container *ngFor=\"let date of m.days\">\n <!-- Only the month's own days are rendered; adjacent-month days are\n blank spacers so each calendar in a multi-month view is self-contained. -->\n <button\n *ngIf=\"isCurrentMonth(date, m.date); else pad\"\n type=\"button\"\n role=\"gridcell\"\n [attr.data-date]=\"fmtKey(date)\"\n [attr.aria-selected]=\"isSelected(date)\"\n [attr.aria-label]=\"(date | date:'fullDate')\"\n (click)=\"selectDate(date)\"\n [disabled]=\"isDateDisabled(date)\"\n [class]=\"getDayClass(date, m.date)\"\n >\n {{ date | date: 'd' }}\n </button>\n <ng-template #pad><span class=\"h-9 w-9\" aria-hidden=\"true\"></span></ng-template>\n </ng-container>\n </div>\n </div>\n </div>\n\n <!-- ===== Single-month view (with month / year quick pickers) ===== -->\n <ng-container *ngIf=\"numberOfMonths <= 1\">\n <div class=\"flex items-center justify-between pt-1 pb-4 gap-2\">\n <div class=\"flex items-center gap-1\">\n <button type=\"button\" (click)=\"setView('month')\"\n [class]=\"cn('text-sm font-semibold px-2 py-1 rounded transition-colors', currentView === 'month' ? 'bg-secondary text-secondary-foreground' : 'hover:bg-accent hover:text-accent-foreground')\">\n {{ viewDate | date: 'MMMM' }}\n </button>\n <button type=\"button\" (click)=\"setView('year')\"\n [class]=\"cn('text-sm font-semibold px-2 py-1 rounded transition-colors', currentView === 'year' ? 'bg-secondary text-secondary-foreground' : 'hover:bg-accent hover:text-accent-foreground')\">\n {{ viewDate | date: 'yyyy' }}\n </button>\n </div>\n <div class=\"flex items-center space-x-1\">\n <button type=\"button\" (click)=\"prev()\" [class]=\"navBtnClass\" aria-label=\"Previous month\"><i class=\"ri-arrow-left-s-line text-lg\"></i></button>\n <button type=\"button\" (click)=\"next()\" [class]=\"navBtnClass\" aria-label=\"Next month\"><i class=\"ri-arrow-right-s-line text-lg\"></i></button>\n </div>\n </div>\n\n <div *ngIf=\"currentView === 'date'\" class=\"space-y-2 animate-in fade-in zoom-in-95 duration-200\">\n <div class=\"grid grid-cols-7 gap-y-1 w-full\">\n <span *ngFor=\"let day of weekDays\" class=\"text-[0.8rem] text-muted-foreground font-normal text-center w-9\">\n {{ day }}\n </span>\n </div>\n <div role=\"grid\" class=\"grid grid-cols-7 gap-y-1 w-full\" (keydown)=\"onGridKeydown($event)\">\n <button\n *ngFor=\"let date of daysInMonth\"\n type=\"button\"\n role=\"gridcell\"\n [attr.data-date]=\"fmtKey(date)\"\n [attr.aria-selected]=\"isSelected(date)\"\n [attr.aria-label]=\"(date | date:'fullDate')\"\n (click)=\"selectDate(date)\"\n [disabled]=\"isDateDisabled(date)\"\n [class]=\"getDayClass(date)\"\n >\n {{ date | date: 'd' }}\n </button>\n </div>\n </div>\n\n <div *ngIf=\"currentView === 'month'\" class=\"grid grid-cols-3 gap-2 w-64 animate-in fade-in zoom-in-95 duration-200\">\n <button *ngFor=\"let month of months; let i = index\" type=\"button\" (click)=\"selectMonth(i)\"\n [class]=\"cn('text-sm py-2.5 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors', i === viewDate.getMonth() ? 'bg-primary text-primary-foreground' : '')\">\n {{ month }}\n </button>\n </div>\n\n <div *ngIf=\"currentView === 'year'\" class=\"grid grid-cols-4 gap-2 w-64 animate-in fade-in zoom-in-95 duration-200\">\n <button *ngFor=\"let year of years\" type=\"button\" (click)=\"selectYear(year)\"\n [class]=\"cn('text-sm py-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors', year === viewDate.getFullYear() ? 'bg-primary text-primary-foreground' : '')\">\n {{ year }}\n </button>\n </div>\n </ng-container>\n </div>\n `\n})\nexport class RangeCalendarComponent implements OnInit, OnChanges, ControlValueAccessor {\n @Input() class = '';\n @Input() disablePastDates = false;\n /** Renders the calendar's own border/background/shadow. Set `false` inside a\n * card/popover that already provides chrome. @default true */\n @Input() bordered = true;\n /** Number of consecutive months rendered side by side. @default 1 */\n @Input() numberOfMonths = 1;\n @Output() rangeSelect = new EventEmitter<DateRange>(); // Emits whenever selection changes\n\n currentView: 'date' | 'month' | 'year' = 'date';\n viewDate: Date = new Date();\n\n // The Range Value\n value: DateRange = { start: null, end: null };\n\n weekDays = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];\n daysInMonth: Date[] = [];\n /** Grids for each visible month (multi-month view). */\n visibleMonths: { date: Date; days: Date[]; label: string }[] = [];\n months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];\n years: number[] = [];\n\n navBtnClass = cn('h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 border border-input rounded-md flex items-center justify-center hover:bg-accent hover:text-accent-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring');\n\n onTouched: () => void = () => {};\n onChange: (value: DateRange) => void = () => {};\n protected cn = cn;\n\n constructor(private cdr: ChangeDetectorRef, private el: ElementRef<HTMLElement>) {}\n\n ngOnInit() {\n this.generateDays();\n this.generateYears();\n }\n\n ngOnChanges(changes: SimpleChanges) {\n // Rebuild the month grids when the visible-month count changes after init\n // (e.g. a `[numberOfMonths]` toggle), so single ⇄ multi-month is reactive.\n if (changes['numberOfMonths'] && !changes['numberOfMonths'].firstChange) {\n this.generateDays();\n }\n }\n\n // --- Date Generation Logic (Same as Calendar) ---\n generateDays() {\n const start = startOfWeek(startOfMonth(this.viewDate));\n const end = endOfWeek(endOfMonth(this.viewDate));\n this.daysInMonth = eachDayOfInterval({ start, end });\n\n // Multi-month grids: viewDate is the leftmost month, then N-1 consecutive.\n const count = Math.max(1, this.numberOfMonths);\n this.visibleMonths = Array.from({ length: count }, (_, i) => {\n const monthDate = addMonths(this.viewDate, i);\n return {\n date: monthDate,\n days: eachDayOfInterval({ start: startOfWeek(startOfMonth(monthDate)), end: endOfWeek(endOfMonth(monthDate)) }),\n label: format(monthDate, 'MMMM yyyy'),\n };\n });\n }\n\n generateYears() {\n const currentYear = this.viewDate.getFullYear();\n this.years = Array.from({ length: 16 }, (_, i) => currentYear - 6 + i);\n }\n\n setView(view: 'date' | 'month' | 'year') {\n this.currentView = view;\n if (view === 'year') this.generateYears();\n }\n\n prev() {\n if (this.currentView === 'date') {\n this.viewDate = subMonths(this.viewDate, 1);\n this.generateDays();\n } else if (this.currentView === 'year') {\n this.viewDate = subYears(this.viewDate, 16);\n this.generateYears();\n } else if (this.currentView === 'month') {\n this.viewDate = subYears(this.viewDate, 1);\n }\n }\n\n next() {\n if (this.currentView === 'date') {\n this.viewDate = addMonths(this.viewDate, 1);\n this.generateDays();\n } else if (this.currentView === 'year') {\n this.viewDate = addYears(this.viewDate, 16);\n this.generateYears();\n } else if (this.currentView === 'month') {\n this.viewDate = addYears(this.viewDate, 1);\n }\n }\n\n // --- Range Selection Logic ---\n\n selectDate(date: Date) {\n if (this.isDateDisabled(date)) return;\n\n const { start, end } = this.value;\n\n // 1. If start exists but end doesn't\n if (start && !end) {\n if (isBefore(date, start)) {\n // User clicked earlier date -> Reset start\n this.value = { start: date, end: null };\n } else {\n // User clicked later date -> Complete range\n this.value = { start, end: date };\n }\n }\n // 2. If neither exist OR both exist (reset)\n else {\n this.value = { start: date, end: null };\n }\n\n // Only re-anchor the view when the clicked day isn't already on a visible month\n // (so clicking the right-hand month in a 2-month view doesn't jump the calendar).\n const onVisibleMonth = this.visibleMonths.some((m) => isSameMonth(date, m.date));\n if (!onVisibleMonth) {\n this.viewDate = startOfMonth(date);\n this.generateDays();\n }\n\n this.onChange(this.value);\n this.rangeSelect.emit(this.value);\n this.onTouched();\n }\n\n // --- Grid keyboard navigation (date view only) ---\n\n /**\n * Arrow keys move by day/week, Home/End to week edges, PageUp/PageDown by\n * month. Enter/Space are left to the native day <button>. The focused day is\n * read from its `data-date` key, so navigation is independent of grid layout\n * (adjacent-month days aren't rendered in the multi-month view).\n */\n onGridKeydown(event: KeyboardEvent) {\n if (this.numberOfMonths <= 1 && this.currentView !== 'date') return;\n\n const key = (event.target as HTMLElement).getAttribute('data-date');\n if (!key) return;\n const current = this.keyToDate(key);\n\n let next: Date | null = null;\n switch (event.key) {\n case 'ArrowLeft': next = addDays(current, -1); break;\n case 'ArrowRight': next = addDays(current, 1); break;\n case 'ArrowUp': next = addDays(current, -7); break;\n case 'ArrowDown': next = addDays(current, 7); break;\n case 'Home': next = startOfWeek(current); break;\n case 'End': next = endOfWeek(current); break;\n case 'PageUp': next = subMonths(current, 1); break;\n case 'PageDown': next = addMonths(current, 1); break;\n default: return;\n }\n\n event.preventDefault();\n this.focusDay(next);\n }\n\n private focusDay(date: Date) {\n const selector = `[data-date=\"${this.fmtKey(date)}\"]`;\n let btn = this.el.nativeElement.querySelector(selector) as HTMLElement | null;\n if (btn) { btn.focus(); return; }\n\n // Target day isn't currently rendered → page the view toward it, then retry.\n if (this.numberOfMonths > 1) {\n this.viewDate = isBefore(date, this.visibleMonths[0].date)\n ? subMonths(this.viewDate, 1)\n : addMonths(this.viewDate, 1);\n } else {\n this.viewDate = startOfMonth(date);\n }\n this.generateDays();\n this.cdr.detectChanges();\n btn = this.el.nativeElement.querySelector(selector) as HTMLElement | null;\n btn?.focus();\n }\n\n selectMonth(monthIndex: number) {\n this.viewDate = setMonth(this.viewDate, monthIndex);\n this.currentView = 'date';\n this.generateDays();\n }\n\n selectYear(year: number) {\n this.viewDate = setYear(this.viewDate, year);\n this.currentView = 'date';\n this.generateDays();\n }\n\n // --- Visual Styling for Range ---\n\n isSelected(date: Date): boolean {\n const { start, end } = this.value;\n return !!((start && isSameDay(date, start)) || (end && isSameDay(date, end)));\n }\n\n /** True when `date` belongs to `refMonth` (used to blank out adjacent-month cells). */\n isCurrentMonth(date: Date, refMonth: Date): boolean {\n return isSameMonth(date, refMonth);\n }\n\n /** Stable, timezone-safe `data-date` key used to locate day buttons for focus. */\n fmtKey(date: Date): string {\n return format(date, 'yyyy-MM-dd');\n }\n\n private keyToDate(key: string): Date {\n const [y, m, d] = key.split('-').map(Number);\n return new Date(y, m - 1, d);\n }\n\n getDayClass(date: Date, refMonth: Date = this.viewDate) {\n const { start, end } = this.value;\n const isOutside = !isSameMonth(date, refMonth);\n const isDisabled = this.isDateDisabled(date);\n\n // Range Checks\n const isStart = start && isSameDay(date, start);\n const isEnd = end && isSameDay(date, end);\n const isInside = start && end && isWithinInterval(date, { start, end });\n const isTodayDate = isToday(date);\n\n return cn(\n // Base: h-9 w-9, but we remove margins/rounding for the 'strip' effect\n 'h-9 w-9 p-0 font-normal text-sm transition-all flex items-center justify-center relative z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n\n // Default State (Not selected, Not disabled)\n !isInside && !isStart && !isEnd && !isDisabled && 'hover:bg-accent hover:text-accent-foreground rounded-md',\n\n // The \"Caps\": Start and End\n (isStart || isEnd) && 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',\n\n // The \"Strip\": Dates between start and end\n isInside && !isStart && !isEnd && 'bg-accent text-accent-foreground rounded-none',\n\n // Connecting the Caps to the Strip (Rectangular inside edges)\n isStart && end && 'rounded-l-md rounded-r-none',\n isEnd && start && 'rounded-r-md rounded-l-none',\n // If no end date yet, Start should be fully rounded\n isStart && !end && 'rounded-md',\n\n // Muted/Disabled logic\n !isInside && isTodayDate && !isStart && !isEnd && 'bg-accent/50 text-accent-foreground rounded-md',\n (isOutside || isDisabled) && 'text-muted-foreground opacity-50',\n isDisabled && 'cursor-not-allowed'\n );\n }\n\n isDateDisabled(date: Date): boolean {\n return this.disablePastDates ? isBefore(date, startOfDay(new Date())) : false;\n }\n\n // --- CVA Implementation ---\n writeValue(val: DateRange | null): void {\n if (val) {\n this.value = val;\n if (val.start) this.viewDate = val.start;\n this.generateDays();\n this.generateYears();\n }\n }\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "types/date-range.ts",
|
|
19
|
+
"target": "ui/types/date-range.ts",
|
|
20
|
+
"content": "export type DateRange = {\n start: Date | null;\n end: Date | null;\n}\n"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "utils/cn.ts",
|
|
24
|
+
"target": "ui/utils/cn.ts",
|
|
25
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "resizable-panel-item",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Resizable Panel Item",
|
|
5
|
+
"description": "Resizable Panel Item component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"resizable-panel"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "resizable-panel-item.component.ts",
|
|
14
|
+
"target": "ui/resizable-panel-item.component.ts",
|
|
15
|
+
"content": "import { Component, Input, inject, HostBinding } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ResizablePanelComponent } from './resizable-panel.component';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-resizable-panel-item',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"computedContainerClass\">\n <ng-content></ng-content>\n </div>\n\n <div\n *ngIf=\"resizable && !isLast\"\n [class]=\"computedHandleClass\"\n (mousedown)=\"onHandleMouseDown($event)\"\n >\n <div class=\"absolute inset-0 flex items-center justify-center pointer-events-none\">\n <div [class]=\"computedHandleIndicatorClass\"></div>\n </div>\n </div>\n `,\n styles: [`\n :host {\n display: block;\n position: relative;\n overflow: visible;\n }\n `]\n})\nexport class ResizablePanelItemComponent {\n @Input() @HostBinding('style.flex') size: number = 1;\n @Input() minSize: number = 10; // percentage or pixels\n @Input() maxSize?: number;\n @Input() resizable: boolean = true;\n @Input() class: string = '';\n\n isLast: boolean = false;\n private container = inject(ResizablePanelComponent);\n\n get computedContainerClass() {\n return cn(\n 'relative h-full w-full overflow-hidden',\n this.class\n );\n }\n\n get computedHandleClass() {\n const isHorizontal = this.container?.direction === 'horizontal';\n return cn(\n 'absolute z-20',\n isHorizontal\n ? 'top-0 bottom-0 -right-2 w-4 cursor-col-resize'\n : 'left-0 right-0 -bottom-2 h-4 cursor-row-resize',\n 'hover:bg-primary/5 active:bg-primary/10'\n );\n }\n\n get computedHandleIndicatorClass() {\n const isHorizontal = this.container?.direction === 'horizontal';\n return cn(\n 'bg-muted-foreground/40 rounded-full',\n isHorizontal ? 'w-1 h-6' : 'h-1 w-6'\n );\n }\n\n onHandleMouseDown(event: MouseEvent) {\n this.container.startResize(this, event);\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "resizable-panel",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Resizable Panel",
|
|
5
|
+
"description": "Resizable Panel component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"resizable-panel-item"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "resizable-panel.component.ts",
|
|
14
|
+
"target": "ui/resizable-panel.component.ts",
|
|
15
|
+
"content": "import { Component, Input, ContentChildren, QueryList, AfterContentInit, ElementRef, inject, ChangeDetectorRef, OnDestroy, forwardRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport { ResizablePanelItemComponent } from './resizable-panel-item.component';\n\n@Component({\n selector: 'tolle-resizable-panel',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"computedContainerClass\">\n <div class=\"flex\" [class.flex-col]=\"direction === 'vertical'\" [class.flex-row]=\"direction === 'horizontal'\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n styles: [`\n :host {\n display: block;\n }\n `]\n})\nexport class ResizablePanelComponent implements AfterContentInit, OnDestroy {\n @Input() direction: 'horizontal' | 'vertical' = 'horizontal';\n @Input() class: string = '';\n\n // forwardRef breaks the panel <-> item circular import (TDZ at class-decoration time).\n @ContentChildren(forwardRef(() => ResizablePanelItemComponent)) panels!: QueryList<ResizablePanelItemComponent>;\n\n private el = inject(ElementRef);\n private cdr = inject(ChangeDetectorRef);\n\n private isResizing = false;\n private activePanelIndex = -1;\n private startCursorPosition = 0;\n private startPanelSizes: number[] = [];\n\n private mouseMoveListener?: (e: MouseEvent) => void;\n private mouseUpListener?: (e: MouseEvent) => void;\n\n ngAfterContentInit() {\n // Initialize panel sizes if needed\n setTimeout(() => {\n this.updatePanelSizes();\n this.updateLastStatus();\n });\n\n this.panels.changes.subscribe(() => {\n this.updatePanelSizes();\n this.updateLastStatus();\n });\n }\n\n ngOnDestroy() {\n this.cleanupListeners();\n }\n\n private updateLastStatus() {\n const panels = this.panels.toArray();\n panels.forEach((p, i) => p.isLast = i === panels.length - 1);\n }\n\n startResize(item: ResizablePanelItemComponent, event: MouseEvent) {\n const panels = this.panels.toArray();\n this.activePanelIndex = panels.indexOf(item);\n if (this.activePanelIndex === -1 || this.activePanelIndex === panels.length - 1) return;\n\n this.isResizing = true;\n this.startCursorPosition = this.direction === 'horizontal' ? event.clientX : event.clientY;\n this.startPanelSizes = panels.map(p => p.size);\n\n this.setupListeners();\n document.body.style.cursor = this.direction === 'horizontal' ? 'col-resize' : 'row-resize';\n document.body.style.userSelect = 'none';\n }\n\n private setupListeners() {\n this.mouseMoveListener = (e: MouseEvent) => this.onMouseMove(e);\n this.mouseUpListener = () => this.stopResize();\n\n document.addEventListener('mousemove', this.mouseMoveListener);\n document.addEventListener('mouseup', this.mouseUpListener);\n }\n\n private cleanupListeners() {\n if (this.mouseMoveListener) document.removeEventListener('mousemove', this.mouseMoveListener);\n if (this.mouseUpListener) document.removeEventListener('mouseup', this.mouseUpListener);\n }\n\n private onMouseMove(event: MouseEvent) {\n if (!this.isResizing) return;\n\n const currentPos = this.direction === 'horizontal' ? event.clientX : event.clientY;\n const deltaPx = currentPos - this.startCursorPosition;\n\n const containerSize = this.direction === 'horizontal'\n ? this.el.nativeElement.offsetWidth\n : this.el.nativeElement.offsetHeight;\n\n const deltaPercent = (deltaPx / containerSize) * 100;\n\n const panels = this.panels.toArray();\n const panelA = panels[this.activePanelIndex];\n const panelB = panels[this.activePanelIndex + 1];\n\n const newSizeA = Math.max(panelA.minSize, this.startPanelSizes[this.activePanelIndex] + deltaPercent);\n const actualDelta = newSizeA - this.startPanelSizes[this.activePanelIndex];\n const newSizeB = Math.max(panelB.minSize, this.startPanelSizes[this.activePanelIndex + 1] - actualDelta);\n\n panelA.size = newSizeA;\n panelB.size = this.startPanelSizes[this.activePanelIndex] + this.startPanelSizes[this.activePanelIndex + 1] - panelA.size;\n\n if (panelB.size < panelB.minSize) {\n panelB.size = panelB.minSize;\n panelA.size = (this.startPanelSizes[this.activePanelIndex] + this.startPanelSizes[this.activePanelIndex + 1]) - panelB.minSize;\n }\n\n this.cdr.detectChanges();\n }\n\n private stopResize() {\n this.isResizing = false;\n this.cleanupListeners();\n document.body.style.cursor = '';\n document.body.style.userSelect = '';\n }\n\n private updatePanelSizes() {\n const panels = this.panels.toArray();\n const totalPanels = panels.length;\n\n if (totalPanels > 0) {\n const defaultSize = 100 / totalPanels;\n panels.forEach(panel => {\n if (!panel.size) {\n panel.size = defaultSize;\n }\n });\n }\n }\n\n get computedContainerClass() {\n return cn(\n 'w-full h-full',\n this.class\n );\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "resizable",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Resizable",
|
|
5
|
+
"description": "Resizable component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "resizable.component.ts",
|
|
12
|
+
"target": "ui/resizable.component.ts",
|
|
13
|
+
"content": "import { Component, Input, Output, EventEmitter, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-resizable',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n #container\n [class]=\"computedContainerClass\"\n [style.width.px]=\"width\"\n [style.height.px]=\"height\"\n [style.minWidth.px]=\"minWidth\"\n [style.minHeight.px]=\"minHeight\"\n [style.maxWidth.px]=\"maxWidth\"\n [style.maxHeight.px]=\"maxHeight\"\n >\n <ng-content></ng-content>\n\n <!-- Resize handles -->\n <div\n *ngIf=\"resizable\"\n class=\"absolute -right-1 -bottom-1 w-3 h-3 cursor-se-resize z-10\"\n (mousedown)=\"onResizeStart($event, 'both')\"\n >\n <div class=\"absolute right-0 bottom-0 w-2 h-2 border-r-2 border-b-2 border-muted-foreground/50 rounded-sm\"></div>\n </div>\n\n <div\n *ngIf=\"resizable && directions.includes('right')\"\n class=\"absolute -right-1 top-0 bottom-0 w-2 cursor-col-resize z-10\"\n (mousedown)=\"onResizeStart($event, 'horizontal')\"\n >\n <div class=\"absolute right-0 top-1/2 -translate-y-1/2 w-1 h-4 rounded-full bg-muted-foreground/30\"></div>\n </div>\n\n <div\n *ngIf=\"resizable && directions.includes('bottom')\"\n class=\"absolute -bottom-1 left-0 right-0 h-2 cursor-row-resize z-10\"\n (mousedown)=\"onResizeStart($event, 'vertical')\"\n >\n <div class=\"absolute bottom-0 left-1/2 -translate-x-1/2 h-1 w-4 rounded-full bg-muted-foreground/30\"></div>\n </div>\n </div>\n `,\n styles: [`\n :host {\n display: inline-block;\n position: relative;\n }\n `]\n})\nexport class ResizableComponent implements AfterViewInit, OnDestroy {\n @Input() width: number = 300;\n @Input() height: number = 200;\n @Input() minWidth: number = 50;\n @Input() minHeight: number = 50;\n @Input() maxWidth?: number;\n @Input() maxHeight?: number;\n @Input() resizable: boolean = true;\n @Input() directions: ('right' | 'bottom' | 'both')[] = ['right', 'bottom', 'both'];\n @Input() class: string = '';\n @Input() containerClass: string = '';\n\n @Output() resizeStart = new EventEmitter<void>();\n @Output() resize = new EventEmitter<{ width: number; height: number }>();\n @Output() resizeEnd = new EventEmitter<{ width: number; height: number }>();\n\n @ViewChild('container') container!: ElementRef<HTMLDivElement>;\n\n private isResizing = false;\n private resizeDirection: 'horizontal' | 'vertical' | 'both' | null = null;\n private startX = 0;\n private startY = 0;\n private startWidth = 0;\n private startHeight = 0;\n\n private mouseMoveListener?: (e: MouseEvent) => void;\n private mouseUpListener?: (e: MouseEvent) => void;\n\n ngAfterViewInit() {\n this.setupGlobalListeners();\n }\n\n ngOnDestroy() {\n this.cleanupGlobalListeners();\n }\n\n get computedContainerClass() {\n return cn(\n 'relative bg-background border border-input rounded-md shadow-sm',\n 'transition-[box-shadow] duration-200',\n this.resizable && 'hover:shadow-md',\n this.isResizing && 'shadow-lg ring-2 ring-ring/20',\n this.containerClass,\n this.class\n );\n }\n\n onResizeStart(event: MouseEvent, direction: 'horizontal' | 'vertical' | 'both') {\n if (!this.resizable) return;\n\n event.preventDefault();\n event.stopPropagation();\n\n this.isResizing = true;\n this.resizeDirection = direction;\n this.startX = event.clientX;\n this.startY = event.clientY;\n this.startWidth = this.width;\n this.startHeight = this.height;\n\n this.resizeStart.emit();\n\n // Add active styling\n document.body.style.userSelect = 'none';\n document.body.style.cursor = this.getResizeCursor(direction);\n }\n\n private onMouseMove(event: MouseEvent) {\n if (!this.isResizing || !this.resizeDirection) return;\n\n const deltaX = event.clientX - this.startX;\n const deltaY = event.clientY - this.startY;\n\n let newWidth = this.startWidth;\n let newHeight = this.startHeight;\n\n if (this.resizeDirection === 'horizontal' || this.resizeDirection === 'both') {\n newWidth = Math.max(this.minWidth, this.startWidth + deltaX);\n if (this.maxWidth) newWidth = Math.min(this.maxWidth, newWidth);\n }\n\n if (this.resizeDirection === 'vertical' || this.resizeDirection === 'both') {\n newHeight = Math.max(this.minHeight, this.startHeight + deltaY);\n if (this.maxHeight) newHeight = Math.min(this.maxHeight, newHeight);\n }\n\n this.width = newWidth;\n this.height = newHeight;\n\n this.resize.emit({ width: newWidth, height: newHeight });\n }\n\n private onMouseUp() {\n if (!this.isResizing) return;\n\n this.isResizing = false;\n this.resizeDirection = null;\n\n // Reset cursor and selection\n document.body.style.userSelect = '';\n document.body.style.cursor = '';\n\n this.resizeEnd.emit({ width: this.width, height: this.height });\n }\n\n private getResizeCursor(direction: 'horizontal' | 'vertical' | 'both'): string {\n switch (direction) {\n case 'horizontal': return 'col-resize';\n case 'vertical': return 'row-resize';\n case 'both': return 'se-resize';\n default: return '';\n }\n }\n\n private setupGlobalListeners() {\n this.mouseMoveListener = (e: MouseEvent) => this.onMouseMove(e);\n this.mouseUpListener = () => this.onMouseUp();\n\n document.addEventListener('mousemove', this.mouseMoveListener);\n document.addEventListener('mouseup', this.mouseUpListener);\n }\n\n private cleanupGlobalListeners() {\n if (this.mouseMoveListener) {\n document.removeEventListener('mousemove', this.mouseMoveListener);\n }\n if (this.mouseUpListener) {\n document.removeEventListener('mouseup', this.mouseUpListener);\n }\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scroll-area",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Scroll Area",
|
|
5
|
+
"description": "Scroll Area component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "scroll-area.component.ts",
|
|
12
|
+
"target": "ui/scroll-area.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-scroll-area',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div \n [class]=\"cn('relative overflow-hidden', class)\"\n [style.height]=\"height\"\n >\n <div \n class=\"h-full w-full rounded-[inherit] overflow-auto scrollbar-hide\"\n [class.overflow-x-hidden]=\"orientation === 'vertical'\"\n [class.overflow-y-hidden]=\"orientation === 'horizontal'\"\n >\n <ng-content></ng-content>\n </div>\n </div>\n `,\n styles: [`\n :host { display: block; }\n \n .scrollbar-hide::-webkit-scrollbar {\n width: 6px;\n height: 6px;\n }\n \n .scrollbar-hide::-webkit-scrollbar-track {\n background: transparent;\n }\n \n .scrollbar-hide::-webkit-scrollbar-thumb {\n background: rgb(var(--border));\n border-radius: 10px;\n }\n\n .scrollbar-hide::-webkit-scrollbar-thumb:hover {\n background: rgb(var(--muted-foreground));\n }\n `]\n})\nexport class ScrollAreaComponent {\n @Input() class: string = '';\n @Input() height: string = '100%';\n @Input() orientation: 'vertical' | 'horizontal' | 'both' = 'both';\n\n cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "segment",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Segment",
|
|
5
|
+
"description": "Segment component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "segment.component.ts",
|
|
12
|
+
"target": "ui/segment.component.ts",
|
|
13
|
+
"content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChildren,\n QueryList,\n AfterViewInit,\n ChangeDetectorRef,\n OnChanges,\n OnDestroy,\n SimpleChanges,\n TemplateRef,\n ViewChild,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\nexport type SegmentItem = {\n label: string;\n value: any;\n disabled?: boolean;\n icon?: string; // Added: Optional icon class (e.g., 'ri-home-line')\n class?: string; // Added: Custom class for specific item wrapper\n data?: any; // Added: Extra data payload for custom templates\n}\n\n@Component({\n selector: 'tolle-segment',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SegmentedComponent),\n multi: true\n }\n ],\n template: `\n <div\n #container\n [class]=\"cn(\n 'relative flex items-center p-1 bg-muted rounded-lg select-none w-full gap-1',\n class\n )\"\n role=\"radiogroup\"\n (keydown)=\"onKeydown($event)\"\n >\n <div\n class=\"absolute top-1 bottom-1 bg-primary shadow-sm rounded-md transition-all duration-300 ease-[cubic-bezier(0.2,0.0,0.2,1)]\"\n [style.left.px]=\"gliderLeft\"\n [style.width.px]=\"gliderWidth\"\n [class.opacity-0]=\"!hasValue\"\n ></div>\n\n <button\n *ngFor=\"let item of items\"\n #itemEls\n type=\"button\"\n role=\"radio\"\n [disabled]=\"item.disabled || disabled\"\n [attr.aria-checked]=\"value === item.value\"\n [attr.tabindex]=\"getTabIndex(item)\"\n (click)=\"select(item.value)\"\n [class]=\"cn(\n 'relative z-10 flex-1 px-3 py-1.5 text-sm font-medium transition-colors duration-200 rounded-md text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n 'flex items-center justify-center gap-2',\n value === item.value\n ? 'text-primary-foreground'\n : 'text-muted-foreground hover:text-foreground/70',\n item.disabled && 'opacity-50 cursor-not-allowed',\n item.class\n )\"\n >\n <ng-container *ngIf=\"itemTemplate; else defaultContent\">\n <ng-container *ngTemplateOutlet=\"itemTemplate; context: { $implicit: item, selected: value === item.value }\">\n </ng-container>\n </ng-container>\n\n <ng-template #defaultContent>\n <i *ngIf=\"item.icon\" [class]=\"item.icon\"></i>\n <span class=\"truncate\">{{ item.label }}</span>\n </ng-template>\n </button>\n </div>\n `,\n styles: [`\n :host {\n display: block;\n }\n `]\n})\nexport class SegmentedComponent implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy {\n @Input() items: SegmentItem[] = [];\n @Input() class = '';\n @Input() disabled = false;\n @Input() itemTemplate?: TemplateRef<any>; // Allow custom content\n\n value: any = null;\n gliderLeft = 0;\n gliderWidth = 0;\n hasValue = false;\n\n @ViewChild('container') containerEl!: ElementRef<HTMLElement>;\n @ViewChildren('itemEls') itemElements!: QueryList<ElementRef<HTMLElement>>;\n\n private resizeObserver?: ResizeObserver;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n ngAfterViewInit() {\n setTimeout(() => this.updateGlider());\n\n if (typeof ResizeObserver !== 'undefined' && this.containerEl) {\n this.resizeObserver = new ResizeObserver(() => {\n this.updateGlider();\n });\n this.resizeObserver.observe(this.containerEl.nativeElement);\n }\n }\n\n ngOnDestroy() {\n if (this.resizeObserver) {\n this.resizeObserver.disconnect();\n }\n }\n\n ngOnChanges(changes: SimpleChanges) {\n // Recalculate if items change or if value changes externally\n if (changes['items'] && !changes['items'].firstChange) {\n setTimeout(() => this.updateGlider());\n }\n }\n\n select(val: any) {\n if (this.disabled) return;\n const item = this.items.find(i => i.value === val);\n if (item?.disabled) return;\n\n this.value = val;\n this.onChange(val);\n this.onTouched();\n this.updateGlider();\n }\n\n /** True when the current value matches one of the items. */\n private hasSelectedValue(): boolean {\n return this.items.some(i => i.value === this.value);\n }\n\n private firstEnabledItem(): SegmentItem | undefined {\n if (this.disabled) return undefined;\n return this.items.find(i => !i.disabled);\n }\n\n /**\n * Roving tabindex: the selected item is tab-reachable, or - when nothing is\n * selected - the first enabled item. All other items get -1.\n */\n getTabIndex(item: SegmentItem): number {\n if (this.disabled || item.disabled) return -1;\n if (this.value === item.value) return 0;\n if (!this.hasSelectedValue() && this.firstEnabledItem() === item) return 0;\n return -1;\n }\n\n /**\n * WAI-ARIA radio group keyboard nav: Arrow Left/Right (and Home/End) move\n * selection + focus across the enabled items, wrapping and skipping disabled.\n */\n onKeydown(event: KeyboardEvent) {\n if (this.disabled) return;\n\n const key = event.key;\n const forward = key === 'ArrowRight';\n const backward = key === 'ArrowLeft';\n const home = key === 'Home';\n const end = key === 'End';\n if (!forward && !backward && !home && !end) return;\n\n const enabled = this.items.map((it, i) => ({ it, i })).filter(x => !x.it.disabled);\n if (!enabled.length) return;\n\n event.preventDefault();\n\n const buttons = this.itemElements?.toArray().map(r => r.nativeElement) ?? [];\n const focusedIndex = buttons.indexOf(event.target as HTMLElement);\n let pos = enabled.findIndex(x => x.i === focusedIndex);\n if (pos < 0) pos = enabled.findIndex(x => x.it.value === this.value);\n\n let targetPos: number;\n if (home) {\n targetPos = 0;\n } else if (end) {\n targetPos = enabled.length - 1;\n } else if (forward) {\n targetPos = pos < 0 ? 0 : (pos + 1) % enabled.length;\n } else {\n targetPos = pos < 0 ? enabled.length - 1 : (pos - 1 + enabled.length) % enabled.length;\n }\n\n const target = enabled[targetPos];\n this.select(target.it.value);\n this.itemElements.get(target.i)?.nativeElement.focus();\n }\n\n updateGlider() {\n if (!this.itemElements || !this.items.length) return;\n\n const index = this.items.findIndex(i => i.value === this.value);\n\n if (index === -1) {\n this.hasValue = false;\n this.gliderWidth = 0;\n return;\n }\n\n const activeElement = this.itemElements.get(index)?.nativeElement;\n\n if (activeElement) {\n this.hasValue = true;\n this.gliderLeft = activeElement.offsetLeft;\n this.gliderWidth = activeElement.offsetWidth;\n this.cdr.detectChanges();\n }\n }\n\n // CVA Implementation\n writeValue(val: any): void {\n this.value = val;\n setTimeout(() => this.updateGlider());\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n protected cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "select-group",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Select Group",
|
|
5
|
+
"description": "Select Group component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "select-group.component.ts",
|
|
12
|
+
"target": "ui/select-group.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-select-group',\n standalone: true,\n template: `\n <div [class]=\"cn('px-2 py-1.5 text-sm font-semibold text-muted-foreground', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SelectGroupComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "select-item",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Select Item",
|
|
5
|
+
"description": "Select Item component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "select-item.component.ts",
|
|
12
|
+
"target": "ui/select-item.component.ts",
|
|
13
|
+
"content": "import {Component, Input, HostListener, ElementRef, Optional} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport {SelectService} from './select.service';\n\n@Component({\n selector: 'tolle-select-item',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n *ngIf=\"!hidden\"\n [id]=\"id\"\n [class]=\"cn(\n 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors',\n getItemClasses(),\n class\n )\"\n [attr.aria-disabled]=\"disabled\"\n [attr.aria-selected]=\"selected\"\n [attr.data-highlighted]=\"active ? '' : null\"\n role=\"option\"\n >\n <!-- Single Select: Checkmark -->\n <span *ngIf=\"!multiSelect && selected\" class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <i class=\"ri-check-line text-primary\"></i>\n </span>\n\n <!-- Multi-Select: Checkbox -->\n <span *ngIf=\"multiSelect\" class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <div [class]=\"cn(\n 'flex h-4 w-4 items-center justify-center rounded-sm border transition-all duration-200',\n selected ? 'border-primary bg-primary text-primary-foreground' : 'border-input'\n )\">\n <i [class]=\"cn(\n 'ri-check-line text-xs transition-all duration-200',\n selected ? 'opacity-100 scale-100' : 'opacity-0 scale-75'\n )\"></i>\n </div>\n </span>\n\n <!-- Content -->\n <span class=\"flex-1 truncate\">\n <ng-content></ng-content>\n </span>\n\n <!-- Disabled indicator -->\n <span *ngIf=\"disabled && !selected\" class=\"ml-2 text-xs text-muted-foreground/50\">\n <i class=\"ri-forbid-line\"></i>\n </span>\n </div>\n `,\n})\nexport class SelectItemComponent {\n @Input() value: any;\n @Input() class = '';\n @Input() selected = false;\n @Input() disabled = false;\n @Input() multiSelect = false; // Will be set by parent component\n // Stable option id used by the parent for aria-activedescendant wiring.\n @Input() id = `select-item-${Math.random().toString(36).substr(2, 9)}`;\n // Active-descendant highlight state, driven by the parent by index.\n @Input() active = false;\n\n hidden = false;\n\n constructor(\n @Optional() private selectService: SelectService,\n private el: ElementRef\n ) {}\n\n // Helper method for the parent to get the searchable text\n getLabel(): string {\n return this.el.nativeElement.textContent?.trim() || '';\n }\n\n // Scroll this option into view when it becomes the active descendant.\n scrollIntoActiveView(): void {\n this.el.nativeElement.scrollIntoView({ block: 'nearest' });\n }\n\n getItemClasses(): string {\n return cn(\n // Base state\n 'focus:bg-accent focus:text-accent-foreground',\n\n // Active-descendant (keyboard) highlight\n 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',\n\n // Hover states (only if not disabled)\n !this.disabled && [\n 'cursor-pointer',\n 'hover:bg-accent hover:text-accent-foreground'\n ],\n\n // Selected state\n this.selected && [\n this.multiSelect\n ? 'bg-primary/5 text-foreground'\n : 'bg-accent text-accent-foreground'\n ],\n\n // Disabled state\n this.disabled && [\n 'opacity-50',\n 'cursor-not-allowed',\n 'hover:bg-transparent hover:text-foreground'\n ]\n );\n }\n\n @HostListener('click', ['$event'])\n onClick(event: MouseEvent) {\n if (this.hidden || this.disabled) return;\n\n event.stopPropagation();\n if (this.selectService) {\n const label = this.getLabel();\n\n if (this.multiSelect) {\n // For multi-select, toggle selection\n this.selected = !this.selected;\n }\n // For both single and multi-select, notify parent\n this.selectService.registerClick(this.value, label);\n }\n }\n\n protected cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "select.service.ts",
|
|
17
|
+
"target": "ui/select.service.ts",
|
|
18
|
+
"content": "import { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\n@Injectable()\nexport class SelectService {\n // Emits the value of the clicked item\n private selectedValueSource = new Subject<any>();\n selectedValue$ = this.selectedValueSource.asObservable();\n\n // Emits the label/text of the clicked item\n private selectedLabelSource = new Subject<string>();\n selectedLabel$ = this.selectedLabelSource.asObservable();\n\n registerClick(value: any, label: string) {\n this.selectedValueSource.next(value);\n this.selectedLabelSource.next(label);\n }\n}\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "utils/cn.ts",
|
|
22
|
+
"target": "ui/utils/cn.ts",
|
|
23
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "select-separator",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Select Separator",
|
|
5
|
+
"description": "Select Separator component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "select-separator.component.ts",
|
|
12
|
+
"target": "ui/select-separator.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-select-separator',\n standalone: true,\n template: `\n <div role=\"separator\" aria-hidden=\"true\" [class]=\"cn('-mx-1 my-1 h-px bg-border', class)\"></div>\n `,\n})\nexport class SelectSeparatorComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "select",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Select",
|
|
5
|
+
"description": "Select component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"input",
|
|
9
|
+
"select-item"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": [
|
|
12
|
+
"@floating-ui/dom"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "select.component.ts",
|
|
17
|
+
"target": "ui/select.component.ts",
|
|
18
|
+
"content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChild,\n OnDestroy,\n ContentChildren,\n QueryList,\n AfterContentInit,\n ChangeDetectorRef\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\n// 1. Import 'size' middleware\nimport { computePosition, flip, shift, offset, autoUpdate, size } from '@floating-ui/dom';\nimport { cn } from './utils/cn';\nimport { SelectItemComponent } from './select-item.component';\nimport { Subscription } from 'rxjs';\nimport { SelectService } from './select.service';\nimport { InputComponent } from './input.component';\n\n@Component({\n selector: 'tolle-select',\n standalone: true,\n imports: [CommonModule, FormsModule, InputComponent],\n providers: [\n SelectService,\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SelectComponent),\n multi: true\n }\n ],\n styles: [`\n .hidden-dropdown {\n display: none !important;\n }\n `],\n template: `\n <div class=\"relative w-full\" #container>\n <button\n type=\"button\"\n #trigger\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-activedescendant]=\"isOpen ? activeDescendantId : null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n >\n <span class=\"truncate\" [class.text-muted-foreground]=\"!selectedLabel\">\n {{ selectedLabel || placeholder }}\n </span>\n <i [class]=\"iconClass\"></i>\n </button>\n\n <div\n #popover\n [class.hidden-dropdown]=\"!isOpen\"\n class=\"fixed bg-popover z-[999] overflow-auto flex flex-col rounded-md border border-border text-popover-foreground shadow-md\"\n style=\"visibility: hidden; top: 0; left: 0;\">\n <div *ngIf=\"searchable\" class=\"p-2 border-b border-border bg-popover h-auto\">\n <tolle-input\n #searchInput\n size=\"xs\"\n placeholder=\"Search...\"\n [(ngModel)]=\"searchQuery\"\n (ngModelChange)=\"onSearchChange($event)\"\n (keydown)=\"onSearchKeyDown($event)\"\n class=\"w-full\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n </div>\n\n <div role=\"listbox\" class=\"p-1 overflow-y-auto grow h-full w-full\">\n <ng-content></ng-content>\n <div *ngIf=\"noResults\" class=\"py-6 text-center text-sm text-muted-foreground\">\n No results found.\n </div>\n </div>\n </div>\n </div>\n `,\n})\nexport class SelectComponent implements ControlValueAccessor, AfterContentInit, OnDestroy {\n @Input() placeholder = 'Select an option';\n @Input() class = '';\n @Input() disabled = false;\n @Input() searchable = false;\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() readonly = false;\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n @ViewChild('container') container!: ElementRef;\n @ViewChild('searchInput', { read: ElementRef }) searchInput?: ElementRef<HTMLElement>;\n @ContentChildren(SelectItemComponent, { descendants: true }) items!: QueryList<SelectItemComponent>;\n\n private sub = new Subscription();\n private itemsChangeSub?: Subscription;\n private pendingValue: any = undefined;\n\n searchQuery = '';\n noResults = false;\n isOpen = false;\n value: any = null;\n selectedLabel = '';\n cleanupAutoUpdate?: () => void;\n\n // Keyboard navigation (active-descendant) state\n activeIndex = -1;\n private typeaheadBuffer = '';\n private typeaheadTime = 0;\n private pendingTypeaheadChar = '';\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n protected cn = cn;\n\n constructor(\n private selectService: SelectService,\n private cdr: ChangeDetectorRef\n ) {\n this.sub.add(\n this.selectService.selectedValue$.subscribe(val => {\n this.value = val;\n this.onChange(val);\n this.updateItemSelection();\n })\n );\n\n this.sub.add(\n this.selectService.selectedLabel$.subscribe(label => {\n this.selectedLabel = label;\n this.close();\n })\n );\n }\n\n get computedTriggerClass() {\n return cn(\n 'flex w-full items-center justify-between rounded-md border transition-all duration-200',\n 'bg-background text-foreground',\n 'border-input shadow-sm',\n this.size === 'xs' && 'h-8 px-2 text-xs',\n this.size === 'sm' && 'h-9 px-3 text-sm',\n this.size === 'default' && 'h-10 px-3 text-sm',\n this.size === 'lg' && 'h-11 px-4 text-base',\n !(this.readonly || this.disabled) && [\n 'focus-visible:outline-none',\n 'focus-visible:ring-4',\n 'focus-visible:ring-ring/30',\n 'focus-visible:ring-offset-0',\n 'focus-visible:shadow-none',\n 'focus-visible:border-primary/80'\n ],\n !(this.readonly || this.disabled) && 'hover:border-accent',\n this.disabled && [\n 'cursor-not-allowed opacity-50',\n 'border-opacity-50'\n ],\n this.readonly && [\n 'cursor-default',\n 'border-dashed',\n !this.disabled && 'focus-visible:ring-0 focus-visible:border-opacity-100'\n ],\n this.class\n );\n }\n\n get iconClass() {\n return cn(\n 'ri-arrow-down-s-line text-muted-foreground ml-2 transition-transform duration-200',\n this.isOpen ? 'rotate-180' : '',\n (this.size === 'xs' || this.size === 'sm') ? 'text-[14px]' : 'text-[18px]',\n (this.disabled || this.readonly) && 'opacity-30'\n );\n }\n\n ngAfterContentInit() {\n // Subscribe to items changes to handle dynamic content\n this.itemsChangeSub = this.items.changes.subscribe(() => {\n this.updateItemSelection();\n this.applyPendingValue();\n });\n\n // Apply initial selection if items are already available\n this.updateItemSelection();\n this.applyPendingValue();\n }\n\n private applyPendingValue(): void {\n if (this.pendingValue !== undefined && this.items && this.items.length > 0) {\n const found = this.items.find(i => i.value === this.pendingValue);\n if (found) {\n this.selectedLabel = found.getLabel();\n this.cdr.markForCheck();\n }\n this.pendingValue = undefined;\n }\n }\n\n private updateItemSelection() {\n if (this.items) {\n this.items.forEach(item => {\n item.selected = item.value === this.value;\n });\n }\n }\n\n private syncSelectedLabel(): void {\n if (this.items) {\n const found = this.items.find(i => i.value === this.value);\n if (found) {\n this.selectedLabel = found.getLabel();\n this.cdr.markForCheck();\n }\n }\n }\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.trigger.nativeElement.contains(event.target) && !this.popover?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n toggle() {\n if (this.disabled || this.readonly) return;\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n this.isOpen = true;\n this.trigger.nativeElement.focus();\n requestAnimationFrame(() => {\n this.updatePosition();\n document.addEventListener('mousedown', this._outsideClickHandler);\n this.initActiveAfterOpen();\n });\n }\n\n close() {\n this.isOpen = false;\n this.searchQuery = '';\n this.onSearchChange('');\n this.resetActive();\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n private updatePosition() {\n if (!this.trigger || !this.popover) return;\n\n this.cleanupAutoUpdate = autoUpdate(\n this.trigger.nativeElement,\n this.popover.nativeElement,\n () => {\n computePosition(this.trigger.nativeElement, this.popover.nativeElement, {\n strategy: 'fixed', // 3. Use fixed strategy\n placement: 'bottom-start',\n middleware: [\n offset(4),\n flip(),\n shift({ padding: 8 }),\n // 4. Use size middleware to sync width and handle constraints\n size({\n apply({ rects, elements, availableHeight }) {\n Object.assign(elements.floating.style, {\n width: `${rects.reference.width}px`,\n maxHeight: `${availableHeight}px`\n });\n },\n }),\n ],\n }).then(({ x, y, strategy }) => {\n Object.assign(this.popover.nativeElement.style, {\n position: strategy, // 5. Apply strategy to style\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n });\n }\n );\n }\n\n onSearchChange(query: string) {\n const filter = (query || '').toLowerCase().trim();\n let visibleCount = 0;\n\n this.items.forEach(item => {\n const text = item.getLabel().toLowerCase();\n const isVisible = text.includes(filter);\n item.hidden = !isVisible;\n if (isVisible) visibleCount++;\n });\n\n this.noResults = visibleCount === 0 && filter !== '';\n\n // Re-anchor the active option to the first visible match while open.\n if (this.isOpen) this.setActive(0);\n }\n\n // ---- Keyboard navigation (WAI-ARIA listbox pattern) ----\n\n private get navigableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(i => !i.hidden && !i.disabled) : [];\n }\n\n get activeDescendantId(): string | null {\n const list = this.navigableItems;\n return this.activeIndex >= 0 && this.activeIndex < list.length\n ? list[this.activeIndex].id\n : null;\n }\n\n private updateActiveStates() {\n const list = this.navigableItems;\n const activeItem = this.activeIndex >= 0 ? list[this.activeIndex] ?? null : null;\n this.items?.forEach(i => (i.active = i === activeItem));\n this.syncActiveDescendant();\n }\n\n private syncActiveDescendant() {\n const input = this.searchInput?.nativeElement?.querySelector('input');\n if (!input) return;\n const id = this.activeDescendantId;\n if (id) input.setAttribute('aria-activedescendant', id);\n else input.removeAttribute('aria-activedescendant');\n }\n\n private setActive(index: number) {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n this.activeIndex = Math.max(0, Math.min(index, list.length - 1));\n this.updateActiveStates();\n list[this.activeIndex]?.scrollIntoActiveView();\n }\n\n private setActiveItem(item: SelectItemComponent) {\n const idx = this.navigableItems.indexOf(item);\n if (idx >= 0) this.setActive(idx);\n }\n\n private moveActive(delta: number) {\n const list = this.navigableItems;\n if (!list.length) return;\n const next = this.activeIndex < 0 ? (delta > 0 ? 0 : list.length - 1) : this.activeIndex + delta;\n this.setActive(next); // setActive clamps (no wrap, matching shadcn)\n }\n\n private setActiveToSelectedOrFirst() {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n const selIdx = list.findIndex(i => i.value === this.value);\n this.setActive(selIdx >= 0 ? selIdx : 0);\n }\n\n private initActiveAfterOpen() {\n if (this.pendingTypeaheadChar) {\n const c = this.pendingTypeaheadChar;\n this.pendingTypeaheadChar = '';\n this.setActiveToSelectedOrFirst();\n this.typeahead(c);\n } else {\n this.setActiveToSelectedOrFirst();\n }\n }\n\n private resetActive() {\n this.activeIndex = -1;\n this.typeaheadBuffer = '';\n this.pendingTypeaheadChar = '';\n this.items?.forEach(i => (i.active = false));\n }\n\n private isTypeaheadChar(event: KeyboardEvent): boolean {\n return event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;\n }\n\n private typeahead(char: string) {\n const now = Date.now();\n // Reset the buffer based on elapsed time (correctness is not timer-driven).\n if (now - this.typeaheadTime > 500) this.typeaheadBuffer = '';\n this.typeaheadTime = now;\n this.typeaheadBuffer += char.toLowerCase();\n const match = this.navigableItems.find(i =>\n i.getLabel().toLowerCase().startsWith(this.typeaheadBuffer)\n );\n if (match) this.setActiveItem(match);\n }\n\n private selectActive() {\n const item = this.navigableItems[this.activeIndex];\n if (!item) return;\n // Single-select: notify parent (which closes and syncs the label).\n this.selectService.registerClick(item.value, item.getLabel());\n this.trigger.nativeElement.focus();\n }\n\n onTriggerKeyDown(event: KeyboardEvent) {\n if (this.disabled || this.readonly) return;\n const key = event.key;\n\n if (!this.isOpen) {\n if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ' || key === 'Home' || key === 'End') {\n event.preventDefault();\n this.open();\n return;\n }\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.pendingTypeaheadChar = key;\n this.open();\n }\n return;\n }\n\n switch (key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Home': event.preventDefault(); this.setActive(0); break;\n case 'End': event.preventDefault(); this.setActive(this.navigableItems.length - 1); break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n this.selectActive();\n break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n case 'Tab':\n this.close();\n break;\n default:\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.typeahead(key);\n }\n }\n }\n\n // Keydown while the (optional) search input is focused. Arrows/Enter/Escape\n // drive the list; other keys fall through so the user can type to filter.\n onSearchKeyDown(event: KeyboardEvent) {\n if (!this.isOpen) return;\n switch (event.key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Enter': event.preventDefault(); this.selectActive(); break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n }\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.updateItemSelection();\n if (this.items && this.items.length > 0) {\n const found = this.items.find(i => i.value === value);\n if (found) {\n this.selectedLabel = found.getLabel();\n this.cdr.markForCheck();\n }\n } else {\n // Queue the value for when items become available\n this.pendingValue = value;\n }\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; }\n\n ngOnDestroy() {\n this.sub.unsubscribe();\n this.itemsChangeSub?.unsubscribe();\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n}\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "select.service.ts",
|
|
22
|
+
"target": "ui/select.service.ts",
|
|
23
|
+
"content": "import { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\n@Injectable()\nexport class SelectService {\n // Emits the value of the clicked item\n private selectedValueSource = new Subject<any>();\n selectedValue$ = this.selectedValueSource.asObservable();\n\n // Emits the label/text of the clicked item\n private selectedLabelSource = new Subject<string>();\n selectedLabel$ = this.selectedLabelSource.asObservable();\n\n registerClick(value: any, label: string) {\n this.selectedValueSource.next(value);\n this.selectedLabelSource.next(label);\n }\n}\n"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "utils/cn.ts",
|
|
27
|
+
"target": "ui/utils/cn.ts",
|
|
28
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "separator",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Separator",
|
|
5
|
+
"description": "Separator component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "separator.component.ts",
|
|
12
|
+
"target": "ui/separator.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-separator',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n [class]=\"computedClass\"\n [attr.role]=\"decorative ? 'none' : 'separator'\"\n [attr.aria-hidden]=\"decorative ? 'true' : null\"\n [attr.aria-orientation]=\"!decorative && orientation === 'vertical' ? 'vertical' : null\"\n [attr.data-orientation]=\"orientation\"\n ></div>\n `\n})\nexport class SeparatorComponent {\n /** Orientation of the rule. @default 'horizontal' */\n @Input() orientation: 'horizontal' | 'vertical' = 'horizontal';\n /** When true, the separator is purely visual (aria-hidden, role=\"none\"). @default true */\n @Input() decorative: boolean = true;\n @Input() class: string = '';\n\n get computedClass() {\n return cn(\n \"shrink-0 bg-border\",\n this.orientation === \"horizontal\" ? \"h-px w-full\" : \"h-full w-px\",\n this.class\n );\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|