@tolle_/tolle-ui 18.2.28 → 18.2.30
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 +5 -11
- package/esm2022/lib/accordion.component.mjs +4 -9
- package/esm2022/lib/alert-dialog-dynamic.component.mjs +5 -10
- package/esm2022/lib/alert-dialog.component.mjs +44 -59
- package/esm2022/lib/alert.component.mjs +5 -11
- package/esm2022/lib/aspect-ratio.component.mjs +119 -0
- package/esm2022/lib/attachment.component.mjs +334 -0
- package/esm2022/lib/avatar-fallback.component.mjs +3 -3
- package/esm2022/lib/avatar.component.mjs +4 -9
- package/esm2022/lib/badge.component.mjs +4 -9
- package/esm2022/lib/breadcrumb-item.component.mjs +3 -3
- package/esm2022/lib/breadcrumb-link.component.mjs +3 -3
- package/esm2022/lib/breadcrumb-separator.component.mjs +3 -3
- package/esm2022/lib/breadcrumb.component.mjs +3 -3
- package/esm2022/lib/bubble.component.mjs +301 -0
- package/esm2022/lib/button.component.mjs +5 -11
- package/esm2022/lib/calendar.component.mjs +5 -11
- package/esm2022/lib/card.component.mjs +14 -43
- package/esm2022/lib/carousel/carousel.component.mjs +5 -12
- package/esm2022/lib/chain-of-thought.component.mjs +380 -0
- package/esm2022/lib/chart-pie.component.mjs +386 -0
- package/esm2022/lib/chart.component.mjs +1146 -0
- package/esm2022/lib/chart.service.mjs +555 -0
- package/esm2022/lib/checkbox.component.mjs +5 -11
- package/esm2022/lib/checkpoint.component.mjs +134 -0
- package/esm2022/lib/collapsible.component.mjs +4 -11
- package/esm2022/lib/combobox.component.mjs +372 -0
- package/esm2022/lib/command-dialog.component.mjs +164 -0
- package/esm2022/lib/command.component.mjs +514 -0
- package/esm2022/lib/command.service.mjs +130 -0
- package/esm2022/lib/confirmation.component.mjs +219 -0
- package/esm2022/lib/context-menu-trigger.directive.mjs +36 -0
- package/esm2022/lib/context-menu.component.mjs +460 -0
- package/esm2022/lib/context-menu.service.mjs +121 -0
- package/esm2022/lib/context.component.mjs +377 -0
- package/esm2022/lib/conversation.component.mjs +442 -0
- package/esm2022/lib/country-selector.component.mjs +5 -11
- package/esm2022/lib/data-table.component.mjs +63 -12
- package/esm2022/lib/date-picker.component.mjs +251 -198
- package/esm2022/lib/date-range-picker.component.mjs +224 -116
- package/esm2022/lib/date-time-picker.component.mjs +506 -0
- package/esm2022/lib/direction.component.mjs +79 -0
- package/esm2022/lib/dropdown-item.component.mjs +3 -3
- 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 +6 -4
- package/esm2022/lib/empty-state.component.mjs +4 -9
- package/esm2022/lib/field.component.mjs +394 -0
- package/esm2022/lib/hover-card.component.mjs +6 -16
- package/esm2022/lib/inline-citation.component.mjs +263 -0
- package/esm2022/lib/input-group.component.mjs +418 -0
- package/esm2022/lib/input.component.mjs +5 -11
- package/esm2022/lib/item.component.mjs +243 -0
- package/esm2022/lib/kbd.component.mjs +82 -0
- package/esm2022/lib/label.component.mjs +4 -9
- package/esm2022/lib/marker.component.mjs +135 -0
- package/esm2022/lib/masked-input.component.mjs +5 -11
- package/esm2022/lib/menubar.component.mjs +683 -0
- package/esm2022/lib/message-scroller.component.mjs +353 -0
- package/esm2022/lib/message-scroller.service.mjs +157 -0
- package/esm2022/lib/message.component.mjs +228 -0
- package/esm2022/lib/model-selector.component.mjs +310 -0
- package/esm2022/lib/multi-select.component.mjs +155 -72
- package/esm2022/lib/native-select.component.mjs +201 -0
- package/esm2022/lib/navigation-menu.component.mjs +642 -0
- package/esm2022/lib/otp-group.component.mjs +3 -3
- package/esm2022/lib/otp-slot.component.mjs +4 -9
- package/esm2022/lib/otp.component.mjs +5 -11
- package/esm2022/lib/pagination.component.mjs +7 -10
- package/esm2022/lib/plan.component.mjs +335 -0
- package/esm2022/lib/popover-content.component.mjs +4 -9
- package/esm2022/lib/popover.component.mjs +15 -13
- package/esm2022/lib/progress.component.mjs +4 -9
- package/esm2022/lib/prompt-input.component.mjs +526 -0
- package/esm2022/lib/queue.component.mjs +280 -0
- package/esm2022/lib/radio-group.component.mjs +6 -13
- package/esm2022/lib/radio-item.component.mjs +4 -9
- package/esm2022/lib/range-calendar.component.mjs +5 -11
- package/esm2022/lib/reasoning.component.mjs +335 -0
- package/esm2022/lib/resizable.component.mjs +219 -0
- package/esm2022/lib/segment.component.mjs +17 -7
- package/esm2022/lib/select-group.component.mjs +4 -8
- package/esm2022/lib/select-item.component.mjs +4 -9
- package/esm2022/lib/select-separator.component.mjs +4 -8
- package/esm2022/lib/select.component.mjs +133 -49
- package/esm2022/lib/separator.component.mjs +4 -9
- package/esm2022/lib/sheet.component.mjs +38 -56
- package/esm2022/lib/shimmer.component.mjs +84 -0
- package/esm2022/lib/sidebar.component.mjs +11 -1
- package/esm2022/lib/skeleton.component.mjs +4 -9
- package/esm2022/lib/sources.component.mjs +452 -0
- package/esm2022/lib/spinner.component.mjs +68 -0
- package/esm2022/lib/suggestion.component.mjs +207 -0
- package/esm2022/lib/switch.component.mjs +5 -11
- package/esm2022/lib/table.component.mjs +213 -0
- package/esm2022/lib/tabs.component.mjs +4 -11
- package/esm2022/lib/tag-input.component.mjs +5 -11
- package/esm2022/lib/task.component.mjs +359 -0
- package/esm2022/lib/textarea.component.mjs +5 -11
- package/esm2022/lib/theme.service.mjs +17 -6
- package/esm2022/lib/time-picker.component.mjs +923 -0
- package/esm2022/lib/toast.service.mjs +3 -2
- package/esm2022/lib/toaster.component.mjs +76 -39
- package/esm2022/lib/toggle-group.component.mjs +4 -9
- package/esm2022/lib/toggle.component.mjs +5 -11
- package/esm2022/lib/tool.component.mjs +462 -0
- package/esm2022/lib/typography.component.mjs +83 -0
- package/esm2022/lib/utils/color.mjs +140 -19
- package/esm2022/public-api.mjs +61 -1
- package/fesm2022/tolle-ui.mjs +16457 -1024
- package/fesm2022/tolle-ui.mjs.map +1 -1
- package/lib/alert-dialog.component.d.ts +10 -2
- package/lib/aspect-ratio.component.d.ts +20 -0
- package/lib/attachment.component.d.ts +101 -0
- package/lib/bubble.component.d.ts +112 -0
- package/lib/chain-of-thought.component.d.ts +166 -0
- package/lib/chart-pie.component.d.ts +108 -0
- package/lib/chart.component.d.ts +369 -0
- package/lib/chart.service.d.ts +222 -0
- package/lib/checkpoint.component.d.ts +51 -0
- package/lib/combobox.component.d.ts +90 -0
- package/lib/command-dialog.component.d.ts +53 -0
- package/lib/command.component.d.ts +189 -0
- package/lib/command.service.d.ts +67 -0
- package/lib/confirmation.component.d.ts +70 -0
- package/lib/context-menu-trigger.directive.d.ts +12 -0
- package/lib/context-menu.component.d.ts +41 -0
- package/lib/context-menu.service.d.ts +38 -0
- package/lib/context.component.d.ts +134 -0
- package/lib/conversation.component.d.ts +165 -0
- package/lib/data-table.component.d.ts +27 -1
- package/lib/date-picker.component.d.ts +72 -11
- package/lib/date-range-picker.component.d.ts +63 -6
- package/lib/date-time-picker.component.d.ts +119 -0
- package/lib/direction.component.d.ts +45 -0
- package/lib/field.component.d.ts +201 -0
- package/lib/inline-citation.component.d.ts +103 -0
- package/lib/input-group.component.d.ts +150 -0
- package/lib/item.component.d.ts +154 -0
- package/lib/kbd.component.d.ts +50 -0
- package/lib/marker.component.d.ts +58 -0
- package/lib/menubar.component.d.ts +247 -0
- package/lib/message-scroller.component.d.ts +159 -0
- package/lib/message-scroller.service.d.ts +73 -0
- package/lib/message.component.d.ts +128 -0
- package/lib/model-selector.component.d.ts +103 -0
- package/lib/multi-select.component.d.ts +66 -14
- package/lib/native-select.component.d.ts +57 -0
- package/lib/navigation-menu.component.d.ts +230 -0
- package/lib/plan.component.d.ts +120 -0
- package/lib/popover.component.d.ts +1 -0
- package/lib/prompt-input.component.d.ts +194 -0
- package/lib/queue.component.d.ts +106 -0
- package/lib/reasoning.component.d.ts +127 -0
- package/lib/resizable.component.d.ts +43 -0
- package/lib/segment.component.d.ts +13 -3
- package/lib/select.component.d.ts +37 -5
- package/lib/sheet.component.d.ts +3 -0
- package/lib/shimmer.component.d.ts +38 -0
- package/lib/sidebar.component.d.ts +2 -0
- package/lib/sources.component.d.ts +156 -0
- package/lib/spinner.component.d.ts +29 -0
- package/lib/suggestion.component.d.ts +96 -0
- package/lib/table.component.d.ts +106 -0
- package/lib/task.component.d.ts +151 -0
- package/lib/theme.service.d.ts +9 -0
- package/lib/time-picker.component.d.ts +243 -0
- package/lib/toast.service.d.ts +3 -1
- package/lib/toaster.component.d.ts +26 -9
- package/lib/tool.component.d.ts +158 -0
- package/lib/typography.component.d.ts +32 -0
- package/lib/utils/color.d.ts +19 -5
- package/package.json +1 -1
- package/public-api.d.ts +50 -0
- package/registry/docs-content.json +8497 -1553
- package/registry/llms-full.txt +2679 -318
- package/registry/llms.txt +43 -4
- package/registry/manifest.json +9093 -1739
- package/registry/r/accordion-item.json +1 -1
- package/registry/r/accordion.json +1 -1
- package/registry/r/alert-dialog-dynamic.json +1 -1
- package/registry/r/alert-dialog.json +1 -1
- package/registry/r/alert.json +1 -1
- package/registry/r/attachment.json +23 -0
- package/registry/r/avatar-fallback.json +1 -1
- package/registry/r/avatar.json +1 -1
- package/registry/r/badge.json +1 -1
- package/registry/r/breadcrumb-item.json +1 -1
- package/registry/r/breadcrumb-link.json +1 -1
- package/registry/r/breadcrumb-separator.json +1 -1
- package/registry/r/breadcrumb.json +1 -1
- package/registry/r/bubble.json +23 -0
- package/registry/r/button.json +1 -1
- package/registry/r/calendar.json +1 -1
- package/registry/r/card.json +1 -1
- package/registry/r/carousel.json +1 -1
- package/registry/r/chain-of-thought.json +23 -0
- package/registry/r/chart-pie.json +30 -0
- package/registry/r/chart.json +28 -0
- package/registry/r/checkbox.json +1 -1
- package/registry/r/checkpoint.json +23 -0
- package/registry/r/collapsible.json +1 -1
- package/registry/r/combobox.json +31 -0
- package/registry/r/command-dialog.json +21 -0
- package/registry/r/command.json +26 -0
- package/registry/r/confirmation.json +23 -0
- package/registry/r/context.json +26 -0
- package/registry/r/conversation.json +23 -0
- package/registry/r/country-selector.json +1 -1
- package/registry/r/data-table.json +1 -1
- package/registry/r/date-picker.json +4 -4
- package/registry/r/date-range-picker.json +3 -3
- package/registry/r/date-time-picker.json +28 -0
- package/registry/r/direction.json +16 -0
- package/registry/r/dropdown-item.json +1 -1
- package/registry/r/dropdown-label.json +1 -1
- package/registry/r/dropdown-menu.json +1 -1
- package/registry/r/dropdown-separator.json +1 -1
- package/registry/r/dropdown-trigger.json +1 -1
- package/registry/r/empty-state.json +1 -1
- package/registry/r/field.json +23 -0
- package/registry/r/hover-card.json +1 -1
- package/registry/r/inline-citation.json +26 -0
- package/registry/r/input-group.json +23 -0
- package/registry/r/input.json +1 -1
- package/registry/r/item.json +23 -0
- package/registry/r/kbd.json +23 -0
- package/registry/r/label.json +1 -1
- package/registry/r/marker.json +23 -0
- package/registry/r/masked-input.json +1 -1
- package/registry/r/menubar.json +24 -0
- package/registry/r/message-scroller.json +26 -0
- package/registry/r/message.json +23 -0
- package/registry/r/model-selector.json +32 -0
- package/registry/r/multi-select.json +2 -2
- package/registry/r/native-select.json +23 -0
- package/registry/r/navigation-menu.json +24 -0
- package/registry/r/otp-group.json +1 -1
- package/registry/r/otp-slot.json +1 -1
- package/registry/r/otp.json +1 -1
- package/registry/r/pagination.json +3 -2
- package/registry/r/plan.json +23 -0
- package/registry/r/popover-content.json +1 -1
- package/registry/r/popover.json +1 -1
- package/registry/r/progress.json +1 -1
- package/registry/r/prompt-input.json +23 -0
- package/registry/r/queue.json +23 -0
- package/registry/r/radio-group.json +1 -1
- package/registry/r/radio-item.json +1 -1
- package/registry/r/range-calendar.json +1 -1
- package/registry/r/reasoning.json +23 -0
- package/registry/r/segment.json +1 -1
- package/registry/r/select-group.json +1 -1
- package/registry/r/select-item.json +1 -1
- package/registry/r/select-separator.json +1 -1
- package/registry/r/select.json +4 -3
- package/registry/r/separator.json +1 -1
- package/registry/r/sheet.json +1 -1
- package/registry/r/shimmer.json +23 -0
- package/registry/r/sidebar.json +1 -1
- package/registry/r/skeleton.json +1 -1
- package/registry/r/sources.json +23 -0
- package/registry/r/spinner.json +23 -0
- package/registry/r/suggestion.json +23 -0
- package/registry/r/switch.json +1 -1
- package/registry/r/table.json +21 -0
- package/registry/r/tabs.json +1 -1
- package/registry/r/tag-input.json +1 -1
- package/registry/r/task.json +23 -0
- package/registry/r/textarea.json +1 -1
- package/registry/r/time-picker.json +24 -0
- package/registry/r/toaster.json +2 -2
- package/registry/r/toggle-group.json +1 -1
- package/registry/r/toggle.json +1 -1
- package/registry/r/tool.json +23 -0
- package/registry/r/typography.json +23 -0
- package/registry/registry.json +915 -22
- package/theme.css +109 -8
package/registry/r/select.json
CHANGED
|
@@ -2,20 +2,21 @@
|
|
|
2
2
|
"name": "select",
|
|
3
3
|
"type": "registry:ui",
|
|
4
4
|
"title": "Select",
|
|
5
|
-
"description": "Select component.",
|
|
5
|
+
"description": "Select component with size and disabled and readonly options.",
|
|
6
6
|
"category": "forms",
|
|
7
7
|
"registryDependencies": [
|
|
8
8
|
"input",
|
|
9
9
|
"select-item"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": [
|
|
12
|
-
"@floating-ui/dom"
|
|
12
|
+
"@floating-ui/dom",
|
|
13
|
+
"class-variance-authority"
|
|
13
14
|
],
|
|
14
15
|
"files": [
|
|
15
16
|
{
|
|
16
17
|
"path": "select.component.ts",
|
|
17
18
|
"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
|
+
"content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n forwardRef,\n ElementRef,\n ViewChild,\n OnDestroy,\n ContentChildren,\n QueryList,\n AfterContentInit,\n OnChanges,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate, size } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\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\nconst selectTriggerVariants = cva(\n 'flex w-full items-center justify-between rounded-md border border-input bg-background text-foreground shadow-sm transition-all duration-200',\n {\n variants: {\n size: {\n xs: 'h-8 px-2 text-xs',\n sm: 'h-9 px-3 text-sm',\n default: 'h-10 px-3 text-sm',\n lg: 'h-11 px-4 text-base',\n },\n disabled: {\n true: 'cursor-not-allowed border-opacity-50 opacity-50',\n false: '',\n },\n readonly: {\n true: 'cursor-default border-dashed',\n false: '',\n },\n },\n compoundVariants: [\n // Only an interactive trigger gets the hover border and the focus ring.\n {\n disabled: false,\n readonly: false,\n class:\n 'hover:border-accent focus-visible:border-primary/80 focus-visible:shadow-none focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-0',\n },\n // Readonly still takes focus, but without the emphasis ring.\n {\n disabled: false,\n readonly: true,\n class: 'focus-visible:border-opacity-100 focus-visible:ring-0',\n },\n ],\n defaultVariants: { size: 'default', disabled: false, readonly: false },\n }\n);\n\nconst selectIconVariants = cva(\n 'ri-arrow-down-s-line ml-2 text-muted-foreground transition-transform duration-200',\n {\n variants: {\n size: {\n xs: 'text-[14px]',\n sm: 'text-[14px]',\n default: 'text-[18px]',\n lg: 'text-[18px]',\n },\n open: { true: 'rotate-180', false: '' },\n muted: { true: 'opacity-30', false: '' },\n },\n defaultVariants: { size: 'default', open: false, muted: false },\n }\n);\n\nexport type SelectProps = VariantProps<typeof selectTriggerVariants>;\n\n/**\n * A single-select listbox: a trigger that opens a floating panel of\n * `tolle-select-item` options, with optional inline search. Implements\n * `ControlValueAccessor`, so it works with `ngModel` and reactive forms.\n */\n@Component({\n selector: 'tolle-select',\n standalone: true,\n imports: [CommonModule, FormsModule, InputComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n SelectService,\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SelectComponent),\n multi: true,\n },\n ],\n styles: [':host { display: block; }', '.hidden-dropdown { display: none !important; }'],\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\n implements ControlValueAccessor, AfterContentInit, OnChanges, OnDestroy {\n /** Text shown on the trigger when nothing is selected. @default 'Select an option' */\n @Input() placeholder = 'Select an option';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). @default '' */\n @Input() class = '';\n /** Disables the control and blocks opening the panel. @default false */\n @Input() disabled = false;\n /** Adds a search box that filters the projected options. @default false */\n @Input() searchable = false;\n /** Height and text size of the trigger. @default 'default' */\n @Input() size: SelectProps['size'] = 'default';\n /** Shows the current value but blocks opening the panel. @default false */\n @Input() readonly = false;\n\n /** Emitted with the chosen value whenever the selection changes. */\n @Output() valueChange = new EventEmitter<any>();\n /** Emitted when the panel opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the panel closes. */\n @Output() closed = new EventEmitter<void>();\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.valueChange.emit(val);\n this.updateItemSelection();\n this.cdr.markForCheck();\n })\n );\n\n this.sub.add(\n this.selectService.selectedLabel$.subscribe(label => {\n this.selectedLabel = label;\n this.close();\n this.cdr.markForCheck();\n })\n );\n }\n\n get computedTriggerClass() {\n return cn(\n selectTriggerVariants({\n size: this.size,\n disabled: this.disabled,\n readonly: this.readonly,\n }),\n this.class\n );\n }\n\n get iconClass() {\n return cn(\n selectIconVariants({\n size: this.size,\n open: this.isOpen,\n muted: this.disabled || this.readonly,\n })\n );\n }\n\n /**\n * Under OnPush a `[class]` binding is applied through Angular's *styling*\n * path, which writes the input but — unlike a plain property binding — never\n * marks the component dirty. Without this the trigger would keep rendering\n * the class it was born with. Covering every input here is cheap and leaves\n * no second instance of that trap.\n */\n ngOnChanges() {\n this.cdr.markForCheck();\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 this.cdr.markForCheck();\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 _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 const wasOpen = this.isOpen;\n this.isOpen = true;\n this.trigger.nativeElement.focus();\n if (!wasOpen) this.opened.emit();\n this.cdr.markForCheck();\n requestAnimationFrame(() => {\n this.updatePosition();\n document.addEventListener('pointerdown', this._outsideClickHandler, true);\n this.initActiveAfterOpen();\n this.cdr.markForCheck();\n });\n }\n\n close() {\n const wasOpen = this.isOpen;\n this.isOpen = false;\n this.searchQuery = '';\n this.onSearchChange('');\n this.resetActive();\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n if (wasOpen) this.closed.emit();\n this.cdr.markForCheck();\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',\n placement: 'bottom-start',\n middleware: [\n offset(4),\n flip(),\n shift({ padding: 8 }),\n // Match the panel to the trigger width and cap its height to the\n // space actually available, so the list scrolls instead of\n // overflowing.\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,\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 this.cdr.markForCheck();\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 this.cdr.markForCheck();\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 }\n } else {\n // Queue the value for when items become available\n this.pendingValue = value;\n }\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n ngOnDestroy() {\n this.sub.unsubscribe();\n this.itemsChangeSub?.unsubscribe();\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n }\n}\n"
|
|
19
20
|
},
|
|
20
21
|
{
|
|
21
22
|
"path": "select.service.ts",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "separator.component.ts",
|
|
12
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"
|
|
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 styles: [':host { display: block; }'],\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
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "utils/cn.ts",
|
package/registry/r/sheet.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "sheet.component.ts",
|
|
12
12
|
"target": "ui/sheet.component.ts",
|
|
13
|
-
"content": "import { Component, Input, Output, EventEmitter, inject, TemplateRef, ViewChild, ViewContainerRef, OnDestroy, ContentChild, forwardRef, ElementRef, AfterContentInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Overlay, OverlayRef, OverlayConfig, ScrollStrategyOptions } from '@angular/cdk/overlay';\nimport { TemplatePortal } from '@angular/cdk/portal';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-sheet',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`\n})\nexport class SheetComponent {\n @Input() isOpen = false;\n @Input() hasBackdrop = true;\n @Output() isOpenChange = new EventEmitter<boolean>();\n\n private overlay = inject(Overlay);\n private viewContainerRef = inject(ViewContainerRef);\n private scrollStrategy = inject(ScrollStrategyOptions);\n\n private overlayRef?: OverlayRef;\n /** Element focused before the sheet opened, restored on close. */\n private previouslyFocused?: HTMLElement | null;\n\n @ContentChild(forwardRef(() => SheetContentComponent)) contentComponent?: any;\n\n open() {\n if (this.isOpen) return;\n this.isOpen = true;\n this.isOpenChange.emit(true);\n this.show();\n }\n\n close() {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.isOpenChange.emit(false);\n this.hide();\n }\n\n private show() {\n if (this.overlayRef || !this.contentComponent?.contentTemplate) return;\n\n // Remember the trigger so focus can be restored when the sheet closes.\n this.previouslyFocused = document.activeElement as HTMLElement | null;\n\n const overlayConfig = new OverlayConfig({\n hasBackdrop: this.hasBackdrop,\n backdropClass: 'tolle-modal-backdrop',\n scrollStrategy: this.scrollStrategy.block(),\n positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically() // This centers key, but sheet is fixed via classes\n });\n\n this.overlayRef = this.overlay.create(overlayConfig);\n\n const portal = new TemplatePortal(this.contentComponent.contentTemplate, this.viewContainerRef);\n this.overlayRef.attach(portal);\n\n if (this.hasBackdrop) {\n this.overlayRef.backdropClick().subscribe(() => this.close());\n }\n\n // Escape-to-close.\n this.overlayRef.keydownEvents().subscribe((event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n event.preventDefault();\n this.close();\n }\n });\n }\n\n private hide() {\n if (this.overlayRef) {\n const toRestore = this.previouslyFocused;\n setTimeout(() => {\n if (this.overlayRef) {\n this.overlayRef.detach();\n this.overlayRef.dispose();\n this.overlayRef = undefined;\n }\n // Restore focus to the trigger after the exit animation.\n toRestore?.focus?.();\n }, 300);\n }\n }\n\n ngOnDestroy() {\n // Immediate cleanup on destroy\n if (this.overlayRef) {\n this.overlayRef.dispose();\n }\n }\n}\n\n@Component({\n selector: 'tolle-sheet-trigger',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n '(click)': 'onClick()',\n 'class': 'inline-block'\n }\n})\nexport class SheetTriggerComponent {\n private sheet = inject(SheetComponent);\n\n onClick() {\n this.sheet.open();\n }\n}\n\n@Component({\n selector: 'tolle-sheet-content',\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `\n <ng-template #sheetContent>\n <div\n [class]=\"computedClass\"\n [attr.data-state]=\"sheet.isOpen ? 'open' : 'closed'\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"titleCmp?.id || null\"\n [attr.aria-describedby]=\"descriptionCmp?.id || null\"\n cdkTrapFocus cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n <button\n (click)=\"close()\"\n class=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none\"\n >\n <i class=\"ri-close-line text-xl\"></i>\n <span class=\"sr-only\">Close</span>\n </button>\n <ng-content></ng-content>\n </div>\n </ng-template>\n `\n})\nexport class SheetContentComponent {\n @Input() side: 'top' | 'bottom' | 'left' | 'right' = 'right';\n @Input() rounded: boolean = false;\n @Input() class: string = '';\n\n @ViewChild('sheetContent', { static: true }) contentTemplate!: TemplateRef<any>;\n\n /** Projected title/description used to build the dialog's accessible name. */\n @ContentChild(forwardRef(() => SheetTitleComponent)) titleCmp?: SheetTitleComponent;\n @ContentChild(forwardRef(() => SheetDescriptionComponent)) descriptionCmp?: SheetDescriptionComponent;\n\n public sheet = inject(SheetComponent);\n\n close() {\n this.sheet.close();\n }\n\n get computedClass() {\n const sideClasses = {\n top: \"inset-x-0 top-0 border-b data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n bottom: \"inset-x-0 bottom-0 border-t data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n left: \"inset-y-0 left-0 h-full w-3/4 border-r data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm\",\n right: \"inset-y-0 right-0 h-full w-3/4 border-l data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm\",\n };\n\n const roundingClasses = {\n top: \"rounded-b-2xl\",\n bottom: \"rounded-t-2xl\",\n left: \"rounded-r-2xl\",\n right: \"rounded-l-2xl\",\n };\n\n return cn(\n \"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-500 data-[state=closed]:duration-300\",\n sideClasses[this.side],\n this.rounded && roundingClasses[this.side],\n this.class\n );\n }\n}\n\n@Component({\n selector: 'tolle-sheet-header',\n standalone: true,\n template: `\n <div class=\"flex flex-col space-y-2 text-center sm:text-left\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class SheetHeaderComponent { }\n\n@Component({\n selector: 'tolle-sheet-footer',\n standalone: true,\n template: `\n <div class=\"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class SheetFooterComponent { }\n\n@Component({\n selector: 'tolle-sheet-title',\n standalone: true,\n template: `\n <h3 class=\"text-lg font-semibold text-foreground\">\n <ng-content></ng-content>\n </h3>\n `,\n host: { '[attr.id]': 'id' }\n})\nexport class SheetTitleComponent {\n /** Stable id referenced by the sheet's `aria-labelledby`. */\n readonly id = `tolle-sheet-title-${Math.random().toString(36).substr(2, 9)}`;\n}\n\n@Component({\n selector: 'tolle-sheet-description',\n standalone: true,\n template: `\n <p class=\"text-sm text-muted-foreground\">\n <ng-content></ng-content>\n </p>\n `,\n host: { '[attr.id]': 'id' }\n})\nexport class SheetDescriptionComponent {\n /** Stable id referenced by the sheet's `aria-describedby`. */\n readonly id = `tolle-sheet-description-${Math.random().toString(36).substr(2, 9)}`;\n}\n"
|
|
13
|
+
"content": "import { Component, Input, Output, EventEmitter, inject, TemplateRef, ViewChild, ViewContainerRef, OnDestroy, ContentChild, forwardRef, ElementRef, AfterContentInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Overlay, OverlayRef, OverlayConfig, ScrollStrategyOptions } from '@angular/cdk/overlay';\nimport { TemplatePortal } from '@angular/cdk/portal';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-sheet',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`\n})\nexport class SheetComponent {\n @Input() isOpen = false;\n @Input() hasBackdrop = true;\n @Output() isOpenChange = new EventEmitter<boolean>();\n\n private overlay = inject(Overlay);\n private viewContainerRef = inject(ViewContainerRef);\n private scrollStrategy = inject(ScrollStrategyOptions);\n\n private overlayRef?: OverlayRef;\n /** Pending teardown scheduled by `hide()` for the exit animation. */\n private disposeTimer?: ReturnType<typeof setTimeout>;\n /** Element focused before the sheet opened, restored on close. */\n private previouslyFocused?: HTMLElement | null;\n\n @ContentChild(forwardRef(() => SheetContentComponent)) contentComponent?: any;\n\n open() {\n if (this.isOpen) return;\n this.isOpen = true;\n this.isOpenChange.emit(true);\n // Re-opening inside the 300ms exit window: cancel the pending teardown so the\n // still-attached overlay is reused. Without this the timer disposes the overlay\n // after `show()` has already bailed out, leaving isOpen=true with no overlay —\n // and every later open() short-circuits on the guard above.\n this.cancelPendingDispose();\n this.show();\n }\n\n private cancelPendingDispose() {\n if (this.disposeTimer !== undefined) {\n clearTimeout(this.disposeTimer);\n this.disposeTimer = undefined;\n }\n }\n\n close() {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.isOpenChange.emit(false);\n this.hide();\n }\n\n private show() {\n if (this.overlayRef || !this.contentComponent?.contentTemplate) return;\n\n // Remember the trigger so focus can be restored when the sheet closes.\n this.previouslyFocused = document.activeElement as HTMLElement | null;\n\n const overlayConfig = new OverlayConfig({\n hasBackdrop: this.hasBackdrop,\n backdropClass: 'tolle-modal-backdrop',\n scrollStrategy: this.scrollStrategy.block(),\n positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically() // This centers key, but sheet is fixed via classes\n });\n\n this.overlayRef = this.overlay.create(overlayConfig);\n\n const portal = new TemplatePortal(this.contentComponent.contentTemplate, this.viewContainerRef);\n this.overlayRef.attach(portal);\n\n if (this.hasBackdrop) {\n this.overlayRef.backdropClick().subscribe(() => this.close());\n }\n\n // Escape-to-close.\n this.overlayRef.keydownEvents().subscribe((event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n event.preventDefault();\n this.close();\n }\n });\n }\n\n private hide() {\n if (this.overlayRef) {\n const toRestore = this.previouslyFocused;\n this.cancelPendingDispose();\n this.disposeTimer = setTimeout(() => {\n this.disposeTimer = undefined;\n if (this.overlayRef) {\n this.overlayRef.detach();\n this.overlayRef.dispose();\n this.overlayRef = undefined;\n }\n // Restore focus to the trigger after the exit animation.\n toRestore?.focus?.();\n }, 300);\n }\n }\n\n ngOnDestroy() {\n // Immediate cleanup on destroy\n this.cancelPendingDispose();\n if (this.overlayRef) {\n this.overlayRef.dispose();\n }\n }\n}\n\n@Component({\n selector: 'tolle-sheet-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n '(click)': 'onClick()',\n 'class': 'inline-block'\n }\n})\nexport class SheetTriggerComponent {\n private sheet = inject(SheetComponent);\n\n onClick() {\n this.sheet.open();\n }\n}\n\n@Component({\n selector: 'tolle-sheet-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `\n <ng-template #sheetContent>\n <div\n [class]=\"computedClass\"\n [attr.data-state]=\"sheet.isOpen ? 'open' : 'closed'\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"titleCmp?.id || null\"\n [attr.aria-describedby]=\"descriptionCmp?.id || null\"\n cdkTrapFocus cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n <button\n (click)=\"close()\"\n class=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none\"\n >\n <i class=\"ri-close-line text-xl\"></i>\n <span class=\"sr-only\">Close</span>\n </button>\n <ng-content></ng-content>\n </div>\n </ng-template>\n `\n})\nexport class SheetContentComponent {\n @Input() side: 'top' | 'bottom' | 'left' | 'right' = 'right';\n @Input() rounded: boolean = false;\n @Input() class: string = '';\n\n @ViewChild('sheetContent', { static: true }) contentTemplate!: TemplateRef<any>;\n\n /** Projected title/description used to build the dialog's accessible name. */\n @ContentChild(forwardRef(() => SheetTitleComponent)) titleCmp?: SheetTitleComponent;\n @ContentChild(forwardRef(() => SheetDescriptionComponent)) descriptionCmp?: SheetDescriptionComponent;\n\n public sheet = inject(SheetComponent);\n\n close() {\n this.sheet.close();\n }\n\n get computedClass() {\n const sideClasses = {\n top: \"inset-x-0 top-0 border-b data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n bottom: \"inset-x-0 bottom-0 border-t data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n left: \"inset-y-0 left-0 h-full w-3/4 border-r data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm\",\n right: \"inset-y-0 right-0 h-full w-3/4 border-l data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm\",\n };\n\n const roundingClasses = {\n top: \"rounded-b-2xl\",\n bottom: \"rounded-t-2xl\",\n left: \"rounded-r-2xl\",\n right: \"rounded-l-2xl\",\n };\n\n return cn(\n \"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-500 data-[state=closed]:duration-300\",\n sideClasses[this.side],\n this.rounded && roundingClasses[this.side],\n this.class\n );\n }\n}\n\n@Component({\n selector: 'tolle-sheet-header',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <div class=\"flex flex-col space-y-2 text-center sm:text-left\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class SheetHeaderComponent { }\n\n@Component({\n selector: 'tolle-sheet-footer',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <div class=\"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class SheetFooterComponent { }\n\n@Component({\n selector: 'tolle-sheet-title',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <h3 class=\"text-lg font-semibold text-foreground\">\n <ng-content></ng-content>\n </h3>\n `,\n host: { '[attr.id]': 'id' }\n})\nexport class SheetTitleComponent {\n /** Stable id referenced by the sheet's `aria-labelledby`. */\n readonly id = `tolle-sheet-title-${Math.random().toString(36).substr(2, 9)}`;\n}\n\n@Component({\n selector: 'tolle-sheet-description',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <p class=\"text-sm text-muted-foreground\">\n <ng-content></ng-content>\n </p>\n `,\n host: { '[attr.id]': 'id' }\n})\nexport class SheetDescriptionComponent {\n /** Stable id referenced by the sheet's `aria-describedby`. */\n readonly id = `tolle-sheet-description-${Math.random().toString(36).substr(2, 9)}`;\n}\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "utils/cn.ts",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shimmer",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Shimmer",
|
|
5
|
+
"description": "Shimmer component with size and active options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "shimmer.component.ts",
|
|
14
|
+
"target": "ui/shimmer.component.ts",
|
|
15
|
+
"content": "import { ChangeDetectionStrategy, Component, Input, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst shimmerVariants = cva('inline-block max-w-full align-baseline', {\n variants: {\n size: {\n xs: 'text-xs',\n sm: 'text-sm',\n default: 'text-base',\n lg: 'text-lg',\n xl: 'text-xl',\n },\n active: {\n // `.shimmer` is the animated gradient-sweep utility from theme.css.\n true: 'shimmer',\n false: 'text-muted-foreground',\n },\n },\n defaultVariants: { size: 'default', active: true },\n});\n\nexport type ShimmerProps = VariantProps<typeof shimmerVariants>;\n\n/**\n * Shimmering text for a live, in-progress status — \"Thinking…\", \"Generating\n * response…\". While `active` the projected text sweeps with the `.shimmer`\n * utility; when it is false the same text renders plain and static, so the\n * label can stay mounted as the state settles.\n *\n * ```html\n * <tolle-shimmer [active]=\"isStreaming\" size=\"sm\">Generating response…</tolle-shimmer>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-shimmer',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <span\n [attr.role]=\"active ? 'status' : null\"\n [attr.aria-live]=\"active ? 'polite' : null\"\n [attr.data-active]=\"active ? '' : null\"\n [class]=\"computedClass\"\n >\n <ng-content></ng-content>\n </span>\n `,\n})\nexport class ShimmerComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Runs the shimmer animation; false renders plain static text. @default true */\n @Input() active = true;\n /** Text size of the label. @default 'default' */\n @Input() size: ShimmerProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the label via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(shimmerVariants({ size: this.size, active: this.active }), this.class);\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
|
+
}
|
package/registry/r/sidebar.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "sidebar.component.ts",
|
|
12
12
|
"target": "ui/sidebar.component.ts",
|
|
13
|
-
"content": "import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { RouterModule } from '@angular/router';\nimport { cn } from './utils/cn';\n\n// --- Interfaces ---\nexport type SidebarItem = {\n title: string;\n url?: string;\n icon?: string;\n isActive?: boolean;\n items?: SidebarItem[];\n id?: string;\n expanded?: boolean;\n /** Render as a plain anchor to an external URL / static file (opens in a new tab) instead of a router link. */\n external?: boolean;\n}\n\nexport type SidebarGroup = {\n title: string;\n items: SidebarItem[];\n id?: string;\n}\n\n@Component({\n selector: 'tolle-sidebar',\n standalone: true,\n imports: [CommonModule, RouterModule],\n template: `\n <aside [class]=\"cn(\n 'flex flex-col h-full bg-background transition-[width] duration-300 ease-in-out shrink-0 overflow-hidden',\n collapsed ? 'w-16' : 'w-64',\n class\n )\">\n <div class=\"flex h-14 shrink-0 items-center px-3 overflow-hidden whitespace-nowrap\">\n <ng-content select=\"[header]\"></ng-content>\n </div>\n\n <div class=\"flex-1 min-h-0 overflow-y-auto overflow-x-hidden py-4 px-3\">\n\n @for (group of items; track group.id || group.title || $index; let gIndex = $index) {\n <div class=\"mb-6\">\n\n <div [class]=\"cn(\n 'mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground/70 transition-all duration-200 overflow-hidden',\n collapsed ? 'opacity-0 h-0 mb-0 px-0' : 'opacity-100 h-auto'\n )\">\n {{ group.title }}\n </div>\n\n <div class=\"flex flex-col gap-1\">\n @for (item of group.items; track item.id || item.title || $index; let i = $index) {\n\n @if (item.items && item.items.length) {\n <!-- Parent Item (Group) -->\n <div class=\"relative\">\n <button\n type=\"button\"\n (click)=\"toggleParent(group, item, gIndex, i)\"\n [disabled]=\"collapsed\"\n [attr.aria-expanded]=\"isParentExpanded(group, item, gIndex, i)\"\n [class]=\"cn(\n 'group w-full relative flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground',\n isParentExpanded(group, item, gIndex, i) ? 'text-foreground bg-accent/50' : 'text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n\n @if (!collapsed) {\n <i [class]=\"cn(\n 'ri-arrow-down-s-line ml-auto transition-transform duration-300',\n isParentExpanded(group, item, gIndex, i) ? 'rotate-180' : ''\n )\"></i>\n }\n </button>\n\n <!-- Submenu Grid Transition -->\n <div [class]=\"cn(\n 'grid transition-[grid-template-rows] duration-300 ease-in-out',\n isParentExpanded(group, item, gIndex, i) && !collapsed ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'\n )\">\n <div class=\"overflow-hidden\">\n <div class=\"flex flex-col gap-1 mt-1 ml-4 border-l border-border/50 pl-2\">\n\n @for (subItem of item.items; track subItem.id || subItem.title || $index; let j = $index) {\n @if (subItem.items && subItem.items.length) {\n <!-- Grandchild Item Group (Recursive-ish logic for depth 2) -->\n <div class=\"relative\">\n <button\n type=\"button\"\n (click)=\"toggleChild(group, item, subItem, gIndex, i, j); $event.stopPropagation()\"\n [attr.aria-expanded]=\"isChildExpanded(group, item, subItem, gIndex, i, j)\"\n [class]=\"cn(\n 'group w-full relative flex items-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'text-foreground bg-accent/30' : ''\n )\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border shrink-0\"></span>\n <span class=\"ml-2 truncate\">{{ subItem.title }}</span>\n <i [class]=\"cn(\n 'ri-arrow-right-s-line ml-auto transition-transform duration-300 text-xs',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'rotate-90' : ''\n )\"></i>\n </button>\n\n <div [class]=\"cn(\n 'grid transition-[grid-template-rows] duration-300 ease-in-out',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'\n )\">\n <div class=\"overflow-hidden\">\n <div class=\"flex flex-col gap-1 mt-1 ml-4 border-l border-border/30 pl-2\">\n @for (grandChild of subItem.items; track grandChild.title) {\n <a\n [routerLink]=\"grandChild.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n class=\"flex items-center gap-2 hover:no-underline rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground text-muted-foreground whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border/70 shrink-0\"></span>\n <span class=\"truncate\">{{ grandChild.title }}</span>\n </a>\n }\n </div>\n </div>\n </div>\n </div>\n }\n @else {\n <!-- Standard Sub Item -->\n <a\n [routerLink]=\"subItem.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n class=\"flex items-center gap-2 hover:no-underline rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground text-muted-foreground whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border shrink-0\"></span>\n <span class=\"truncate\">{{ subItem.title }}</span>\n </a>\n }\n }\n </div>\n </div>\n </div>\n </div>\n }\n @else if (item.external) {\n <!-- External / static-file Item (opens in a new tab) -->\n <a\n [href]=\"item.url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n [class]=\"cn(\n 'group relative hover:no-underline flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n @if (!collapsed) {\n <i aria-hidden=\"true\" class=\"ri-external-link-line ml-auto text-xs opacity-50 transition-opacity group-hover:opacity-100\"></i>\n }\n </a>\n }\n @else {\n <!-- Standard Top-Level Item -->\n <a\n [routerLink]=\"item.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n [class]=\"cn(\n 'group relative hover:no-underline flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n </a>\n }\n }\n </div>\n </div>\n }\n </div>\n\n <div class=\"shrink-0 p-3 overflow-hidden whitespace-nowrap\">\n <ng-content select=\"[footer]\"></ng-content>\n </div>\n </aside>\n `,\n styles: [`\n :host { display: block; height: 100%; }\n button:disabled { cursor: not-allowed; opacity: 0.6; }\n * { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }\n `]\n})\nexport class SidebarComponent implements OnChanges {\n @Input() items: SidebarGroup[] = [];\n @Input() collapsed = false;\n @Input() class = '';\n\n /**\n * Styling variant for active items.\n * - default: Solid primary background, light text (Matches Badge Default)\n * - secondary: Solid secondary background\n * - ghost: Transparent background, accent text (Shadcn standard)\n * - outline: Bordered\n */\n @Input() variant: 'default' | 'secondary' | 'ghost' | 'outline' = 'default';\n\n expandedParents = new Set<string>();\n expandedChildren = new Set<string>();\n\n protected readonly cn = cn;\n\n get activeClasses(): string {\n return cn(\n 'transition-colors',\n // Default: Solid Primary (Badge-like) with lighter opacity (80% instead of 90%)\n this.variant === 'default' && 'bg-primary/80 text-primary-foreground hover:bg-primary hover:text-primary-foreground',\n\n // Secondary: Solid Secondary\n this.variant === 'secondary' && 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n\n // Ghost: Subtle background (Sidebar standard)\n this.variant === 'ghost' && 'bg-accent text-accent-foreground',\n\n // Outline: Bordered\n this.variant === 'outline' && 'border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground'\n );\n }\n\n ngOnChanges(changes: SimpleChanges) {\n if (changes['collapsed'] && this.collapsed) {\n this.collapseAll();\n return;\n }\n if (changes['items']) {\n this.initializeState();\n }\n }\n\n // --- STATE INIT ---\n private initializeState() {\n this.collapseAll();\n if (!this.items) return;\n\n this.items.forEach((group, gIndex) => {\n group.items.forEach((item, iIndex) => {\n const parentId = this.getParentId(group, item, gIndex, iIndex);\n\n if (item.expanded) {\n this.expandedParents.add(parentId);\n }\n\n if (item.items) {\n item.items.forEach((subItem, sIndex) => {\n if (subItem.expanded) {\n const childId = this.getChildId(group, item, subItem, gIndex, iIndex, sIndex);\n this.expandedChildren.add(childId);\n this.expandedParents.add(parentId);\n }\n });\n }\n });\n });\n }\n\n // --- ID GENERATORS ---\n getGroupId(group: SidebarGroup, index: number): string {\n return group.id || group.title || `g-${index}`;\n }\n\n getParentId(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number): string {\n const groupId = this.getGroupId(group, gIndex);\n const itemId = item.id || item.title || `p-${iIndex}`;\n return `${groupId}__${itemId}`;\n }\n\n getChildId(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number): string {\n const parentId = this.getParentId(group, parent, gIndex, iIndex);\n const subItemId = subItem.id || subItem.title || `c-${sIndex}`;\n return `${parentId}__${subItemId}`;\n }\n\n // --- ACTIONS ---\n toggleParent(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number) {\n if (this.collapsed) return;\n const id = this.getParentId(group, item, gIndex, iIndex);\n\n const newSet = new Set(this.expandedParents);\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n this.expandedParents = newSet;\n }\n\n toggleChild(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number) {\n const id = this.getChildId(group, parent, subItem, gIndex, iIndex, sIndex);\n\n const newSet = new Set(this.expandedChildren);\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n this.expandedChildren = newSet;\n }\n\n private collapseAll() {\n this.expandedParents = new Set();\n this.expandedChildren = new Set();\n }\n\n // --- TEMPLATE HELPERS ---\n isParentExpanded(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number): boolean {\n return this.expandedParents.has(this.getParentId(group, item, gIndex, iIndex));\n }\n\n isChildExpanded(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number): boolean {\n return this.expandedChildren.has(this.getChildId(group, parent, subItem, gIndex, iIndex, sIndex));\n }\n}\n"
|
|
13
|
+
"content": "import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { RouterModule } from '@angular/router';\nimport { cn } from './utils/cn';\n\n// --- Interfaces ---\nexport type SidebarItem = {\n title: string;\n url?: string;\n icon?: string;\n isActive?: boolean;\n items?: SidebarItem[];\n id?: string;\n expanded?: boolean;\n /** Render as a plain anchor to an external URL / static file (opens in a new tab) instead of a router link. */\n external?: boolean;\n /** Short trailing pill, e.g. 'New' or 'Beta'. Hidden while the sidebar is collapsed. */\n badge?: string;\n}\n\nexport type SidebarGroup = {\n title: string;\n items: SidebarItem[];\n id?: string;\n}\n\n@Component({\n selector: 'tolle-sidebar',\n standalone: true,\n imports: [CommonModule, RouterModule],\n template: `\n <aside [class]=\"cn(\n 'flex flex-col h-full bg-background transition-[width] duration-300 ease-in-out shrink-0 overflow-hidden',\n collapsed ? 'w-16' : 'w-64',\n class\n )\">\n <div class=\"flex h-14 shrink-0 items-center px-3 overflow-hidden whitespace-nowrap\">\n <ng-content select=\"[header]\"></ng-content>\n </div>\n\n <div class=\"flex-1 min-h-0 overflow-y-auto overflow-x-hidden py-4 px-3\">\n\n @for (group of items; track group.id || group.title || $index; let gIndex = $index) {\n <div class=\"mb-6\">\n\n <div [class]=\"cn(\n 'mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground/70 transition-all duration-200 overflow-hidden',\n collapsed ? 'opacity-0 h-0 mb-0 px-0' : 'opacity-100 h-auto'\n )\">\n {{ group.title }}\n </div>\n\n <div class=\"flex flex-col gap-1\">\n @for (item of group.items; track item.id || item.title || $index; let i = $index) {\n\n @if (item.items && item.items.length) {\n <!-- Parent Item (Group) -->\n <div class=\"relative\">\n <button\n type=\"button\"\n (click)=\"toggleParent(group, item, gIndex, i)\"\n [disabled]=\"collapsed\"\n [attr.aria-expanded]=\"isParentExpanded(group, item, gIndex, i)\"\n [class]=\"cn(\n 'group w-full relative flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground',\n isParentExpanded(group, item, gIndex, i) ? 'text-foreground bg-accent/50' : 'text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n\n @if (!collapsed) {\n <i [class]=\"cn(\n 'ri-arrow-down-s-line ml-auto transition-transform duration-300',\n isParentExpanded(group, item, gIndex, i) ? 'rotate-180' : ''\n )\"></i>\n }\n </button>\n\n <!-- Submenu Grid Transition -->\n <div [class]=\"cn(\n 'grid transition-[grid-template-rows] duration-300 ease-in-out',\n isParentExpanded(group, item, gIndex, i) && !collapsed ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'\n )\">\n <div class=\"overflow-hidden\">\n <div class=\"flex flex-col gap-1 mt-1 ml-4 border-l border-border/50 pl-2\">\n\n @for (subItem of item.items; track subItem.id || subItem.title || $index; let j = $index) {\n @if (subItem.items && subItem.items.length) {\n <!-- Grandchild Item Group (Recursive-ish logic for depth 2) -->\n <div class=\"relative\">\n <button\n type=\"button\"\n (click)=\"toggleChild(group, item, subItem, gIndex, i, j); $event.stopPropagation()\"\n [attr.aria-expanded]=\"isChildExpanded(group, item, subItem, gIndex, i, j)\"\n [class]=\"cn(\n 'group w-full relative flex items-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'text-foreground bg-accent/30' : ''\n )\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border shrink-0\"></span>\n <span class=\"ml-2 truncate\">{{ subItem.title }}</span>\n <i [class]=\"cn(\n 'ri-arrow-right-s-line ml-auto transition-transform duration-300 text-xs',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'rotate-90' : ''\n )\"></i>\n </button>\n\n <div [class]=\"cn(\n 'grid transition-[grid-template-rows] duration-300 ease-in-out',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'\n )\">\n <div class=\"overflow-hidden\">\n <div class=\"flex flex-col gap-1 mt-1 ml-4 border-l border-border/30 pl-2\">\n @for (grandChild of subItem.items; track grandChild.title) {\n <a\n [routerLink]=\"grandChild.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n class=\"flex items-center gap-2 hover:no-underline rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground text-muted-foreground whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border/70 shrink-0\"></span>\n <span class=\"truncate\">{{ grandChild.title }}</span>\n </a>\n }\n </div>\n </div>\n </div>\n </div>\n }\n @else {\n <!-- Standard Sub Item -->\n <a\n [routerLink]=\"subItem.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n class=\"flex items-center gap-2 hover:no-underline rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground text-muted-foreground whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border shrink-0\"></span>\n <span class=\"truncate\">{{ subItem.title }}</span>\n </a>\n }\n }\n </div>\n </div>\n </div>\n </div>\n }\n @else if (item.external) {\n <!-- External / static-file Item (opens in a new tab) -->\n <a\n [href]=\"item.url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n [class]=\"cn(\n 'group relative hover:no-underline flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n @if (!collapsed) {\n <i aria-hidden=\"true\" class=\"ri-external-link-line ml-auto text-xs opacity-50 transition-opacity group-hover:opacity-100\"></i>\n }\n </a>\n }\n @else {\n <!-- Standard Top-Level Item -->\n <a\n [routerLink]=\"item.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n [class]=\"cn(\n 'group relative hover:no-underline flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n @if (item.badge && !collapsed) {\n <span class=\"ml-auto shrink-0 rounded-full border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary\">\n {{ item.badge }}\n </span>\n }\n </a>\n }\n }\n </div>\n </div>\n }\n </div>\n\n <div class=\"shrink-0 p-3 overflow-hidden whitespace-nowrap\">\n <ng-content select=\"[footer]\"></ng-content>\n </div>\n </aside>\n `,\n styles: [`\n :host { display: block; height: 100%; }\n button:disabled { cursor: not-allowed; opacity: 0.6; }\n * { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }\n `]\n})\nexport class SidebarComponent implements OnChanges {\n @Input() items: SidebarGroup[] = [];\n @Input() collapsed = false;\n @Input() class = '';\n\n /**\n * Styling variant for active items.\n * - default: Solid primary background, light text (Matches Badge Default)\n * - secondary: Solid secondary background\n * - ghost: Transparent background, accent text (Shadcn standard)\n * - outline: Bordered\n */\n @Input() variant: 'default' | 'secondary' | 'ghost' | 'outline' = 'default';\n\n expandedParents = new Set<string>();\n expandedChildren = new Set<string>();\n\n protected readonly cn = cn;\n\n get activeClasses(): string {\n return cn(\n 'transition-colors',\n // Default: Solid Primary (Badge-like) with lighter opacity (80% instead of 90%)\n this.variant === 'default' && 'bg-primary/80 text-primary-foreground hover:bg-primary hover:text-primary-foreground',\n\n // Secondary: Solid Secondary\n this.variant === 'secondary' && 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n\n // Ghost: Subtle background (Sidebar standard)\n this.variant === 'ghost' && 'bg-accent text-accent-foreground',\n\n // Outline: Bordered\n this.variant === 'outline' && 'border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground'\n );\n }\n\n ngOnChanges(changes: SimpleChanges) {\n if (changes['collapsed'] && this.collapsed) {\n this.collapseAll();\n return;\n }\n if (changes['items']) {\n this.initializeState();\n }\n }\n\n // --- STATE INIT ---\n private initializeState() {\n this.collapseAll();\n if (!this.items) return;\n\n this.items.forEach((group, gIndex) => {\n group.items.forEach((item, iIndex) => {\n const parentId = this.getParentId(group, item, gIndex, iIndex);\n\n if (item.expanded) {\n this.expandedParents.add(parentId);\n }\n\n if (item.items) {\n item.items.forEach((subItem, sIndex) => {\n if (subItem.expanded) {\n const childId = this.getChildId(group, item, subItem, gIndex, iIndex, sIndex);\n this.expandedChildren.add(childId);\n this.expandedParents.add(parentId);\n }\n });\n }\n });\n });\n }\n\n // --- ID GENERATORS ---\n getGroupId(group: SidebarGroup, index: number): string {\n return group.id || group.title || `g-${index}`;\n }\n\n getParentId(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number): string {\n const groupId = this.getGroupId(group, gIndex);\n const itemId = item.id || item.title || `p-${iIndex}`;\n return `${groupId}__${itemId}`;\n }\n\n getChildId(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number): string {\n const parentId = this.getParentId(group, parent, gIndex, iIndex);\n const subItemId = subItem.id || subItem.title || `c-${sIndex}`;\n return `${parentId}__${subItemId}`;\n }\n\n // --- ACTIONS ---\n toggleParent(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number) {\n if (this.collapsed) return;\n const id = this.getParentId(group, item, gIndex, iIndex);\n\n const newSet = new Set(this.expandedParents);\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n this.expandedParents = newSet;\n }\n\n toggleChild(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number) {\n const id = this.getChildId(group, parent, subItem, gIndex, iIndex, sIndex);\n\n const newSet = new Set(this.expandedChildren);\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n this.expandedChildren = newSet;\n }\n\n private collapseAll() {\n this.expandedParents = new Set();\n this.expandedChildren = new Set();\n }\n\n // --- TEMPLATE HELPERS ---\n isParentExpanded(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number): boolean {\n return this.expandedParents.has(this.getParentId(group, item, gIndex, iIndex));\n }\n\n isChildExpanded(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number): boolean {\n return this.expandedChildren.has(this.getChildId(group, parent, subItem, gIndex, iIndex, sIndex));\n }\n}\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "utils/cn.ts",
|
package/registry/r/skeleton.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
{
|
|
13
13
|
"path": "skeleton.component.ts",
|
|
14
14
|
"target": "ui/skeleton.component.ts",
|
|
15
|
-
"content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst skeletonVariants = cva(\n // The background matches the Google Dark Mode \"Muted\" color\n 'animate-pulse bg-muted dark:bg-secondary rounded-md',\n {\n variants: {\n variant: {\n rect: '',\n circle: 'rounded-full',\n pill: 'rounded-full',\n },\n },\n defaultVariants: {\n variant: 'rect',\n },\n }\n);\n\nexport type SkeletonProps = VariantProps<typeof skeletonVariants>;\n\n@Component({\n selector: 'tolle-skeleton',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"computedClass\"></div>\n `,\n})\nexport class SkeletonComponent {\n /** Shape of the skeleton placeholder. @default 'rect' */\n @Input() variant: SkeletonProps['variant'] = 'rect';\n /** Extra Tailwind classes merged onto the skeleton via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(skeletonVariants({ variant: this.variant }), this.class);\n }\n}\n"
|
|
15
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst skeletonVariants = cva(\n // The background matches the Google Dark Mode \"Muted\" color\n 'animate-pulse bg-muted dark:bg-secondary rounded-md',\n {\n variants: {\n variant: {\n rect: '',\n circle: 'rounded-full',\n pill: 'rounded-full',\n },\n },\n defaultVariants: {\n variant: 'rect',\n },\n }\n);\n\nexport type SkeletonProps = VariantProps<typeof skeletonVariants>;\n\n@Component({\n selector: 'tolle-skeleton',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"computedClass\"></div>\n `,\n})\nexport class SkeletonComponent {\n /** Shape of the skeleton placeholder. @default 'rect' */\n @Input() variant: SkeletonProps['variant'] = 'rect';\n /** Extra Tailwind classes merged onto the skeleton via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(skeletonVariants({ variant: this.variant }), this.class);\n }\n}\n"
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
"path": "utils/cn.ts",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sources",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Sources",
|
|
5
|
+
"description": "Sources component with variant options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "sources.component.ts",
|
|
14
|
+
"target": "ui/sources.component.ts",
|
|
15
|
+
"content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Injectable, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { skip } from 'rxjs/operators';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nlet sourceId = 0;\n\n/**\n * Coordinates a `tolle-sources` with its trigger, content and sources.\n *\n * The trigger has to report how many sources were projected into a sibling\n * component, so every `tolle-source` registers here and the trigger renders\n * from the resulting count.\n *\n * Provided on `SourcesComponent`, so each citation list gets its own instance.\n */\n@Injectable()\nexport class SourcesService {\n private readonly openSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the citation list is expanded. */\n readonly open$ = this.openSource.asObservable();\n\n private readonly idsSource = new BehaviorSubject<string[]>([]);\n /** Emits the ids of the registered sources, in registration (DOM) order. */\n readonly ids$ = this.idsSource.asObservable();\n\n /** True while the citation list is expanded. */\n get open(): boolean {\n return this.openSource.value;\n }\n\n /** How many sources are currently registered. */\n get count(): number {\n return this.idsSource.value.length;\n }\n\n setOpen(value: boolean): void {\n if (this.openSource.value !== value) this.openSource.next(value);\n }\n\n toggle(): void {\n this.setOpen(!this.openSource.value);\n }\n\n register(id: string): void {\n if (this.idsSource.value.includes(id)) return;\n this.idsSource.next([...this.idsSource.value, id]);\n }\n\n unregister(id: string): void {\n this.idsSource.next(this.idsSource.value.filter((existing) => existing !== id));\n }\n\n /** Zero-based position of a source, or -1 when it is not registered. */\n indexOf(id: string): number {\n return this.idsSource.value.indexOf(id);\n }\n}\n\nconst sourcesVariants = cva('flex w-full flex-col gap-2 text-sm', {\n variants: {\n variant: {\n default: '',\n card: 'rounded-md border border-border bg-card p-3 text-card-foreground',\n muted: 'rounded-md bg-muted p-3',\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\nexport type SourcesProps = VariantProps<typeof sourcesVariants>;\n\n/**\n * A collapsible list of the citations behind a response.\n *\n * Compose it from `tolle-sources-trigger`, `tolle-sources-content` and one\n * `tolle-source` per citation — the trigger counts the sources for you.\n * @new\n */\n@Component({\n selector: 'tolle-sources',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [SourcesService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" [attr.data-state]=\"open ? 'open' : 'closed'\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SourcesComponent implements OnInit, OnChanges, OnDestroy {\n /** Whether the citation list is expanded. @default false */\n @Input() open = false;\n /** Visual treatment of the list container. @default 'default' */\n @Input() variant: SourcesProps['variant'] = 'default';\n /** Extra Tailwind classes merged onto the list via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new state whenever the list is expanded or collapsed. */\n @Output() openChange = new EventEmitter<boolean>();\n\n private readonly service = inject(SourcesService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n /** True while an `open` input is being pushed into the service. */\n private applyingInput = false;\n\n ngOnInit(): void {\n this.service.setOpen(this.open);\n // `skip(1)` drops the BehaviorSubject's current value so mounting never\n // emits a spurious `openChange`.\n this.subscriptions.add(\n this.service.open$.pipe(skip(1)).subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n // Only user-driven toggles are worth reporting; echoing a change the\n // parent just made would fight a `[(open)]` binding.\n if (!this.applyingInput) this.openChange.emit(open);\n })\n );\n }\n\n ngOnChanges(changes: SimpleChanges): void {\n if (changes['open']) this.applyInput(this.open);\n \n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n private applyInput(open: boolean): void {\n this.applyingInput = true;\n try {\n this.service.setOpen(open);\n } finally {\n this.applyingInput = false;\n }\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(sourcesVariants({ variant: this.variant }), this.class);\n }\n}\n\n/** Toggle for a `tolle-sources`, labelled with how many sources there are. */\n@Component({\n selector: 'tolle-sources-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n data-sources-trigger\n [attr.aria-expanded]=\"open\"\n [attr.data-state]=\"open ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onToggle()\"\n >\n <i class=\"ri-links-line\" aria-hidden=\"true\"></i>\n\n <span data-sources-label>{{ label }}</span>\n\n <i\n class=\"ri-arrow-down-s-line transition-transform duration-200\"\n [class.rotate-180]=\"open\"\n aria-hidden=\"true\"\n ></i>\n\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class SourcesTriggerComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Overrides the counted number of sources. @default null */\n @Input() count: number | null = null;\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(SourcesService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n registered = 0;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.ids$.subscribe((ids) => {\n this.registered = ids.length;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** How many sources the trigger reports. */\n get total(): number {\n return this.count ?? this.registered;\n }\n\n get label(): string {\n return 'Used ' + this.total + (this.total === 1 ? ' source' : ' sources');\n }\n\n get computedClass() {\n return cn(\n 'flex w-fit items-center gap-1.5 rounded-md px-1.5 py-1 text-sm text-muted-foreground transition-colors',\n 'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n this.class\n );\n }\n\n onToggle(): void {\n this.service.toggle();\n }\n}\n\n/**\n * Holds the `tolle-source` entries of a `tolle-sources`.\n *\n * Stays in the DOM while collapsed — hidden and `inert` rather than removed —\n * so the sources inside it stay registered and the trigger's count is right\n * before the list has ever been opened.\n */\n@Component({\n selector: 'tolle-sources-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n data-sources-content\n [attr.data-state]=\"open ? 'open' : 'closed'\"\n [attr.inert]=\"open ? null : ''\"\n [attr.aria-hidden]=\"open ? null : 'true'\"\n [class]=\"computedClass\"\n >\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SourcesContentComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the content via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(SourcesService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn('flex flex-col gap-1.5', !this.open && 'hidden', this.class);\n }\n}\n\nconst sourceVariants = cva(\n 'group flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n {\n variants: {\n size: {\n sm: 'text-xs',\n default: 'text-sm',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n }\n);\n\nexport type SourceProps = VariantProps<typeof sourceVariants>;\n\n/** A single citation inside a `tolle-sources-content`. */\n@Component({\n selector: 'tolle-source',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <a\n data-source\n [attr.href]=\"href || null\"\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n [class]=\"computedClass\"\n >\n <img\n *ngIf=\"favicon; else chip\"\n [attr.src]=\"favicon\"\n alt=\"\"\n aria-hidden=\"true\"\n class=\"size-4 shrink-0 rounded-sm\"\n />\n\n <ng-template #chip>\n <span\n class=\"flex size-4 shrink-0 items-center justify-center rounded-sm bg-muted text-[10px] font-medium text-muted-foreground\"\n aria-hidden=\"true\"\n >{{ position }}</span\n >\n </ng-template>\n\n <span class=\"truncate text-foreground\" data-source-title>{{ title || href }}</span>\n\n <i\n class=\"ri-external-link-line ml-auto shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100\"\n aria-hidden=\"true\"\n ></i>\n\n <ng-content></ng-content>\n </a>\n `,\n})\nexport class SourceComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** URL the citation points at. @default '' */\n @Input() href = '';\n /** Human-readable title; falls back to the href when empty. @default '' */\n @Input() title = '';\n /** URL of a favicon shown instead of the numbered chip. @default '' */\n @Input() favicon = '';\n /** Overrides the auto-numbered position chip. @default null */\n @Input() index: number | null = null;\n /** Text scale of the citation. @default 'default' */\n @Input() size: SourceProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the citation via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(SourcesService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n private readonly id = 'source-' + sourceId++;\n\n order = 0;\n\n ngOnInit(): void {\n this.service.register(this.id);\n this.subscriptions.add(\n this.service.ids$.subscribe(() => {\n this.order = this.service.indexOf(this.id);\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.service.unregister(this.id);\n this.subscriptions.unsubscribe();\n }\n\n /** One-based number shown in the chip. */\n get position(): number {\n return this.index ?? this.order + 1;\n }\n\n get computedClass() {\n return cn(sourceVariants({ size: this.size }), this.class);\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": "spinner",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Spinner",
|
|
5
|
+
"description": "Spinner component with size and variant options.",
|
|
6
|
+
"category": "feedback",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "spinner.component.ts",
|
|
14
|
+
"target": "ui/spinner.component.ts",
|
|
15
|
+
"content": "import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst spinnerVariants = cva('inline-block animate-spin rounded-full border-solid border-current border-r-transparent align-[-0.125em]', {\n variants: {\n size: {\n xs: 'h-3 w-3 border-[1.5px]',\n sm: 'h-4 w-4 border-2',\n default: 'h-5 w-5 border-2',\n lg: 'h-8 w-8 border-[3px]',\n xl: 'h-12 w-12 border-4',\n },\n variant: {\n default: 'text-foreground',\n primary: 'text-primary',\n muted: 'text-muted-foreground',\n destructive: 'text-destructive',\n },\n },\n defaultVariants: { size: 'default', variant: 'default' },\n});\n\nexport type SpinnerProps = VariantProps<typeof spinnerVariants>;\n\n/**\n * An indeterminate loading indicator. Renders as `role=\"status\"` with a\n * visually-hidden label so screen readers announce the loading state.\n * @new\n */\n@Component({\n selector: 'tolle-spinner',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <span role=\"status\" [attr.aria-label]=\"label\" [class]=\"computedClass\">\n <span class=\"sr-only\">{{ label }}</span>\n </span>\n `,\n})\nexport class SpinnerComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Diameter of the spinner. @default 'default' */\n @Input() size: SpinnerProps['size'] = 'default';\n /** Colour of the spinner, inherited from the theme tokens. @default 'default' */\n @Input() variant: SpinnerProps['variant'] = 'default';\n /** Accessible label announced while the spinner is visible. @default 'Loading' */\n @Input() label = 'Loading';\n /** Extra Tailwind classes merged onto the spinner via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(spinnerVariants({ size: this.size, variant: this.variant }), this.class);\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": "suggestion",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Suggestion",
|
|
5
|
+
"description": "Suggestion component with variant and size options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "suggestion.component.ts",
|
|
14
|
+
"target": "ui/suggestion.component.ts",
|
|
15
|
+
"content": "import { ChangeDetectionStrategy, Component, EventEmitter, Injectable, Input, OnDestroy, OnInit, Output, inject, ChangeDetectorRef, OnChanges } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Subject, Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/**\n * Relays a chosen suggestion from a `tolle-suggestion` up to the\n * `tolle-suggestions` row that contains it, so consumers can listen in one\n * place instead of binding every pill.\n *\n * Provided on `SuggestionsComponent`; injected optionally, since a pill is\n * perfectly usable on its own.\n */\n@Injectable()\nexport class SuggestionsService {\n private readonly selectedSource = new Subject<string>();\n /** Emits the value of whichever suggestion the user picked. */\n readonly selected$ = this.selectedSource.asObservable();\n\n select(value: string): void {\n this.selectedSource.next(value);\n }\n}\n\nconst suggestionsVariants = cva('flex w-full items-center overflow-x-auto scroll-fade-x', {\n variants: {\n gap: {\n sm: 'gap-1',\n default: 'gap-2',\n lg: 'gap-3',\n },\n },\n defaultVariants: {\n gap: 'default',\n },\n});\n\nexport type SuggestionsProps = VariantProps<typeof suggestionsVariants>;\n\n/**\n * A horizontally scrollable row of suggested follow-up prompts.\n *\n * Fill it with `tolle-suggestion` pills and listen to this component's\n * `selected` output to react to any of them.\n */\n@Component({\n selector: 'tolle-suggestions',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [SuggestionsService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" role=\"group\" [attr.aria-label]=\"ariaLabel || null\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SuggestionsComponent implements OnChanges, OnInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Spacing between the pills. @default 'default' */\n @Input() gap: SuggestionsProps['gap'] = 'default';\n /** Accessible name for the row. @default 'Suggestions' */\n @Input() ariaLabel = 'Suggestions';\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the `value` of whichever suggestion in the row was picked. */\n @Output() selected = new EventEmitter<string>();\n\n private readonly service = inject(SuggestionsService);\n private readonly subscriptions = new Subscription();\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.selected$.subscribe((value) => this.selected.emit(value))\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(suggestionsVariants({ gap: this.gap }), this.class);\n }\n}\n\nconst suggestionVariants = cva(\n 'inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',\n {\n variants: {\n variant: {\n outline: 'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground',\n secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',\n ghost: 'border-transparent bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground',\n },\n size: {\n sm: 'h-7 px-2.5 text-xs',\n default: 'h-8 px-3 text-sm',\n lg: 'h-10 px-4 text-sm',\n },\n },\n defaultVariants: {\n variant: 'outline',\n size: 'default',\n },\n }\n);\n\nexport type SuggestionProps = VariantProps<typeof suggestionVariants>;\n\n/**\n * A single suggested follow-up prompt, rendered as a pill button.\n *\n * Projected content is the label; `value` is what gets emitted when it is\n * picked, so the two can differ.\n * @new\n */\n@Component({\n selector: 'tolle-suggestion',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n data-suggestion\n [disabled]=\"disabled\"\n [attr.aria-label]=\"ariaLabel || null\"\n [class]=\"computedClass\"\n (click)=\"onClick()\"\n >\n <i *ngIf=\"icon\" [class]=\"icon\" aria-hidden=\"true\"></i>\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class SuggestionComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Value emitted when the pill is picked. @default '' */\n @Input() value = '';\n /** Visual style of the pill. @default 'outline' */\n @Input() variant: SuggestionProps['variant'] = 'outline';\n /** Size of the pill. @default 'default' */\n @Input() size: SuggestionProps['size'] = 'default';\n /** Remixicon class shown before the label. @default '' */\n @Input() icon = '';\n /** Blocks interaction with the pill. @default false */\n @Input() disabled = false;\n /** Accessible name, when the projected label is not descriptive enough. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the pill via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with this pill's `value` when it is picked. */\n @Output() selected = new EventEmitter<string>();\n\n private readonly parent = inject(SuggestionsService, { optional: true });\n\n get computedClass() {\n return cn(suggestionVariants({ variant: this.variant, size: this.size }), this.class);\n }\n\n onClick(): void {\n if (this.disabled) return;\n this.selected.emit(this.value);\n this.parent?.select(this.value);\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
|
+
}
|
package/registry/r/switch.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
{
|
|
13
13
|
"path": "switch.component.ts",
|
|
14
14
|
"target": "ui/switch.component.ts",
|
|
15
|
-
"content": "import { Component, Input, forwardRef, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst switchTrackVariants = cva(\n 'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-4 w-7',\n sm: 'h-5 w-9',\n default: 'h-6 w-11',\n lg: 'h-7 w-[3.25rem]',\n },\n checked: {\n true: 'bg-primary',\n false: 'bg-input',\n },\n },\n defaultVariants: {\n size: 'default',\n checked: false,\n },\n }\n);\n\nconst switchThumbVariants = cva(\n 'pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform',\n {\n variants: {\n size: {\n xs: 'h-3 w-3',\n sm: 'h-4 w-4',\n default: 'h-5 w-5',\n lg: 'h-6 w-6',\n },\n checked: {\n true: '',\n false: 'translate-x-0',\n },\n },\n compoundVariants: [\n { size: 'xs', checked: true, class: 'translate-x-3' },\n { size: 'sm', checked: true, class: 'translate-x-4' },\n { size: 'default', checked: true, class: 'translate-x-5' },\n { size: 'lg', checked: true, class: 'translate-x-6' },\n ],\n defaultVariants: {\n size: 'default',\n checked: false,\n },\n }\n);\n\nexport type SwitchProps = VariantProps<typeof switchTrackVariants>;\n\n@Component({\n selector: 'tolle-switch',\n standalone: true,\n imports: [CommonModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SwitchComponent),\n multi: true\n }\n ],\n template: `\n <button\n type=\"button\"\n role=\"switch\"\n [attr.aria-checked]=\"checked\"\n [attr.data-state]=\"checked ? 'checked' : 'unchecked'\"\n [disabled]=\"disabled\"\n (click)=\"toggle()\"\n [class]=\"trackClass\"\n >\n <span [class]=\"thumbClass\"></span>\n </button>\n `\n})\nexport class SwitchComponent implements ControlValueAccessor {\n /** Extra Tailwind classes merged onto the switch track via `cn()` (last-wins). */\n @Input() class = '';\n /** Disables the switch and blocks toggling. @default false */\n @Input() disabled = false;\n /** Size of the switch. @default 'default' */\n @Input() size: SwitchProps['size'] = 'default';\n\n checked = false;\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get trackClass() {\n return cn(\n switchTrackVariants({ size: this.size, checked: this.checked }),\n this.class\n );\n }\n\n get thumbClass() {\n return cn(switchThumbVariants({ size: this.size, checked: this.checked }));\n }\n\n toggle() {\n if (this.disabled) return;\n this.checked = !this.checked;\n this.onChange(this.checked);\n this.onTouched();\n }\n\n // --- ControlValueAccessor ---\n writeValue(value: boolean): void {\n this.checked = value;\n this.cdr.markForCheck();\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; this.cdr.markForCheck(); }\n}\n"
|
|
15
|
+
"content": "import { Component, Input, forwardRef, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst switchTrackVariants = cva(\n 'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-4 w-7',\n sm: 'h-5 w-9',\n default: 'h-6 w-11',\n lg: 'h-7 w-[3.25rem]',\n },\n checked: {\n true: 'bg-primary',\n false: 'bg-input',\n },\n },\n defaultVariants: {\n size: 'default',\n checked: false,\n },\n }\n);\n\nconst switchThumbVariants = cva(\n 'pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform',\n {\n variants: {\n size: {\n xs: 'h-3 w-3',\n sm: 'h-4 w-4',\n default: 'h-5 w-5',\n lg: 'h-6 w-6',\n },\n checked: {\n true: '',\n false: 'translate-x-0',\n },\n },\n compoundVariants: [\n { size: 'xs', checked: true, class: 'translate-x-3' },\n { size: 'sm', checked: true, class: 'translate-x-4' },\n { size: 'default', checked: true, class: 'translate-x-5' },\n { size: 'lg', checked: true, class: 'translate-x-6' },\n ],\n defaultVariants: {\n size: 'default',\n checked: false,\n },\n }\n);\n\nexport type SwitchProps = VariantProps<typeof switchTrackVariants>;\n\n@Component({\n selector: 'tolle-switch',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SwitchComponent),\n multi: true\n }\n ],\n template: `\n <button\n type=\"button\"\n role=\"switch\"\n [attr.aria-checked]=\"checked\"\n [attr.data-state]=\"checked ? 'checked' : 'unchecked'\"\n [disabled]=\"disabled\"\n (click)=\"toggle()\"\n [class]=\"trackClass\"\n >\n <span [class]=\"thumbClass\"></span>\n </button>\n `\n})\nexport class SwitchComponent implements ControlValueAccessor {\n /** Extra Tailwind classes merged onto the switch track via `cn()` (last-wins). */\n @Input() class = '';\n /** Disables the switch and blocks toggling. @default false */\n @Input() disabled = false;\n /** Size of the switch. @default 'default' */\n @Input() size: SwitchProps['size'] = 'default';\n\n checked = false;\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get trackClass() {\n return cn(\n switchTrackVariants({ size: this.size, checked: this.checked }),\n this.class\n );\n }\n\n get thumbClass() {\n return cn(switchThumbVariants({ size: this.size, checked: this.checked }));\n }\n\n toggle() {\n if (this.disabled) return;\n this.checked = !this.checked;\n this.onChange(this.checked);\n this.onTouched();\n }\n\n // --- ControlValueAccessor ---\n writeValue(value: boolean): void {\n this.checked = value;\n this.cdr.markForCheck();\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; this.cdr.markForCheck(); }\n}\n"
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
"path": "utils/cn.ts",
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "table",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Table",
|
|
5
|
+
"description": "Table component.",
|
|
6
|
+
"category": "data",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "table.component.ts",
|
|
12
|
+
"target": "ui/table.component.ts",
|
|
13
|
+
"content": "import { Component, Directive, Input, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n/**\n * Unstyled table primitives you compose yourself, for when you need full\n * control over the markup.\n *\n * For sorting, paging, selection and column definitions out of the box, use\n * `tolle-data-table` instead — this is the lower-level building block.\n *\n * Everything below the `<table>` is a **directive on the native element**\n * (`<thead tolleTableHeader>`, `<tr tolleTableRow>`, …) rather than a custom\n * element. A `<tolle-table-row>` wrapper would sit between `<tbody>` and `<tr>`\n * and break the CSS table layout, so the directives style the real elements in\n * place and add no DOM of their own.\n *\n * ```html\n * <tolle-table>\n * <thead tolleTableHeader>\n * <tr tolleTableRow><th tolleTableHead>Name</th></tr>\n * </thead>\n * <tbody tolleTableBody>\n * <tr tolleTableRow><td tolleTableCell>Ada</td></tr>\n * </tbody>\n * </tolle-table>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-table',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"cn('relative w-full overflow-x-auto', containerClass)\">\n <table [class]=\"cn('w-full caption-bottom border-collapse text-sm', class)\">\n <ng-content></ng-content>\n </table>\n </div>\n `,\n})\nexport class TableComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the scrolling container via `cn()` (last-wins). */\n @Input() containerClass = '';\n /** Extra Tailwind classes merged onto the `<table>` via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Styles a native `<thead>`. */\n@Directive({\n selector: '[tolleTableHeader]',\n standalone: true,\n host: { '[class]': `cn('[&_tr]:border-b [&_tr]:border-border', class)` },\n})\nexport class TableHeaderDirective {\n /** Extra Tailwind classes merged onto the `<thead>` via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Styles a native `<tbody>`. */\n@Directive({\n selector: '[tolleTableBody]',\n standalone: true,\n host: { '[class]': `cn('[&_tr:last-child]:border-0', class)` },\n})\nexport class TableBodyDirective {\n /** Extra Tailwind classes merged onto the `<tbody>` via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Styles a native `<tfoot>` for summary or total rows. */\n@Directive({\n selector: '[tolleTableFooter]',\n standalone: true,\n host: { '[class]': `cn('border-t border-border bg-muted/50 font-medium [&>tr]:last:border-b-0', class)` },\n})\nexport class TableFooterDirective {\n /** Extra Tailwind classes merged onto the `<tfoot>` via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Styles a native `<tr>`, with an optional selected state. */\n@Directive({\n selector: '[tolleTableRow]',\n standalone: true,\n host: {\n '[attr.data-state]': `selected ? 'selected' : null`,\n '[class]': `cn('border-b border-border transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', class)`,\n },\n})\nexport class TableRowDirective {\n /** Applies the selected background and sets `data-state=\"selected\"`. @default false */\n @Input() selected = false;\n /** Extra Tailwind classes merged onto the `<tr>` via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Styles a native `<th>` header cell. */\n@Directive({\n selector: '[tolleTableHead]',\n standalone: true,\n host: {\n '[attr.scope]': 'scope',\n '[attr.aria-sort]': 'sort',\n '[class]': `cn('h-10 whitespace-nowrap px-3 text-left align-middle font-medium text-muted-foreground', class)`,\n },\n})\nexport class TableHeadDirective {\n /** Scope of the header cell for assistive tech. @default 'col' */\n @Input() scope: 'col' | 'row' | 'colgroup' | 'rowgroup' = 'col';\n /** Current sort direction, surfaced as `aria-sort`. @default null */\n @Input() sort: 'ascending' | 'descending' | 'none' | null = null;\n /** Extra Tailwind classes merged onto the `<th>` via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Styles a native `<td>` data cell. */\n@Directive({\n selector: '[tolleTableCell]',\n standalone: true,\n host: { '[class]': `cn('p-3 align-middle', class)` },\n})\nexport class TableCellDirective {\n /** Extra Tailwind classes merged onto the `<td>` via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Styles a native `<caption>`, rendered below the table. */\n@Directive({\n selector: '[tolleTableCaption]',\n standalone: true,\n host: { '[class]': `cn('mt-4 text-sm text-muted-foreground', class)` },\n})\nexport class TableCaptionDirective {\n /** Extra Tailwind classes merged onto the `<caption>` via `cn()` (last-wins). */\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
|
+
}
|
package/registry/r/tabs.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
{
|
|
13
13
|
"path": "tabs.component.ts",
|
|
14
14
|
"target": "ui/tabs.component.ts",
|
|
15
|
-
"content": "import { Component, Input, Output, EventEmitter, Injectable, inject, ElementRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { BehaviorSubject } from 'rxjs';\n\nconst tabsListVariants = cva('', {\n variants: {\n variant: {\n default: 'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',\n underline: 'flex h-10 items-center justify-start border-b border-border w-full bg-transparent p-0 text-muted-foreground gap-8',\n },\n },\n defaultVariants: { variant: 'default' },\n});\n\nexport type TabsListProps = VariantProps<typeof tabsListVariants>;\n\nconst tabsTriggerVariants = cva(\n 'inline-flex cursor-pointer items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50',\n {\n variants: {\n variant: {\n default: 'rounded-sm px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',\n underline: 'py-2 border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:text-foreground hover:text-foreground/80 -mb-[1px]',\n },\n },\n defaultVariants: { variant: 'default' },\n }\n);\n\n@Injectable()\nclass TabsService {\n private activeTabSubject = new BehaviorSubject<string | null>(null);\n activeTab$ = this.activeTabSubject.asObservable();\n\n setActiveTab(value: string) {\n this.activeTabSubject.next(value);\n }\n\n getActiveTab() {\n return this.activeTabSubject.getValue();\n }\n}\n\n@Component({\n selector: 'tolle-tabs',\n standalone: true,\n imports: [CommonModule],\n providers: [TabsService],\n template: `<ng-content></ng-content>`,\n host: {\n '[class]': 'computedClass'\n }\n})\nexport class TabsComponent {\n @Input() defaultValue?: string;\n @Input() value?: string;\n @Output() valueChange = new EventEmitter<string>();\n @Input() class: string = '';\n\n private tabsService = inject(TabsService);\n\n ngOnInit() {\n if (this.value) {\n this.tabsService.setActiveTab(this.value);\n } else if (this.defaultValue) {\n this.tabsService.setActiveTab(this.defaultValue);\n }\n\n this.tabsService.activeTab$.subscribe(val => {\n if (val && val !== this.value) {\n this.value = val;\n this.valueChange.emit(val);\n }\n });\n }\n\n get computedClass() {\n return cn(\"w-full\", this.class);\n }\n}\n\n@Component({\n selector: 'tolle-tabs-list',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n 'role': 'tablist',\n '[class]': 'computedClass'\n }\n})\nexport class TabsListComponent {\n /** Extra Tailwind classes merged onto the tabs list via `cn()` (last-wins). */\n @Input() class: string = '';\n /** Visual style of the tab list. @default 'default' */\n @Input() variant: TabsListProps['variant'] = 'default';\n\n get computedClass() {\n return cn(tabsListVariants({ variant: this.variant }), this.class);\n }\n}\n\n@Component({\n selector: 'tolle-tabs-trigger',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n 'role': 'tab',\n '[attr.aria-selected]': 'isActive',\n '[attr.tabindex]': 'isActive ? 0 : -1',\n '[attr.id]': \"'tab-'+value\",\n '[attr.aria-controls]': \"'tabpanel-'+value\",\n '[attr.data-state]': 'isActive ? \"active\" : \"inactive\"',\n '(click)': 'onActivate()',\n '(keydown)': 'onKeydown($event)',\n '[class]': 'computedClass'\n }\n})\nexport class TabsTriggerComponent {\n @Input({ required: true }) value!: string;\n @Input() class: string = '';\n\n private tabsService = inject(TabsService);\n private tabsList = inject(TabsListComponent, { optional: true });\n private el = inject(ElementRef) as ElementRef<HTMLElement>;\n isActive = false;\n\n constructor() {\n this.tabsService.activeTab$.subscribe(active => {\n this.isActive = active === this.value;\n });\n }\n\n onActivate() {\n this.tabsService.setActiveTab(this.value);\n }\n\n onKeydown(event: KeyboardEvent) {\n const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];\n if (!keys.includes(event.key)) return;\n\n const host = this.el.nativeElement;\n const list = host.closest('[role=tablist]');\n if (!list) return;\n\n const triggers = Array.from(list.querySelectorAll('tolle-tabs-trigger')) as HTMLElement[];\n if (!triggers.length) return;\n\n const currentIndex = triggers.indexOf(host);\n let nextIndex = currentIndex;\n\n switch (event.key) {\n case 'ArrowLeft':\n nextIndex = currentIndex <= 0 ? triggers.length - 1 : currentIndex - 1;\n break;\n case 'ArrowRight':\n nextIndex = currentIndex >= triggers.length - 1 ? 0 : currentIndex + 1;\n break;\n case 'Home':\n nextIndex = 0;\n break;\n case 'End':\n nextIndex = triggers.length - 1;\n break;\n }\n\n const target = triggers[nextIndex];\n if (!target) return;\n\n event.preventDefault();\n const targetValue = target.id.replace(/^tab-/, '');\n if (targetValue) {\n this.tabsService.setActiveTab(targetValue);\n }\n target.focus();\n }\n\n get computedClass() {\n const variant = this.tabsList?.variant === 'underline' ? 'underline' : 'default';\n return cn(tabsTriggerVariants({ variant }), this.class);\n }\n}\n\n@Component({\n selector: 'tolle-tabs-content',\n standalone: true,\n imports: [CommonModule],\n template: `<div *ngIf=\"isActive\"><ng-content></ng-content></div>`,\n host: {\n 'role': 'tabpanel',\n '[attr.id]': \"'tabpanel-'+value\",\n '[attr.aria-labelledby]': \"'tab-'+value\",\n '[attr.tabindex]': '0',\n '[attr.data-state]': 'isActive ? \"active\" : \"inactive\"',\n '[class]': 'computedClass'\n }\n})\nexport class TabsContentComponent {\n @Input({ required: true }) value!: string;\n @Input() class: string = '';\n\n private tabsService = inject(TabsService);\n isActive = false;\n\n constructor() {\n this.tabsService.activeTab$.subscribe(active => {\n this.isActive = active === this.value;\n });\n }\n\n get computedClass() {\n return cn(\n \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n this.class\n );\n }\n}\n"
|
|
15
|
+
"content": "import { Component, Input, Output, EventEmitter, Injectable, inject, ElementRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { BehaviorSubject } from 'rxjs';\n\nconst tabsListVariants = cva('', {\n variants: {\n variant: {\n default: 'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',\n underline: 'flex h-10 items-center justify-start border-b border-border w-full bg-transparent p-0 text-muted-foreground gap-8',\n },\n },\n defaultVariants: { variant: 'default' },\n});\n\nexport type TabsListProps = VariantProps<typeof tabsListVariants>;\n\nconst tabsTriggerVariants = cva(\n 'inline-flex cursor-pointer items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50',\n {\n variants: {\n variant: {\n default: 'rounded-sm px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',\n underline: 'py-2 border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:text-foreground hover:text-foreground/80 -mb-[1px]',\n },\n },\n defaultVariants: { variant: 'default' },\n }\n);\n\n@Injectable()\nclass TabsService {\n private activeTabSubject = new BehaviorSubject<string | null>(null);\n activeTab$ = this.activeTabSubject.asObservable();\n\n setActiveTab(value: string) {\n this.activeTabSubject.next(value);\n }\n\n getActiveTab() {\n return this.activeTabSubject.getValue();\n }\n}\n\n@Component({\n selector: 'tolle-tabs',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [TabsService],\n template: `<ng-content></ng-content>`,\n host: {\n '[class]': 'computedClass'\n }\n})\nexport class TabsComponent {\n @Input() defaultValue?: string;\n @Input() value?: string;\n @Output() valueChange = new EventEmitter<string>();\n @Input() class: string = '';\n\n private tabsService = inject(TabsService);\n\n ngOnInit() {\n if (this.value) {\n this.tabsService.setActiveTab(this.value);\n } else if (this.defaultValue) {\n this.tabsService.setActiveTab(this.defaultValue);\n }\n\n this.tabsService.activeTab$.subscribe(val => {\n if (val && val !== this.value) {\n this.value = val;\n this.valueChange.emit(val);\n }\n });\n }\n\n get computedClass() {\n return cn(\"w-full\", this.class);\n }\n}\n\n@Component({\n selector: 'tolle-tabs-list',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n 'role': 'tablist',\n '[class]': 'computedClass'\n }\n})\nexport class TabsListComponent {\n /** Extra Tailwind classes merged onto the tabs list via `cn()` (last-wins). */\n @Input() class: string = '';\n /** Visual style of the tab list. @default 'default' */\n @Input() variant: TabsListProps['variant'] = 'default';\n\n get computedClass() {\n return cn(tabsListVariants({ variant: this.variant }), this.class);\n }\n}\n\n@Component({\n selector: 'tolle-tabs-trigger',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n 'role': 'tab',\n '[attr.aria-selected]': 'isActive',\n '[attr.tabindex]': 'isActive ? 0 : -1',\n '[attr.id]': \"'tab-'+value\",\n '[attr.aria-controls]': \"'tabpanel-'+value\",\n '[attr.data-state]': 'isActive ? \"active\" : \"inactive\"',\n '(click)': 'onActivate()',\n '(keydown)': 'onKeydown($event)',\n '[class]': 'computedClass'\n }\n})\nexport class TabsTriggerComponent {\n @Input({ required: true }) value!: string;\n @Input() class: string = '';\n\n private tabsService = inject(TabsService);\n private tabsList = inject(TabsListComponent, { optional: true });\n private el = inject(ElementRef) as ElementRef<HTMLElement>;\n isActive = false;\n\n constructor() {\n this.tabsService.activeTab$.subscribe(active => {\n this.isActive = active === this.value;\n });\n }\n\n onActivate() {\n this.tabsService.setActiveTab(this.value);\n }\n\n onKeydown(event: KeyboardEvent) {\n const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];\n if (!keys.includes(event.key)) return;\n\n const host = this.el.nativeElement;\n const list = host.closest('[role=tablist]');\n if (!list) return;\n\n const triggers = Array.from(list.querySelectorAll('tolle-tabs-trigger')) as HTMLElement[];\n if (!triggers.length) return;\n\n const currentIndex = triggers.indexOf(host);\n let nextIndex = currentIndex;\n\n switch (event.key) {\n case 'ArrowLeft':\n nextIndex = currentIndex <= 0 ? triggers.length - 1 : currentIndex - 1;\n break;\n case 'ArrowRight':\n nextIndex = currentIndex >= triggers.length - 1 ? 0 : currentIndex + 1;\n break;\n case 'Home':\n nextIndex = 0;\n break;\n case 'End':\n nextIndex = triggers.length - 1;\n break;\n }\n\n const target = triggers[nextIndex];\n if (!target) return;\n\n event.preventDefault();\n const targetValue = target.id.replace(/^tab-/, '');\n if (targetValue) {\n this.tabsService.setActiveTab(targetValue);\n }\n target.focus();\n }\n\n get computedClass() {\n const variant = this.tabsList?.variant === 'underline' ? 'underline' : 'default';\n return cn(tabsTriggerVariants({ variant }), this.class);\n }\n}\n\n@Component({\n selector: 'tolle-tabs-content',\n standalone: true,\n imports: [CommonModule],\n template: `<div *ngIf=\"isActive\"><ng-content></ng-content></div>`,\n host: {\n 'role': 'tabpanel',\n '[attr.id]': \"'tabpanel-'+value\",\n '[attr.aria-labelledby]': \"'tab-'+value\",\n '[attr.tabindex]': '0',\n '[attr.data-state]': 'isActive ? \"active\" : \"inactive\"',\n '[class]': 'computedClass'\n }\n})\nexport class TabsContentComponent {\n @Input({ required: true }) value!: string;\n @Input() class: string = '';\n\n private tabsService = inject(TabsService);\n isActive = false;\n\n constructor() {\n this.tabsService.activeTab$.subscribe(active => {\n this.isActive = active === this.value;\n });\n }\n\n get computedClass() {\n return cn(\n \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n this.class\n );\n }\n}\n"
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
"path": "utils/cn.ts",
|