@tolle_/tolle-ui 18.2.29 → 18.3.0
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 +12 -12
- package/esm2022/lib/alert-dialog.component.mjs +82 -64
- package/esm2022/lib/alert-dialog.service.mjs +38 -6
- 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 +125 -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 +59 -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/modal-ref.mjs +36 -7
- package/esm2022/lib/modal.component.mjs +42 -28
- package/esm2022/lib/modal.mjs +2 -29
- package/esm2022/lib/modal.service.mjs +26 -4
- 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-panel.component.mjs +5 -5
- package/esm2022/lib/resizable.component.mjs +219 -0
- package/esm2022/lib/segment.component.mjs +19 -9
- 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 +16642 -1105
- package/fesm2022/tolle-ui.mjs.map +1 -1
- package/lib/alert-dialog-dynamic.component.d.ts +5 -0
- package/lib/alert-dialog.component.d.ts +23 -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 +26 -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/modal-ref.d.ts +6 -1
- package/lib/modal.component.d.ts +7 -4
- package/lib/modal.d.ts +9 -3
- 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 +3 -2
- package/preset.js +7 -1
- package/public-api.d.ts +50 -0
- package/registry/docs-content.json +8480 -1542
- package/registry/llms-full.txt +2679 -319
- package/registry/llms.txt +43 -4
- package/registry/manifest.json +9090 -1742
- 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-menu-trigger.json +1 -1
- package/registry/r/context-menu.json +1 -1
- 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/modal.json +3 -3
- 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/resizable-panel.json +1 -1
- 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 +121 -8
|
@@ -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",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
{
|
|
15
15
|
"path": "tag-input.component.ts",
|
|
16
16
|
"target": "ui/tag-input.component.ts",
|
|
17
|
-
"content": "import { Component, Input, forwardRef, ChangeDetectorRef, ElementRef, ViewChild } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { BadgeComponent } from './badge.component';\n\n@Component({\n selector: 'tolle-tag-input',\n standalone: true,\n imports: [CommonModule, FormsModule, BadgeComponent],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => TagInputComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"flex flex-col gap-1.5 w-full\">\n <label\n *ngIf=\"label\"\n [for]=\"id\"\n [class]=\"computedLabelClass\">\n {{ label }}\n </label>\n\n <div\n [class]=\"computedContainerClass\"\n (click)=\"focusInput()\"\n >\n <tolle-badge\n *ngFor=\"let tag of tags; let i = index\"\n size=\"xs\"\n variant=\"secondary\"\n [removable]=\"!disabled && !readonly\"\n (onRemove)=\"removeTag(i, $event)\">\n {{ tagPrefix }}{{ tag }}{{ tagSuffix }}\n </tolle-badge>\n\n <input\n #tagInput\n [id]=\"id\"\n type=\"text\"\n [placeholder]=\"tags.length === 0 ? placeholder : ''\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly || (maxTags != null && tags.length >= maxTags)\"\n [(ngModel)]=\"inputValue\"\n (keydown)=\"onKeydown($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"computedInputClass\"\n />\n </div>\n\n <ng-container *ngIf=\"!disabled\">\n <p\n *ngIf=\"hint && !error\"\n class=\"text-xs text-muted-foreground px-1 transition-opacity duration-200\"\n >\n {{ hint }}\n </p>\n <p\n *ngIf=\"error && errorMessage\"\n class=\"text-xs text-destructive px-1\"\n >\n {{ errorMessage }}\n </p>\n </ng-container>\n </div>\n `,\n})\nexport class TagInputComponent implements ControlValueAccessor {\n @ViewChild('tagInput') tagInputElement!: ElementRef<HTMLInputElement>;\n\n @Input() id: string = `tag-input-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label: string = '';\n @Input() hint: string = '';\n @Input() errorMessage: string = '';\n @Input() placeholder: string = 'Type and press Enter...';\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() class: string = '';\n\n // State inputs\n @Input() disabled: boolean = false;\n @Input() readonly: boolean = false;\n @Input() error: boolean = false;\n\n // Tag-specific inputs\n @Input() delimiter: string = ',';\n @Input() maxTags: number | null = null;\n @Input() allowDuplicates: boolean = false;\n @Input() tagPrefix: string = '';\n @Input() tagSuffix: string = '';\n\n tags: string[] = [];\n inputValue: string = '';\n isFocused: boolean = false;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n writeValue(values: string[]): void {\n this.tags = Array.isArray(values) ? [...values] : [];\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n onKeydown(event: KeyboardEvent): void {\n if (this.disabled || this.readonly) return;\n\n const key = event.key;\n\n // Enter creates a tag\n if (key === 'Enter') {\n event.preventDefault();\n this.commitInput();\n return;\n }\n\n // Delimiter creates a tag (strip the delimiter)\n if (key === this.delimiter) {\n event.preventDefault();\n this.commitInput();\n return;\n }\n\n // Backspace on empty input removes the last tag\n if (key === 'Backspace' && this.inputValue === '') {\n if (this.tags.length > 0) {\n this.removeTag(this.tags.length - 1);\n }\n return;\n }\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n this.commitInput();\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n removeTag(index: number, event?: MouseEvent): void {\n if (this.disabled || this.readonly) return;\n if (event) {\n event.stopPropagation();\n }\n this.tags.splice(index, 1);\n this.emitChange();\n this.focusInput();\n }\n\n focusInput(): void {\n if (!this.disabled && this.tagInputElement) {\n this.tagInputElement.nativeElement.focus();\n }\n }\n\n private commitInput(): void {\n const value = this.inputValue.trim();\n if (value === '') {\n return;\n }\n\n // Check max tags\n if (this.maxTags != null && this.tags.length >= this.maxTags) {\n this.inputValue = '';\n return;\n }\n\n // Check duplicates (case-insensitive)\n if (!this.allowDuplicates) {\n const exists = this.tags.some(\n t => t.toLowerCase() === value.toLowerCase()\n );\n if (exists) {\n this.inputValue = '';\n return;\n }\n }\n\n this.tags.push(value);\n this.inputValue = '';\n this.emitChange();\n }\n\n private emitChange(): void {\n this.onChange([...this.tags]);\n this.cdr.markForCheck();\n }\n\n protected readonly cn = cn;\n\n get computedLabelClass() {\n return cn(\n \"text-sm font-medium text-foreground leading-none transition-opacity duration-200\",\n this.disabled && \"opacity-50\"\n );\n }\n\n get computedContainerClass() {\n return cn(\n // Base styles — flex-wrap so tags flow onto multiple lines\n \"group relative flex flex-wrap items-center w-full rounded-md border transition-all duration-200\",\n \"bg-background\",\n\n // Border and shadow\n \"border-input shadow-sm\",\n\n // Sizing — min-h instead of fixed h so tags can wrap\n this.size === 'xs' && \"min-h-8 px-2 py-1 gap-1 text-xs\",\n this.size === 'sm' && \"min-h-9 px-3 py-1 gap-1.5 text-sm\",\n this.size === 'default' && \"min-h-10 px-3 py-1.5 gap-2 text-sm\",\n this.size === 'lg' && \"min-h-11 px-4 py-2 gap-3 text-base\",\n\n // Focus state\n !(this.readonly || this.disabled) && [\n \"focus-within:border-primary/80\",\n \"focus-within:ring-4\",\n \"focus-within:ring-ring/30\",\n \"focus-within:ring-offset-0\",\n \"focus-within:shadow-none\",\n ],\n\n // Error state\n this.error && [\n \"border-destructive\",\n !(this.readonly || this.disabled) && [\n \"focus-within:border-destructive/80\",\n \"focus-within:ring-destructive/30\"\n ]\n ],\n\n // Disabled state\n this.disabled && [\n \"cursor-not-allowed opacity-50\",\n \"border-opacity-50\"\n ],\n\n // Readonly state\n this.readonly && [\n \"cursor-default\",\n \"border-dashed\",\n !this.disabled && \"focus-within:ring-0 focus-within:border-opacity-100\"\n ],\n\n this.class\n );\n }\n\n get computedInputClass() {\n return cn(\n // Base styles — inline within the flex-wrap container\n \"flex-1 bg-transparent border-none p-0 min-w-[80px]\",\n \"placeholder:text-muted-foreground\",\n\n // Remove all default focus styles\n \"focus:outline-none focus:ring-0 focus:shadow-none\",\n\n // Text sizing\n this.size === 'xs' && \"text-xs\",\n this.size === 'sm' && \"text-sm\",\n this.size === 'default' && \"text-sm\",\n this.size === 'lg' && \"text-base\",\n\n // Cursor states\n this.disabled && \"cursor-not-allowed\",\n this.readonly && \"cursor-default\",\n\n // Text color\n \"text-foreground\",\n\n // Selection color\n \"selection:bg-primary/20 selection:text-foreground\"\n // NOTE: consumer `class` is applied to the container only (computedContainerClass),\n // not duplicated onto the inner <input>.\n );\n }\n}\n"
|
|
17
|
+
"content": "import { Component, Input, forwardRef, ChangeDetectorRef, ElementRef, ViewChild } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { BadgeComponent } from './badge.component';\n\n@Component({\n selector: 'tolle-tag-input',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule, BadgeComponent],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => TagInputComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"flex flex-col gap-1.5 w-full\">\n <label\n *ngIf=\"label\"\n [for]=\"id\"\n [class]=\"computedLabelClass\">\n {{ label }}\n </label>\n\n <div\n [class]=\"computedContainerClass\"\n (click)=\"focusInput()\"\n >\n <tolle-badge\n *ngFor=\"let tag of tags; let i = index\"\n size=\"xs\"\n variant=\"secondary\"\n [removable]=\"!disabled && !readonly\"\n (onRemove)=\"removeTag(i, $event)\">\n {{ tagPrefix }}{{ tag }}{{ tagSuffix }}\n </tolle-badge>\n\n <input\n #tagInput\n [id]=\"id\"\n type=\"text\"\n [placeholder]=\"tags.length === 0 ? placeholder : ''\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly || (maxTags != null && tags.length >= maxTags)\"\n [(ngModel)]=\"inputValue\"\n (keydown)=\"onKeydown($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"computedInputClass\"\n />\n </div>\n\n <ng-container *ngIf=\"!disabled\">\n <p\n *ngIf=\"hint && !error\"\n class=\"text-xs text-muted-foreground px-1 transition-opacity duration-200\"\n >\n {{ hint }}\n </p>\n <p\n *ngIf=\"error && errorMessage\"\n class=\"text-xs text-destructive px-1\"\n >\n {{ errorMessage }}\n </p>\n </ng-container>\n </div>\n `,\n})\nexport class TagInputComponent implements ControlValueAccessor {\n @ViewChild('tagInput') tagInputElement!: ElementRef<HTMLInputElement>;\n\n @Input() id: string = `tag-input-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label: string = '';\n @Input() hint: string = '';\n @Input() errorMessage: string = '';\n @Input() placeholder: string = 'Type and press Enter...';\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() class: string = '';\n\n // State inputs\n @Input() disabled: boolean = false;\n @Input() readonly: boolean = false;\n @Input() error: boolean = false;\n\n // Tag-specific inputs\n @Input() delimiter: string = ',';\n @Input() maxTags: number | null = null;\n @Input() allowDuplicates: boolean = false;\n @Input() tagPrefix: string = '';\n @Input() tagSuffix: string = '';\n\n tags: string[] = [];\n inputValue: string = '';\n isFocused: boolean = false;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n writeValue(values: string[]): void {\n this.tags = Array.isArray(values) ? [...values] : [];\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n onKeydown(event: KeyboardEvent): void {\n if (this.disabled || this.readonly) return;\n\n const key = event.key;\n\n // Enter creates a tag\n if (key === 'Enter') {\n event.preventDefault();\n this.commitInput();\n return;\n }\n\n // Delimiter creates a tag (strip the delimiter)\n if (key === this.delimiter) {\n event.preventDefault();\n this.commitInput();\n return;\n }\n\n // Backspace on empty input removes the last tag\n if (key === 'Backspace' && this.inputValue === '') {\n if (this.tags.length > 0) {\n this.removeTag(this.tags.length - 1);\n }\n return;\n }\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n this.commitInput();\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n removeTag(index: number, event?: MouseEvent): void {\n if (this.disabled || this.readonly) return;\n if (event) {\n event.stopPropagation();\n }\n this.tags.splice(index, 1);\n this.emitChange();\n this.focusInput();\n }\n\n focusInput(): void {\n if (!this.disabled && this.tagInputElement) {\n this.tagInputElement.nativeElement.focus();\n }\n }\n\n private commitInput(): void {\n const value = this.inputValue.trim();\n if (value === '') {\n return;\n }\n\n // Check max tags\n if (this.maxTags != null && this.tags.length >= this.maxTags) {\n this.inputValue = '';\n return;\n }\n\n // Check duplicates (case-insensitive)\n if (!this.allowDuplicates) {\n const exists = this.tags.some(\n t => t.toLowerCase() === value.toLowerCase()\n );\n if (exists) {\n this.inputValue = '';\n return;\n }\n }\n\n this.tags.push(value);\n this.inputValue = '';\n this.emitChange();\n }\n\n private emitChange(): void {\n this.onChange([...this.tags]);\n this.cdr.markForCheck();\n }\n\n protected readonly cn = cn;\n\n get computedLabelClass() {\n return cn(\n \"text-sm font-medium text-foreground leading-none transition-opacity duration-200\",\n this.disabled && \"opacity-50\"\n );\n }\n\n get computedContainerClass() {\n return cn(\n // Base styles — flex-wrap so tags flow onto multiple lines\n \"group relative flex flex-wrap items-center w-full rounded-md border transition-all duration-200\",\n \"bg-background\",\n\n // Border and shadow\n \"border-input shadow-sm\",\n\n // Sizing — min-h instead of fixed h so tags can wrap\n this.size === 'xs' && \"min-h-8 px-2 py-1 gap-1 text-xs\",\n this.size === 'sm' && \"min-h-9 px-3 py-1 gap-1.5 text-sm\",\n this.size === 'default' && \"min-h-10 px-3 py-1.5 gap-2 text-sm\",\n this.size === 'lg' && \"min-h-11 px-4 py-2 gap-3 text-base\",\n\n // Focus state\n !(this.readonly || this.disabled) && [\n \"focus-within:border-primary/80\",\n \"focus-within:ring-4\",\n \"focus-within:ring-ring/30\",\n \"focus-within:ring-offset-0\",\n \"focus-within:shadow-none\",\n ],\n\n // Error state\n this.error && [\n \"border-destructive\",\n !(this.readonly || this.disabled) && [\n \"focus-within:border-destructive/80\",\n \"focus-within:ring-destructive/30\"\n ]\n ],\n\n // Disabled state\n this.disabled && [\n \"cursor-not-allowed opacity-50\",\n \"border-opacity-50\"\n ],\n\n // Readonly state\n this.readonly && [\n \"cursor-default\",\n \"border-dashed\",\n !this.disabled && \"focus-within:ring-0 focus-within:border-opacity-100\"\n ],\n\n this.class\n );\n }\n\n get computedInputClass() {\n return cn(\n // Base styles — inline within the flex-wrap container\n \"flex-1 bg-transparent border-none p-0 min-w-[80px]\",\n \"placeholder:text-muted-foreground\",\n\n // Remove all default focus styles\n \"focus:outline-none focus:ring-0 focus:shadow-none\",\n\n // Text sizing\n this.size === 'xs' && \"text-xs\",\n this.size === 'sm' && \"text-sm\",\n this.size === 'default' && \"text-sm\",\n this.size === 'lg' && \"text-base\",\n\n // Cursor states\n this.disabled && \"cursor-not-allowed\",\n this.readonly && \"cursor-default\",\n\n // Text color\n \"text-foreground\",\n\n // Selection color\n \"selection:bg-primary/20 selection:text-foreground\"\n // NOTE: consumer `class` is applied to the container only (computedContainerClass),\n // not duplicated onto the inner <input>.\n );\n }\n}\n"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"path": "utils/cn.ts",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "task",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Task",
|
|
5
|
+
"description": "Task component.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "task.component.ts",
|
|
14
|
+
"target": "ui/task.component.ts",
|
|
15
|
+
"content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Injectable, Input, OnChanges, OnDestroy, OnInit, Output, 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\n/** Lifecycle of a single agent task. */\nexport type TaskStatus = 'pending' | 'in-progress' | 'completed' | 'error';\n\nconst taskVariants = cva(\n 'w-full rounded-lg border bg-card text-card-foreground transition-colors',\n {\n variants: {\n status: {\n pending: 'border-border',\n 'in-progress': 'border-info/40',\n completed: 'border-success/40',\n error: 'border-destructive/40',\n },\n },\n defaultVariants: { status: 'pending' },\n }\n);\n\nconst taskIconVariants = cva('shrink-0 text-base leading-none', {\n variants: {\n status: {\n pending: 'text-muted-foreground',\n 'in-progress': 'text-info',\n completed: 'text-success',\n error: 'text-destructive',\n },\n },\n defaultVariants: { status: 'pending' },\n});\n\nexport type TaskProps = VariantProps<typeof taskVariants>;\n\n/** Remixicon class used for each task status. */\nconst TASK_ICONS: Record<TaskStatus, string> = {\n pending: 'ri-circle-line',\n 'in-progress': 'ri-loader-4-line animate-spin',\n completed: 'ri-checkbox-circle-fill',\n error: 'ri-error-warning-fill',\n};\n\n/**\n * Coordinates a `tolle-task` and its trigger/content children.\n *\n * The children are `OnPush` and none of the parent's state arrives as their own\n * inputs, so it is published here as observables and every subscriber calls\n * `markForCheck()`.\n */\n@Injectable()\nexport class TaskService {\n private readonly openSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the task's content is expanded. */\n readonly open$ = this.openSource.asObservable();\n\n private readonly statusSource = new BehaviorSubject<TaskStatus>('pending');\n /** Emits the task's current status. */\n readonly status$ = this.statusSource.asObservable();\n\n private readonly titleSource = new BehaviorSubject<string>('');\n /** Emits the task's title. */\n readonly title$ = this.titleSource.asObservable();\n\n get open(): boolean {\n return this.openSource.value;\n }\n\n get status(): TaskStatus {\n return this.statusSource.value;\n }\n\n get title(): string {\n return this.titleSource.value;\n }\n\n setOpen(open: boolean): void {\n if (this.openSource.value !== open) this.openSource.next(open);\n }\n\n toggle(): void {\n this.openSource.next(!this.openSource.value);\n }\n\n setStatus(status: TaskStatus): void {\n if (this.statusSource.value !== status) this.statusSource.next(status);\n }\n\n setTitle(title: string): void {\n if (this.titleSource.value !== title) this.titleSource.next(title);\n }\n}\n\n/**\n * A collapsible record of one unit of agent work — a title, a status, and the\n * individual steps taken, revealed on demand.\n *\n * ```html\n * <tolle-task status=\"completed\" title=\"Read the codebase\" [open]=\"true\">\n * <tolle-task-trigger></tolle-task-trigger>\n * <tolle-task-content>\n * <tolle-task-item>\n * Searched <tolle-task-item-file>utils/cn.ts</tolle-task-item-file>\n * </tolle-task-item>\n * </tolle-task-content>\n * </tolle-task>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-task',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [TaskService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"computedClass\" [attr.data-status]=\"status\"><ng-content></ng-content></div>`,\n})\nexport class TaskComponent implements OnInit, OnChanges, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /** Lifecycle state of the task; drives the icon and border colour. @default 'pending' */\n @Input() status: TaskStatus = 'pending';\n /** Title shown by `tolle-task-trigger`. @default '' */\n @Input() title = '';\n /** Whether the task's content is expanded. @default false */\n @Input() open = false;\n /** Extra Tailwind classes merged onto the task via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new expanded state whenever the task opens or closes. */\n @Output() openChange = new EventEmitter<boolean>();\n\n private readonly service = inject(TaskService);\n private readonly sub = new Subscription();\n\n ngOnInit(): void {\n this.push();\n // skip(1) drops the BehaviorSubject's replayed value so the component does\n // not emit an `openChange` for its own initial state.\n this.sub.add(this.service.open$.pipe(skip(1)).subscribe((open) => this.openChange.emit(open)));\n }\n\n ngOnChanges(): void {\n this.push();\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 ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n private push(): void {\n this.service.setStatus(this.status);\n this.service.setTitle(this.title);\n this.service.setOpen(this.open);\n }\n\n get computedClass() {\n return cn(taskVariants({ status: this.status }), this.class);\n }\n}\n\n/** Header row of a `tolle-task`: status icon, title, and the expand chevron. */\n@Component({\n selector: 'tolle-task-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"toggle()\"\n >\n <i [class]=\"iconClass\" [attr.data-status]=\"status\" aria-hidden=\"true\"></i>\n <span class=\"min-w-0 flex-1 truncate text-left\">{{ title }}</span>\n <ng-content></ng-content>\n <i\n [class]=\"isOpen ? 'ri-arrow-down-s-line shrink-0 text-muted-foreground' : 'ri-arrow-right-s-line shrink-0 text-muted-foreground'\"\n aria-hidden=\"true\"\n ></i>\n </button>\n `,\n})\nexport class TaskTriggerComponent 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 trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(TaskService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n isOpen = false;\n status: TaskStatus = 'pending';\n title = '';\n\n ngOnInit(): void {\n this.sub.add(\n this.service.open$.subscribe((open) => {\n this.isOpen = open;\n this.cdr.markForCheck();\n })\n );\n this.sub.add(\n this.service.status$.subscribe((status) => {\n this.status = status;\n this.cdr.markForCheck();\n })\n );\n this.sub.add(\n this.service.title$.subscribe((title) => {\n this.title = title;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n get iconClass() {\n return cn(taskIconVariants({ status: this.status }), TASK_ICONS[this.status]);\n }\n\n get computedClass() {\n return cn(\n 'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-foreground outline-none transition-colors',\n 'hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring',\n this.class\n );\n }\n\n toggle(): void {\n this.service.toggle();\n }\n}\n\n/** The body of a `tolle-task`; rendered only while the task is expanded. */\n@Component({\n selector: 'tolle-task-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div *ngIf=\"isOpen\" [attr.data-state]=\"'open'\" [class]=\"computedClass\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class TaskContentComponent 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(TaskService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n isOpen = false;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.open$.subscribe((open) => {\n this.isOpen = open;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n get computedClass() {\n return cn('flex flex-col gap-1 border-t border-border px-3 py-2', this.class);\n }\n}\n\n/** One line of work inside a `tolle-task-content`. */\n@Component({\n selector: 'tolle-task-item',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <i class=\"ri-corner-down-right-line mt-0.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n <span class=\"min-w-0 flex-1\"><ng-content></ng-content></span>\n </div>\n `,\n})\nexport class TaskItemComponent 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 item via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn('flex items-start gap-2 text-sm text-muted-foreground', this.class);\n }\n}\n\n/** An inline file chip, for naming the file a task step touched. */\n@Component({\n selector: 'tolle-task-item-file',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <span [class]=\"computedClass\">\n <i [class]=\"icon + ' shrink-0 text-muted-foreground'\" aria-hidden=\"true\"></i>\n <ng-content></ng-content>\n </span>\n `,\n})\nexport class TaskItemFileComponent 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 /** Remixicon class for the leading file glyph. @default 'ri-file-line' */\n @Input() icon = 'ri-file-line';\n /** Extra Tailwind classes merged onto the chip via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(\n 'inline-flex items-center gap-1 rounded-md border border-border bg-muted px-1.5 py-0.5 align-middle text-xs font-medium text-foreground',\n this.class\n );\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
package/registry/r/textarea.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "textarea.component.ts",
|
|
12
12
|
"target": "ui/textarea.component.ts",
|
|
13
|
-
"content": "import { Component, Input, forwardRef, ViewChild, ElementRef, AfterViewInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-textarea',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => TextareaComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"flex flex-col gap-1.5 w-full\">\n <label\n *ngIf=\"label\"\n [for]=\"id\"\n [class]=\"computedLabelClass\"\n >\n {{ label }}\n </label>\n\n <div class=\"relative\">\n <textarea\n #textareaElement\n [id]=\"id\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [rows]=\"rows\"\n [(ngModel)]=\"value\"\n (input)=\"handleInput($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"textareaClasses\"\n [style.resize]=\"(autoGrow || readonly || disabled) ? 'none' : 'vertical'\"\n [style.overflow]=\"autoGrow ? 'hidden' : 'auto'\"\n [attr.aria-invalid]=\"error || null\"\n [attr.aria-describedby]=\"error && errorMessage ? id + '-error' : (hint ? id + '-hint' : null)\"\n [attr.maxlength]=\"maxLength\"\n ></textarea>\n </div>\n\n <div *ngIf=\"(showCharacterCount || hint || errorMessage) && !disabled\" class=\"flex justify-between items-center px-1\">\n <p\n *ngIf=\"hint && !error\"\n [id]=\"id + '-hint'\"\n class=\"text-xs text-muted-foreground transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideHintOnFocus\"\n >\n {{ hint }}\n </p>\n <p\n *ngIf=\"error && errorMessage\"\n [id]=\"id + '-error'\"\n class=\"text-xs text-destructive\"\n >\n {{ errorMessage }}\n </p>\n <p\n *ngIf=\"showCharacterCount\"\n class=\"text-[10px] uppercase tracking-wider text-muted-foreground ml-auto font-medium transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideCharacterCountOnFocus\"\n >\n {{ value.length || 0 }}{{ maxLength ? ' / ' + maxLength : '' }}\n </p>\n </div>\n </div>\n `\n})\nexport class TextareaComponent implements ControlValueAccessor, AfterViewInit {\n @ViewChild('textareaElement') textareaElement!: ElementRef<HTMLTextAreaElement>;\n\n @Input() id = `textarea-${Math.random().toString(36).substring(2, 9)}`;\n @Input() label = '';\n @Input() placeholder = '';\n @Input() hint = '';\n @Input() errorMessage = '';\n @Input() rows = 3;\n @Input() maxLength?: number;\n @Input() showCharacterCount = false;\n @Input() autoGrow = false;\n @Input() error = false;\n @Input() className = '';\n\n // Focus behavior\n @Input() hideHintOnFocus = true;\n @Input() hideCharacterCountOnFocus = false;\n\n // New States\n @Input() disabled = false;\n @Input() readonly = false;\n\n value: string = '';\n isFocused: boolean = false;\n onChange: any = () => {};\n onTouched: any = () => {};\n\n ngAfterViewInit() {\n if (this.autoGrow) this.resize();\n }\n\n get computedLabelClass() {\n return cn(\n \"text-sm font-medium text-foreground leading-none transition-opacity duration-200\",\n this.disabled && \"opacity-50\"\n );\n }\n\n get textareaClasses() {\n return cn(\n // Base styles\n 'flex w-full rounded-md border bg-background px-3 py-2 text-sm',\n 'ring-offset-background transition-all duration-200',\n 'placeholder:text-muted-foreground',\n\n // Border and shadow\n 'border-input shadow-sm',\n\n // Minimum height\n 'min-h-[80px]',\n\n // Focus state - SIMPLE LIKE ZARDUI\n !(this.readonly || this.disabled) && [\n 'focus:outline-none',\n 'focus:ring-4',\n 'focus:ring-ring/30',\n 'focus:ring-offset-0',\n 'focus:shadow-none',\n // Border darkens on focus automatically\n 'focus:border-primary/80'\n ],\n\n // Error state\n this.error && [\n 'border-destructive',\n !(this.readonly || this.disabled) && [\n 'focus:border-destructive/80',\n 'focus:ring-destructive/30'\n ]\n ],\n\n // Disabled state\n this.disabled && [\n 'cursor-not-allowed opacity-50',\n 'border-opacity-50'\n ],\n\n // Readonly state\n this.readonly && [\n 'cursor-default',\n 'border-dashed',\n !this.disabled && 'focus:ring-0 focus:border-opacity-100'\n ],\n\n // Scrollbar styling\n 'scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent',\n\n this.className\n );\n }\n\n handleInput(event: any) {\n if (this.readonly || this.disabled) return;\n const val = event.target.value;\n this.value = val;\n this.onChange(val);\n if (this.autoGrow) this.resize();\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n }\n\n private resize() {\n const textarea = this.textareaElement.nativeElement;\n textarea.style.height = 'auto';\n textarea.style.height = `${textarea.scrollHeight}px`;\n }\n\n writeValue(value: any): void {\n this.value = value ?? '';\n if (this.autoGrow) setTimeout(() => this.resize());\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
|
|
13
|
+
"content": "import { Component, Input, forwardRef, ViewChild, ElementRef, AfterViewInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-textarea',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => TextareaComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"flex flex-col gap-1.5 w-full\">\n <label\n *ngIf=\"label\"\n [for]=\"id\"\n [class]=\"computedLabelClass\"\n >\n {{ label }}\n </label>\n\n <div class=\"relative\">\n <textarea\n #textareaElement\n [id]=\"id\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [rows]=\"rows\"\n [(ngModel)]=\"value\"\n (input)=\"handleInput($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"textareaClasses\"\n [style.resize]=\"(autoGrow || readonly || disabled) ? 'none' : 'vertical'\"\n [style.overflow]=\"autoGrow ? 'hidden' : 'auto'\"\n [attr.aria-invalid]=\"error || null\"\n [attr.aria-describedby]=\"error && errorMessage ? id + '-error' : (hint ? id + '-hint' : null)\"\n [attr.maxlength]=\"maxLength\"\n ></textarea>\n </div>\n\n <div *ngIf=\"(showCharacterCount || hint || errorMessage) && !disabled\" class=\"flex justify-between items-center px-1\">\n <p\n *ngIf=\"hint && !error\"\n [id]=\"id + '-hint'\"\n class=\"text-xs text-muted-foreground transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideHintOnFocus\"\n >\n {{ hint }}\n </p>\n <p\n *ngIf=\"error && errorMessage\"\n [id]=\"id + '-error'\"\n class=\"text-xs text-destructive\"\n >\n {{ errorMessage }}\n </p>\n <p\n *ngIf=\"showCharacterCount\"\n class=\"text-[10px] uppercase tracking-wider text-muted-foreground ml-auto font-medium transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideCharacterCountOnFocus\"\n >\n {{ value.length || 0 }}{{ maxLength ? ' / ' + maxLength : '' }}\n </p>\n </div>\n </div>\n `\n})\nexport class TextareaComponent implements ControlValueAccessor, AfterViewInit {\n @ViewChild('textareaElement') textareaElement!: ElementRef<HTMLTextAreaElement>;\n\n @Input() id = `textarea-${Math.random().toString(36).substring(2, 9)}`;\n @Input() label = '';\n @Input() placeholder = '';\n @Input() hint = '';\n @Input() errorMessage = '';\n @Input() rows = 3;\n @Input() maxLength?: number;\n @Input() showCharacterCount = false;\n @Input() autoGrow = false;\n @Input() error = false;\n @Input() className = '';\n\n // Focus behavior\n @Input() hideHintOnFocus = true;\n @Input() hideCharacterCountOnFocus = false;\n\n // New States\n @Input() disabled = false;\n @Input() readonly = false;\n\n value: string = '';\n isFocused: boolean = false;\n onChange: any = () => {};\n onTouched: any = () => {};\n\n ngAfterViewInit() {\n if (this.autoGrow) this.resize();\n }\n\n get computedLabelClass() {\n return cn(\n \"text-sm font-medium text-foreground leading-none transition-opacity duration-200\",\n this.disabled && \"opacity-50\"\n );\n }\n\n get textareaClasses() {\n return cn(\n // Base styles\n 'flex w-full rounded-md border bg-background px-3 py-2 text-sm',\n 'ring-offset-background transition-all duration-200',\n 'placeholder:text-muted-foreground',\n\n // Border and shadow\n 'border-input shadow-sm',\n\n // Minimum height\n 'min-h-[80px]',\n\n // Focus state - SIMPLE LIKE ZARDUI\n !(this.readonly || this.disabled) && [\n 'focus:outline-none',\n 'focus:ring-4',\n 'focus:ring-ring/30',\n 'focus:ring-offset-0',\n 'focus:shadow-none',\n // Border darkens on focus automatically\n 'focus:border-primary/80'\n ],\n\n // Error state\n this.error && [\n 'border-destructive',\n !(this.readonly || this.disabled) && [\n 'focus:border-destructive/80',\n 'focus:ring-destructive/30'\n ]\n ],\n\n // Disabled state\n this.disabled && [\n 'cursor-not-allowed opacity-50',\n 'border-opacity-50'\n ],\n\n // Readonly state\n this.readonly && [\n 'cursor-default',\n 'border-dashed',\n !this.disabled && 'focus:ring-0 focus:border-opacity-100'\n ],\n\n // Scrollbar styling\n 'scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent',\n\n this.className\n );\n }\n\n handleInput(event: any) {\n if (this.readonly || this.disabled) return;\n const val = event.target.value;\n this.value = val;\n this.onChange(val);\n if (this.autoGrow) this.resize();\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n }\n\n private resize() {\n const textarea = this.textareaElement.nativeElement;\n textarea.style.height = 'auto';\n textarea.style.height = `${textarea.scrollHeight}px`;\n }\n\n writeValue(value: any): void {\n this.value = value ?? '';\n if (this.autoGrow) setTimeout(() => this.resize());\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "utils/cn.ts",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "time-picker",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Time Picker",
|
|
5
|
+
"description": "Time Picker component with size and invalid options.",
|
|
6
|
+
"category": "date-time",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"@floating-ui/dom",
|
|
10
|
+
"class-variance-authority"
|
|
11
|
+
],
|
|
12
|
+
"files": [
|
|
13
|
+
{
|
|
14
|
+
"path": "time-picker.component.ts",
|
|
15
|
+
"target": "ui/time-picker.component.ts",
|
|
16
|
+
"content": "import { Component, Input, Output, EventEmitter, forwardRef, ElementRef, ViewChild, ViewChildren, QueryList, ChangeDetectorRef, ChangeDetectionStrategy, OnDestroy, OnInit, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/* ------------------------------------------------------------------ *\n * Pure time helpers\n *\n * The wire format is a 24-hour `HH:mm` / `HH:mm:ss` string. It is\n * timezone-free and stable across serialisation, which a `Date` is not:\n * a `Date` carries a calendar day and a UTC offset that nobody chose when\n * all the user did was pick \"2:30 PM\".\n * ------------------------------------------------------------------ */\n\n/** A wall-clock time with no date and no timezone attached. */\nexport interface TimeParts {\n hour: number;\n minute: number;\n second: number;\n}\n\nconst pad2 = (n: number): string => (n < 10 ? '0' + n : String(n));\n\n/** Seconds elapsed since midnight for a wall-clock time. */\nexport function timePartsToSeconds(parts: TimeParts): number {\n return parts.hour * 3600 + parts.minute * 60 + parts.second;\n}\n\n/**\n * Parses `HH:mm` or `HH:mm:ss` (24-hour). Returns `null` for anything that\n * is not a well-formed, in-range time — callers treat that as \"no value\".\n */\nexport function parseTimeString(value: string | null | undefined): TimeParts | null {\n if (typeof value !== 'string') return null;\n const match = /^(\\d{1,2}):(\\d{1,2})(?::(\\d{1,2}))?$/.exec(value.trim());\n if (!match) return null;\n\n const hour = Number(match[1]);\n const minute = Number(match[2]);\n const second = match[3] === undefined ? 0 : Number(match[3]);\n\n if (hour > 23 || minute > 59 || second > 59) return null;\n return { hour, minute, second };\n}\n\n/** Renders a wall-clock time as `HH:mm`, or `HH:mm:ss` when `withSeconds`. */\nexport function formatTimeString(parts: TimeParts, withSeconds = false): string {\n const base = pad2(parts.hour) + ':' + pad2(parts.minute);\n return withSeconds ? base + ':' + pad2(parts.second) : base;\n}\n\n/**\n * 24-hour hour to its 12-hour clock face. Midnight is `12 AM` and noon is\n * `12 PM` — the two values that a naive `hour % 12` gets wrong.\n */\nexport function to12Hour(hour24: number): { hour12: number; meridiem: 'AM' | 'PM' } {\n const meridiem: 'AM' | 'PM' = hour24 < 12 ? 'AM' : 'PM';\n const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12;\n return { hour12, meridiem };\n}\n\n/** 12-hour clock face back to a 24-hour hour. `12 AM` is 0, `12 PM` is 12. */\nexport function from12Hour(hour12: number, meridiem: 'AM' | 'PM'): number {\n const base = hour12 % 12; // 12 -> 0, leaves 1..11 alone\n return meridiem === 'PM' ? base + 12 : base;\n}\n\n/* ------------------------------------------------------------------ *\n * Column model\n * ------------------------------------------------------------------ */\n\n/** Which unit a column of the picker scrolls through. */\nexport type TimeColumnKind = 'hour' | 'minute' | 'second' | 'meridiem';\n\n/** One entry inside a time column. */\nexport interface TimeOption {\n /** Stable identity for `trackBy` and `aria-activedescendant`. */\n id: string;\n /** Text shown to the user. */\n label: string;\n /**\n * The 24-hour value this entry commits for its column. For a `meridiem`\n * column this is the resulting 24-hour *hour*.\n */\n raw: number;\n /** Value used for the selected comparison (clock-face value for hours). */\n face: number;\n disabled: boolean;\n}\n\n/** A scrollable column of the picker. */\nexport interface TimeColumn {\n kind: TimeColumnKind;\n label: string;\n options: TimeOption[];\n}\n\nconst SECONDS_PER_DAY = 24 * 3600;\n\nconst timeOptionVariants = cva(\n 'flex w-full shrink-0 cursor-pointer select-none items-center justify-center rounded-md tabular-nums ' +\n 'transition-colors focus:outline-none disabled:pointer-events-none disabled:opacity-40',\n {\n variants: {\n size: {\n xs: 'h-6 px-2 text-[11px]',\n sm: 'h-7 px-2 text-xs',\n default: 'h-8 px-2.5 text-sm',\n lg: 'h-9 px-3 text-base',\n },\n selected: {\n true: 'bg-primary text-primary-foreground hover:bg-primary',\n false: 'text-foreground hover:bg-accent hover:text-accent-foreground',\n },\n },\n defaultVariants: { size: 'default', selected: false },\n }\n);\n\n/** Variant props for the option rows inside a time column. */\nexport type TimeColumnsProps = VariantProps<typeof timeOptionVariants>;\n\n/**\n * The bare scrollable hour / minute / second / AM-PM columns, with no trigger\n * and no popover of its own.\n *\n * `tolle-time-picker` wraps this in a floating panel, and\n * `tolle-date-time-picker` sits it beside a calendar — so the option\n * generation, the min/max disabling and the roving keyboard model live here\n * once instead of in both.\n */\n@Component({\n selector: 'tolle-time-columns',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"cn('flex items-stretch gap-1', class)\">\n <div\n *ngFor=\"let column of columns; let ci = index; trackBy: trackColumn\"\n class=\"flex flex-col\"\n >\n <div class=\"px-1 pb-1 text-center text-[10px] font-medium uppercase tracking-wide text-muted-foreground\">\n {{ column.label }}\n </div>\n <div\n #listbox\n role=\"listbox\"\n tabindex=\"0\"\n [attr.aria-label]=\"column.label\"\n [attr.aria-activedescendant]=\"activeDescendantFor(ci)\"\n (keydown)=\"onColumnKeydown($event, ci)\"\n [class]=\"cn(\n 'flex flex-col gap-0.5 overflow-y-auto scroll-smooth rounded-md p-1',\n 'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n column.kind === 'meridiem' ? 'w-14' : 'w-14',\n heightClass\n )\"\n >\n <button\n *ngFor=\"let option of column.options; trackBy: trackOption\"\n type=\"button\"\n role=\"option\"\n tabindex=\"-1\"\n [id]=\"option.id\"\n [attr.aria-selected]=\"isSelected(column, option)\"\n [attr.aria-disabled]=\"option.disabled\"\n [attr.data-selected]=\"isSelected(column, option)\"\n [attr.data-active]=\"isActive(ci, option)\"\n [disabled]=\"option.disabled\"\n [class]=\"optionClass(column, option, ci)\"\n (click)=\"choose(column, option, ci)\"\n >\n {{ option.label }}\n </button>\n </div>\n </div>\n </div>\n `,\n})\nexport class TimeColumnsComponent implements OnInit, OnChanges {\n /** Selected hour in 24-hour form, or `null` when nothing is chosen yet. @default null */\n @Input() hour: number | null = null;\n /** Selected minute, or `null` when nothing is chosen yet. @default null */\n @Input() minute: number | null = null;\n /** Selected second, or `null` when nothing is chosen yet. @default null */\n @Input() second: number | null = null;\n /** Renders a 12-hour clock face with an AM/PM column. @default false */\n @Input() use12Hours = false;\n /** Adds the seconds column. @default false */\n @Input() showSeconds = false;\n /** Interval between minute entries, in minutes. @default 1 */\n @Input() minuteStep = 1;\n /** Interval between second entries, in seconds. @default 1 */\n @Input() secondStep = 1;\n /** Earliest selectable time, as seconds since midnight. @default 0 */\n @Input() minSeconds = 0;\n /** Latest selectable time, as seconds since midnight. @default 86399 */\n @Input() maxSeconds = SECONDS_PER_DAY - 1;\n /** Row height and text size of the entries. @default 'default' */\n @Input() size: TimeColumnsProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the column strip via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the full wall-clock time whenever an entry is chosen. */\n @Output() timeChange = new EventEmitter<TimeParts>();\n\n @ViewChildren('listbox') listboxes?: QueryList<ElementRef<HTMLElement>>;\n\n readonly columnsId = 'time-col-' + Math.random().toString(36).slice(2, 11);\n\n /**\n * Built once per input change rather than read from a getter: a getter would\n * hand `*ngFor` a fresh array on every change-detection pass, so every row —\n * each carrying a click handler — would be torn down and rebuilt on every\n * pass.\n */\n columns: TimeColumn[] = [];\n /** Roving active entry per column, for `aria-activedescendant`. */\n activeIndex: number[] = [];\n\n protected cn = cn;\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n ngOnInit(): void {\n if (!this.columns.length) this.rebuild();\n }\n\n ngOnChanges(): void {\n this.rebuild();\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 get heightClass(): string {\n switch (this.size) {\n case 'xs':\n return 'max-h-[144px]';\n case 'sm':\n return 'max-h-[168px]';\n case 'lg':\n return 'max-h-[216px]';\n default:\n return 'max-h-[192px]';\n }\n }\n\n /* ---------------- option generation ---------------- */\n\n private rebuild(): void {\n const step = (value: number, fallback: number) =>\n Number.isFinite(value) && value >= 1 ? Math.floor(value) : fallback;\n\n const minuteStep = step(this.minuteStep, 1);\n const secondStep = step(this.secondStep, 1);\n const effectiveHour = this.hour ?? 0;\n const effectiveMinute = this.minute ?? 0;\n const meridiem = to12Hour(effectiveHour).meridiem;\n\n // How far a partially-specified time can stretch. With no seconds column\n // the seconds are pinned to :00, so an hour only reaches :59:00.\n const hourSpan = this.showSeconds ? 3599 : 3540;\n const minuteSpan = this.showSeconds ? 59 : 0;\n\n const columns: TimeColumn[] = [];\n\n // Hours\n const hourOptions: TimeOption[] = [];\n if (this.use12Hours) {\n // Clock-face order: 12, 1, 2 … 11.\n const faces = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];\n for (const face of faces) {\n const raw = from12Hour(face, meridiem);\n hourOptions.push({\n id: this.columnsId + '-hour-' + face,\n label: pad2(face),\n raw,\n face,\n disabled: this.rangeDisabled(raw * 3600, hourSpan),\n });\n }\n } else {\n for (let h = 0; h < 24; h++) {\n hourOptions.push({\n id: this.columnsId + '-hour-' + h,\n label: pad2(h),\n raw: h,\n face: h,\n disabled: this.rangeDisabled(h * 3600, hourSpan),\n });\n }\n }\n columns.push({ kind: 'hour', label: 'Hour', options: hourOptions });\n\n // Minutes\n const minuteOptions: TimeOption[] = [];\n for (let m = 0; m < 60; m += minuteStep) {\n const start = effectiveHour * 3600 + m * 60;\n minuteOptions.push({\n id: this.columnsId + '-minute-' + m,\n label: pad2(m),\n raw: m,\n face: m,\n disabled: this.rangeDisabled(start, minuteSpan),\n });\n }\n columns.push({ kind: 'minute', label: 'Min', options: minuteOptions });\n\n // Seconds\n if (this.showSeconds) {\n const secondOptions: TimeOption[] = [];\n for (let s = 0; s < 60; s += secondStep) {\n const at = effectiveHour * 3600 + effectiveMinute * 60 + s;\n secondOptions.push({\n id: this.columnsId + '-second-' + s,\n label: pad2(s),\n raw: s,\n face: s,\n disabled: this.rangeDisabled(at, 0),\n });\n }\n columns.push({ kind: 'second', label: 'Sec', options: secondOptions });\n }\n\n // Meridiem\n if (this.use12Hours) {\n const face12 = to12Hour(effectiveHour).hour12;\n const meridiemOptions: TimeOption[] = (['AM', 'PM'] as const).map((m, i) => {\n const raw = from12Hour(face12, m);\n const halfStart = m === 'AM' ? 0 : 12 * 3600;\n return {\n id: this.columnsId + '-meridiem-' + m,\n label: m,\n raw,\n face: i, // 0 = AM, 1 = PM\n disabled: this.rangeDisabled(halfStart, 12 * 3600 - 1),\n };\n });\n columns.push({ kind: 'meridiem', label: 'AM/PM', options: meridiemOptions });\n }\n\n this.columns = columns;\n this.syncActiveToSelection();\n }\n\n /**\n * True when nothing in `[start, start + span]` falls inside the allowed\n * window — i.e. picking this entry could not produce a legal time.\n */\n private rangeDisabled(start: number, span: number): boolean {\n return start + span < this.minSeconds || start > this.maxSeconds;\n }\n\n private syncActiveToSelection(): void {\n this.activeIndex = this.columns.map((column) => {\n const idx = column.options.findIndex((o) => this.isSelected(column, o));\n if (idx >= 0) return idx;\n const firstEnabled = column.options.findIndex((o) => !o.disabled);\n return firstEnabled >= 0 ? firstEnabled : 0;\n });\n }\n\n /* ---------------- selection ---------------- */\n\n isSelected(column: TimeColumn, option: TimeOption): boolean {\n switch (column.kind) {\n case 'hour':\n if (this.hour === null) return false;\n return this.use12Hours ? to12Hour(this.hour).hour12 === option.face : this.hour === option.face;\n case 'minute':\n return this.minute !== null && this.minute === option.face;\n case 'second':\n return this.second !== null && this.second === option.face;\n case 'meridiem':\n if (this.hour === null) return false;\n return (to12Hour(this.hour).meridiem === 'AM' ? 0 : 1) === option.face;\n }\n }\n\n isActive(columnIndex: number, option: TimeOption): boolean {\n const column = this.columns[columnIndex];\n if (!column) return false;\n return column.options[this.activeIndex[columnIndex]] === option;\n }\n\n activeDescendantFor(columnIndex: number): string | null {\n const column = this.columns[columnIndex];\n return column?.options[this.activeIndex[columnIndex]]?.id ?? null;\n }\n\n optionClass(column: TimeColumn, option: TimeOption, columnIndex: number): string {\n const selected = this.isSelected(column, option);\n return cn(\n timeOptionVariants({ size: this.size, selected }),\n // The roving highlight has to read as distinct from the committed value,\n // otherwise arrowing around looks like it is already selecting.\n !selected && this.isActive(columnIndex, option) && 'bg-accent text-accent-foreground'\n );\n }\n\n /** Commits `option` in its column and emits the resulting whole time. */\n choose(column: TimeColumn, option: TimeOption, columnIndex: number): void {\n if (option.disabled) return;\n\n const next: TimeParts = {\n hour: this.hour ?? 0,\n minute: this.minute ?? 0,\n second: this.second ?? 0,\n };\n\n switch (column.kind) {\n case 'hour':\n case 'meridiem':\n next.hour = option.raw;\n break;\n case 'minute':\n next.minute = option.raw;\n break;\n case 'second':\n next.second = option.raw;\n break;\n }\n\n this.activeIndex[columnIndex] = column.options.indexOf(option);\n this.timeChange.emit(next);\n this.cdr.markForCheck();\n }\n\n /* ---------------- keyboard ---------------- */\n\n onColumnKeydown(event: KeyboardEvent, columnIndex: number): void {\n const column = this.columns[columnIndex];\n if (!column) return;\n\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault();\n this.moveActive(columnIndex, 1);\n break;\n case 'ArrowUp':\n event.preventDefault();\n this.moveActive(columnIndex, -1);\n break;\n case 'Home':\n event.preventDefault();\n this.setActive(columnIndex, 0, 1);\n break;\n case 'End':\n event.preventDefault();\n this.setActive(columnIndex, column.options.length - 1, -1);\n break;\n case 'ArrowLeft':\n event.preventDefault();\n this.focusColumn(columnIndex - 1);\n break;\n case 'ArrowRight':\n event.preventDefault();\n this.focusColumn(columnIndex + 1);\n break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n {\n const option = column.options[this.activeIndex[columnIndex]];\n if (option) this.choose(column, option, columnIndex);\n }\n break;\n default:\n return;\n }\n }\n\n private moveActive(columnIndex: number, delta: number): void {\n const current = this.activeIndex[columnIndex] ?? 0;\n this.setActive(columnIndex, current + delta, delta);\n }\n\n /** Lands on the first enabled entry at or past `index`, walking `direction`. */\n private setActive(columnIndex: number, index: number, direction: number): void {\n const column = this.columns[columnIndex];\n if (!column) return;\n\n let i = Math.max(0, Math.min(index, column.options.length - 1));\n while (column.options[i]?.disabled) {\n i += direction;\n if (i < 0 || i >= column.options.length) return; // no enabled entry that way\n }\n\n this.activeIndex[columnIndex] = i;\n this.scrollOptionIntoView(columnIndex, i);\n this.cdr.markForCheck();\n }\n\n private focusColumn(columnIndex: number): void {\n if (columnIndex < 0 || columnIndex >= this.columns.length) return;\n this.listboxes?.get(columnIndex)?.nativeElement.focus();\n }\n\n /** Focuses the first column — used when the panel opens. */\n focusFirstColumn(): void {\n this.focusColumn(0);\n }\n\n /**\n * Centres each column on its selected entry. Scrolls the listbox directly\n * rather than calling `scrollIntoView`, which would also scroll the page\n * behind a fixed-position panel.\n */\n scrollSelectedIntoView(): void {\n this.listboxes?.forEach((ref) => {\n const list = ref.nativeElement;\n const selected = list.querySelector('[data-selected=\"true\"]') as HTMLElement | null;\n if (!selected) return;\n list.scrollTop = selected.offsetTop - list.clientHeight / 2 + selected.clientHeight / 2;\n });\n }\n\n private scrollOptionIntoView(columnIndex: number, optionIndex: number): void {\n const list = this.listboxes?.get(columnIndex)?.nativeElement;\n if (!list) return;\n const el = list.children[optionIndex] as HTMLElement | undefined;\n if (!el) return;\n\n if (el.offsetTop < list.scrollTop) {\n list.scrollTop = el.offsetTop;\n } else if (el.offsetTop + el.clientHeight > list.scrollTop + list.clientHeight) {\n list.scrollTop = el.offsetTop + el.clientHeight - list.clientHeight;\n }\n }\n\n trackColumn = (_: number, column: TimeColumn) => column.kind;\n trackOption = (_: number, option: TimeOption) => option.id;\n}\n\n/* ------------------------------------------------------------------ *\n * The picker\n * ------------------------------------------------------------------ */\n\nconst timePickerTriggerVariants = cva(\n 'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background text-foreground shadow-sm transition-colors ' +\n 'focus:outline-none focus-visible:border-primary/80 focus-visible:ring-4 focus-visible:ring-ring/30 ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\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 invalid: {\n true: 'border-destructive focus-visible:border-destructive/80 focus-visible:ring-destructive/30',\n false: '',\n },\n },\n defaultVariants: { size: 'default', invalid: false },\n }\n);\n\n/** Variant props for the time picker trigger. */\nexport type TimePickerProps = VariantProps<typeof timePickerTriggerVariants>;\n\n/**\n * A trigger that opens scrollable hour / minute / second columns in a floating\n * panel.\n *\n * The value is a **24-hour `HH:mm` string** (or `HH:mm:ss` when `showSeconds`),\n * regardless of whether the face is rendered as 12-hour — a wall-clock time has\n * no date and no timezone, and a `Date` would invent both. Set\n * `valueType=\"date\"` to exchange `Date` objects instead, in which case the time\n * is written onto today's date (or onto the date already held by the control).\n *\n * Implements `ControlValueAccessor`, so it works with `ngModel` and reactive\n * forms. Use `tolle-date-time-picker` when a date is needed alongside.\n * @new\n */\n@Component({\n selector: 'tolle-time-picker',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, TimeColumnsComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => TimePickerComponent),\n multi: true,\n },\n ],\n template: `\n <button\n #trigger\n type=\"button\"\n aria-haspopup=\"dialog\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? panelId : null\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n >\n <span [class]=\"cn('truncate', displayValue ? 'text-foreground' : 'text-muted-foreground')\">\n {{ displayValue || placeholder }}\n </span>\n <i class=\"ri-time-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n </button>\n\n <div\n *ngIf=\"isOpen\"\n #panel\n role=\"dialog\"\n [id]=\"panelId\"\n [attr.aria-label]=\"ariaLabel || 'Choose a time'\"\n class=\"fixed left-0 top-0 z-[9999]\"\n style=\"visibility: hidden\"\n >\n <div class=\"rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md\">\n <tolle-time-columns\n #columns\n [hour]=\"hour\"\n [minute]=\"minute\"\n [second]=\"second\"\n [use12Hours]=\"use12Hours\"\n [showSeconds]=\"showSeconds\"\n [minuteStep]=\"minuteStep\"\n [secondStep]=\"secondStep\"\n [minSeconds]=\"minSeconds\"\n [maxSeconds]=\"maxSeconds\"\n [size]=\"size\"\n (timeChange)=\"onTimeChange($event)\"\n ></tolle-time-columns>\n </div>\n </div>\n `,\n})\nexport class TimePickerComponent implements OnChanges, ControlValueAccessor, 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 /** Text shown on the trigger when no time is selected. @default 'Select a time' */\n @Input() placeholder = 'Select a time';\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Renders a 12-hour face with an AM/PM column. The emitted value stays 24-hour. @default false */\n @Input() use12Hours = false;\n /** Adds a seconds column and emits `HH:mm:ss`. @default false */\n @Input() showSeconds = false;\n /** Interval between minute entries, in minutes. @default 1 */\n @Input() minuteStep = 1;\n /** Interval between second entries, in seconds. @default 1 */\n @Input() secondStep = 1;\n /** Earliest selectable time as a 24-hour `HH:mm` string; earlier entries are disabled. */\n @Input() min?: string;\n /** Latest selectable time as a 24-hour `HH:mm` string; later entries are disabled. */\n @Input() max?: string;\n /** Height and text size of the trigger and entries. @default 'default' */\n @Input() size: TimePickerProps['size'] = 'default';\n /** Applies the destructive border and focus ring. @default false */\n @Input() invalid = false;\n /** Accessible name when there is no associated visible label. @default '' */\n @Input() ariaLabel = '';\n /** Exchange `Date` objects instead of `HH:mm` strings through the form control. @default 'string' */\n @Input() valueType: 'string' | 'date' = 'string';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the chosen time whenever it changes. */\n @Output() valueChange = new EventEmitter<string | Date | null>();\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') triggerEl?: ElementRef<HTMLElement>;\n @ViewChild('panel') panelEl?: ElementRef<HTMLElement>;\n @ViewChild('columns') columnsCmp?: TimeColumnsComponent;\n\n readonly panelId = 'time-picker-panel-' + Math.random().toString(36).slice(2, 11);\n\n hour: number | null = null;\n minute: number | null = null;\n second: number | null = null;\n isOpen = false;\n\n protected cn = cn;\n\n private cleanup?: () => void;\n /** The date half of a `Date` value, kept so round-tripping does not move the day. */\n private dateAnchor: Date | null = null;\n private onChange: (value: string | Date | null) => void = () => {};\n private onTouched: () => void = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n private readonly outsideClickHandler = (event: MouseEvent) => {\n const target = event.target as Node;\n if (\n !this.triggerEl?.nativeElement.contains(target) &&\n !this.panelEl?.nativeElement.contains(target)\n ) {\n this.close();\n }\n };\n\n private readonly escapeHandler = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && this.isOpen) {\n event.preventDefault();\n this.close();\n this.triggerEl?.nativeElement.focus();\n }\n };\n\n /* ---------------- derived ---------------- */\n\n /** The current selection as wall-clock parts, or `null` when incomplete. */\n get parts(): TimeParts | null {\n if (this.hour === null || this.minute === null) return null;\n return { hour: this.hour, minute: this.minute, second: this.second ?? 0 };\n }\n\n get displayValue(): string {\n const parts = this.parts;\n if (!parts) return '';\n\n if (this.use12Hours) {\n const { hour12, meridiem } = to12Hour(parts.hour);\n const body =\n String(hour12) +\n ':' +\n pad2(parts.minute) +\n (this.showSeconds ? ':' + pad2(parts.second) : '');\n return body + ' ' + meridiem;\n }\n return formatTimeString(parts, this.showSeconds);\n }\n\n get minSeconds(): number {\n const parsed = parseTimeString(this.min);\n return parsed ? timePartsToSeconds(parsed) : 0;\n }\n\n get maxSeconds(): number {\n const parsed = parseTimeString(this.max);\n return parsed ? timePartsToSeconds(parsed) : SECONDS_PER_DAY - 1;\n }\n\n get computedTriggerClass(): string {\n return cn(timePickerTriggerVariants({ size: this.size, invalid: this.invalid }), this.class);\n }\n\n /* ---------------- open / close ---------------- */\n\n toggle(): void {\n this.isOpen ? this.close() : this.open();\n }\n\n open(): void {\n if (this.disabled || this.isOpen) return;\n this.isOpen = true;\n this.opened.emit();\n this.cdr.markForCheck();\n\n setTimeout(() => {\n this.updatePosition();\n this.columnsCmp?.scrollSelectedIntoView();\n this.columnsCmp?.focusFirstColumn();\n document.addEventListener('pointerdown', this.outsideClickHandler, true);\n document.addEventListener('keydown', this.escapeHandler, true);\n });\n }\n\n close(): void {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.closed.emit();\n this.onTouched();\n this.teardown();\n this.cdr.markForCheck();\n }\n\n onTriggerKeyDown(event: KeyboardEvent): void {\n if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key) && !this.isOpen) {\n event.preventDefault();\n this.open();\n }\n }\n\n /* ---------------- value ---------------- */\n\n onTimeChange(parts: TimeParts): void {\n this.hour = parts.hour;\n this.minute = parts.minute;\n this.second = parts.second;\n this.emit();\n this.cdr.markForCheck();\n }\n\n private emit(): void {\n const value = this.currentValue();\n this.onChange(value);\n this.valueChange.emit(value);\n }\n\n private currentValue(): string | Date | null {\n const parts = this.parts;\n if (!parts) return null;\n if (this.valueType === 'date') {\n const base = this.dateAnchor ?? new Date();\n return new Date(\n base.getFullYear(),\n base.getMonth(),\n base.getDate(),\n parts.hour,\n parts.minute,\n parts.second,\n 0\n );\n }\n return formatTimeString(parts, this.showSeconds);\n }\n\n private updatePosition(): void {\n const trigger = this.triggerEl?.nativeElement;\n const panel = this.panelEl?.nativeElement;\n if (!trigger || !panel) return;\n\n this.cleanup = autoUpdate(trigger, panel, () => {\n computePosition(trigger, panel, {\n strategy: 'fixed',\n placement: 'bottom-start',\n middleware: [offset(4), flip(), shift({ padding: 8 })],\n }).then(({ x, y }) => {\n Object.assign(panel.style, {\n left: x + 'px',\n top: y + 'px',\n visibility: 'visible',\n });\n });\n });\n }\n\n private teardown(): void {\n this.cleanup?.();\n this.cleanup = undefined;\n document.removeEventListener('pointerdown', this.outsideClickHandler, true);\n document.removeEventListener('keydown', this.escapeHandler, true);\n }\n\n ngOnDestroy(): void {\n this.teardown();\n }\n\n /* ---------------- ControlValueAccessor ---------------- */\n\n writeValue(value: string | Date | null | undefined): void {\n let parts: TimeParts | null = null;\n\n if (value instanceof Date && !isNaN(value.getTime())) {\n this.dateAnchor = value;\n parts = { hour: value.getHours(), minute: value.getMinutes(), second: value.getSeconds() };\n } else if (typeof value === 'string') {\n parts = parseTimeString(value);\n }\n\n this.hour = parts ? parts.hour : null;\n this.minute = parts ? parts.minute : null;\n this.second = parts ? parts.second : null;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: (value: string | Date | null) => void): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: () => void): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n if (isDisabled) this.close();\n this.cdr.markForCheck();\n }\n}\n"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"path": "utils/cn.ts",
|
|
20
|
+
"target": "ui/utils/cn.ts",
|
|
21
|
+
"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"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
package/registry/r/toaster.json
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "toast.service.ts",
|
|
12
12
|
"target": "ui/toast.service.ts",
|
|
13
|
-
"content": "// projects/tolle/src/lib/toast.service.ts\nimport { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\nexport interface Toast {\n id: number;\n title?: string;\n description: string;\n variant?:
|
|
13
|
+
"content": "// projects/tolle/src/lib/toast.service.ts\nimport { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\nexport type ToastVariant = 'default' | 'destructive' | 'success' | 'warning' | 'info';\n\nexport interface Toast {\n id: number;\n title?: string;\n description: string;\n variant?: ToastVariant;\n duration?: number; // Custom duration in ms\n remainingTime: number;\n isPaused: boolean;\n}\n\nexport type ToastPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';\n@Injectable({ providedIn: 'root' })\nexport class ToastService {\n private toasts: Toast[] = [];\n private nextId = 0;\n private toastSubject = new Subject<Toast[]>();\n toasts$ = this.toastSubject.asObservable();\n\n constructor() {\n setInterval(() => this.tick(), 100);\n }\n show(toast: Omit<Toast, 'id' | 'remainingTime' | 'isPaused'>) {\n const duration = toast.duration || 3000;\n const newToast: Toast = {\n ...toast,\n id: ++this.nextId,\n duration,\n remainingTime: duration,\n isPaused: false\n };\n this.toasts.push(newToast);\n this.notify();\n }\n\n private tick() {\n let changed = false;\n this.toasts.forEach(t => {\n if (!t.isPaused) {\n t.remainingTime -= 100;\n changed = true;\n }\n });\n\n const initialCount = this.toasts.length;\n this.toasts = this.toasts.filter(t => t.remainingTime > 0);\n\n if (changed || this.toasts.length !== initialCount) {\n this.notify();\n }\n }\n\n setPaused(id: number, paused: boolean) {\n const toast = this.toasts.find(t => t.id === id);\n if (toast) {\n toast.isPaused = paused;\n this.notify();\n }\n }\n\n remove(id: number) {\n this.toasts = this.toasts.filter(t => t.id !== id);\n this.notify();\n }\n\n private notify() {\n this.toastSubject.next([...this.toasts]);\n }\n}\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "toaster.component.ts",
|
|
17
17
|
"target": "ui/toaster.component.ts",
|
|
18
|
-
"content": "// projects/tolle/src/lib/toast-container.component.ts\nimport { Component, inject, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ToastService, Toast, ToastPosition } from './toast.service';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-toaster',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"cn('fixed z-[
|
|
18
|
+
"content": "// projects/tolle/src/lib/toast-container.component.ts\nimport { Component, ElementRef, OnDestroy, OnInit, inject, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ToastService, Toast, ToastPosition, ToastVariant } from './toast.service';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-toaster',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"cn('fixed z-[10000] pointer-events-none flex flex-col gap-2 w-full max-w-[380px] p-4', positionClasses)\">\n <div\n *ngFor=\"let toast of toasts$ | async\"\n role=\"status\"\n [attr.aria-live]=\"toast.variant === 'destructive' ? 'assertive' : 'polite'\"\n aria-atomic=\"true\"\n (mouseenter)=\"toastService.setPaused(toast.id, true)\"\n (mouseleave)=\"toastService.setPaused(toast.id, false)\"\n [class]=\"cn(\n 'pointer-events-auto relative overflow-hidden p-4 rounded-md border shadow-lg flex items-start justify-between gap-4 transition-all duration-300',\n 'bg-background text-foreground',\n getVariantClasses(toast.variant)\n )\"\n >\n <!-- Opaque surface above, variant tint layered on top of it. -->\n <div aria-hidden=\"true\" [class]=\"cn('absolute inset-0 pointer-events-none', getTintClasses(toast.variant))\"></div>\n\n <div class=\"relative flex gap-3\">\n <i *ngIf=\"toast.variant && icons[toast.variant]\" [class]=\"cn('text-lg', icons[toast.variant])\"></i>\n \n <div class=\"grid gap-1\">\n <div *ngIf=\"toast.title\" class=\"text-sm font-semibold leading-none\">{{ toast.title }}</div>\n <div class=\"text-xs opacity-90 leading-relaxed\">{{ toast.description }}</div>\n </div>\n </div>\n\n <button type=\"button\" aria-label=\"Close\" (click)=\"toastService.remove(toast.id)\" class=\"relative opacity-50 hover:opacity-100 transition-opacity\">\n <span class=\"sr-only\">Close</span>\n <i class=\"ri-close-line text-lg\"></i>\n </button>\n\n <div\n aria-hidden=\"true\"\n class=\"absolute bottom-0 left-0 h-1 transition-all duration-100 ease-linear z-10\"\n [style.width.%]=\"(toast.remainingTime / (toast.duration || 3000)) * 100\"\n [class]=\"getProgressClasses(toast.variant)\"\n ></div>\n </div>\n </div>\n `,\n styles: [':host { display: contents; }']\n})\nexport class ToastContainerComponent implements OnInit, OnDestroy {\n toastService = inject(ToastService);\n private elementRef: ElementRef<HTMLElement> = inject(ElementRef);\n @Input() position: ToastPosition = 'bottom-right';\n toasts$ = this.toastService.toasts$;\n\n /**\n * Move the host to <body> so the fixed container is never clipped or\n * z-index-clamped by a consumer ancestor that creates a stacking context\n * (transform / filter / backdrop-blur / opacity). Being a direct child of\n * <body> and sitting above the CDK overlay container keeps toasts on top\n * of modals, sheets and alert dialogs.\n */\n ngOnInit() {\n document.body.appendChild(this.elementRef.nativeElement);\n }\n\n ngOnDestroy() {\n this.elementRef.nativeElement.remove();\n }\n\n icons: Record<ToastVariant, string> = {\n default: 'ri-information-line text-primary',\n destructive: 'ri-error-warning-line text-destructive',\n success: 'ri-checkbox-circle-line text-success',\n warning: 'ri-alert-line text-warning',\n info: 'ri-information-line text-info'\n };\n\n /**\n * Border + text only. The toast root always carries an opaque `bg-background`;\n * the variant colour is applied as a separate tint layer (see `getTintClasses`)\n * so toasts are never see-through over page content.\n */\n private variantClasses: Record<ToastVariant, string> = {\n default: 'border-border',\n destructive: 'border-destructive/50 text-destructive',\n success: 'border-success/50 text-success',\n warning: 'border-warning/50 text-warning',\n info: 'border-info/50 text-info'\n };\n\n private tintClasses: Record<ToastVariant, string> = {\n default: '',\n destructive: 'bg-destructive/10',\n success: 'bg-success/10',\n warning: 'bg-warning/10',\n info: 'bg-info/10'\n };\n\n private progressClasses: Record<ToastVariant, string> = {\n default: 'bg-primary',\n destructive: 'bg-destructive',\n success: 'bg-success',\n warning: 'bg-warning',\n info: 'bg-info'\n };\n\n private resolve(variant: string | undefined): ToastVariant {\n return variant && variant in this.variantClasses ? (variant as ToastVariant) : 'default';\n }\n\n getVariantClasses(variant?: string) {\n return this.variantClasses[this.resolve(variant)];\n }\n\n getTintClasses(variant?: string) {\n return this.tintClasses[this.resolve(variant)];\n }\n\n getProgressClasses(variant?: string) {\n return this.progressClasses[this.resolve(variant)];\n }\n\n get positionClasses() {\n const pos = {\n 'top-right': 'top-0 right-0 flex-col-reverse',\n 'top-left': 'top-0 left-0 flex-col-reverse',\n 'bottom-right': 'bottom-0 right-0',\n 'bottom-left': 'bottom-0 left-0',\n 'top-center': 'top-0 left-1/2 -translate-x-1/2 flex-col-reverse',\n 'bottom-center': 'bottom-0 left-1/2 -translate-x-1/2'\n };\n return pos[this.position];\n }\n protected cn = cn;\n}\n"
|
|
19
19
|
},
|
|
20
20
|
{
|
|
21
21
|
"path": "utils/cn.ts",
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
{
|
|
15
15
|
"path": "toggle-group.component.ts",
|
|
16
16
|
"target": "ui/toggle-group.component.ts",
|
|
17
|
-
"content": "import { Component, Input, Output, EventEmitter, forwardRef, ContentChildren, QueryList, AfterContentInit, AfterViewInit, inject, ChangeDetectorRef, ElementRef, HostBinding } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { ToggleComponent } from './toggle.component';\n\n@Component({\n selector: 'tolle-toggle-group',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ToggleGroupComponent),\n multi: true\n }\n ],\n host: {\n '[class]': 'computedClass',\n 'role': 'group',\n '(keydown)': 'onKeydown($event)',\n '(focusin)': 'onFocusIn($event)'\n },\n styles: [`\n :host {\n display: inline-flex;\n }\n\n /* 1. First Item: Remove right rounding */\n :host ::ng-deep tolle-toggle-group-item:first-child tolle-toggle button {\n @apply rounded-r-none;\n }\n\n /* 2. Middle Items: Remove all rounding and left border */\n :host ::ng-deep tolle-toggle-group-item:not(:first-child):not(:last-child) tolle-toggle button {\n @apply rounded-none border-l-0;\n }\n\n /* 3. Last Item: Remove left rounding and left border */\n :host ::ng-deep tolle-toggle-group-item:last-child:not(:first-child) tolle-toggle button {\n @apply rounded-l-none border-l-0;\n }\n\n /* 4. Hover/Focus State: Higher z-index to show borders/rings correctly */\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button:hover,\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button:focus,\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button[data-state=on] {\n @apply z-10 relative;\n }\n `]\n})\nexport class ToggleGroupComponent implements AfterContentInit, AfterViewInit, ControlValueAccessor {\n @Input() type: 'single' | 'multiple' = 'single';\n @Input() variant: 'default' | 'outline' = 'default';\n @Input() size: 'default' | 'sm' | 'lg' = 'default';\n @Input() class: string = '';\n @Input() disabled: boolean = false;\n\n @Output() valueChange = new EventEmitter<any>();\n\n @ContentChildren(forwardRef(() => ToggleGroupItemComponent), { descendants: true }) items!: QueryList<ToggleGroupItemComponent>;\n\n private _value: any;\n private cdr = inject(ChangeDetectorRef);\n private host = inject(ElementRef<HTMLElement>);\n\n /** Index (into the full button list) of the current roving tab stop. */\n private activeIndex = 0;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n get value() { return this._value; }\n set value(v: any) {\n if (v !== this._value) {\n this._value = v;\n this.updateItemsState();\n this.onChange(v);\n this.valueChange.emit(v);\n }\n }\n\n ngAfterContentInit() {\n this.updateItemsState();\n this.items.changes.subscribe(() => {\n this.updateItemsState();\n this.cdr.markForCheck();\n // Re-apply roving tabindex once the new toggle views have rendered.\n Promise.resolve().then(() => this.applyRovingTabindex());\n });\n }\n\n ngAfterViewInit() {\n this.applyRovingTabindex();\n }\n\n get computedClass() {\n return cn(\"inline-flex items-center justify-center -space-x-px rounded-md\", this.class);\n }\n\n /** All toggle button elements in DOM order. */\n private get toggleButtons(): HTMLButtonElement[] {\n return Array.from(\n this.host.nativeElement.querySelectorAll('tolle-toggle button')\n ) as HTMLButtonElement[];\n }\n\n /**\n * Roving tabindex: exactly one toggle button is tab-reachable at a time.\n * Defaults to the current active button, falling back to the first enabled.\n */\n private applyRovingTabindex() {\n const btns = this.toggleButtons;\n if (!btns.length) return;\n\n let active = this.activeIndex;\n if (active < 0 || active >= btns.length || btns[active].disabled) {\n active = btns.findIndex(b => !b.disabled);\n if (active < 0) active = 0;\n }\n this.activeIndex = active;\n btns.forEach((b, i) => { b.tabIndex = i === active ? 0 : -1; });\n }\n\n /**\n * Toolbar keyboard nav: Arrow Left/Right (and Home/End) move focus between\n * toggle buttons without activating them (toggles activate on Enter/Space/click).\n */\n onKeydown(event: KeyboardEvent) {\n const key = event.key;\n if (key !== 'ArrowRight' && key !== 'ArrowLeft' && key !== 'Home' && key !== 'End') return;\n\n const btns = this.toggleButtons;\n const enabled = btns.map((b, i) => ({ b, i })).filter(x => !x.b.disabled);\n if (!enabled.length) return;\n\n event.preventDefault();\n\n const active = (typeof document !== 'undefined' ? document.activeElement : null) as HTMLButtonElement | null;\n const focusedIndex = active ? btns.indexOf(active) : -1;\n let pos = enabled.findIndex(x => x.i === focusedIndex);\n\n let targetPos: number;\n if (key === 'Home') {\n targetPos = 0;\n } else if (key === 'End') {\n targetPos = enabled.length - 1;\n } else if (key === 'ArrowRight') {\n targetPos = pos < 0 ? 0 : (pos + 1) % enabled.length;\n } else {\n targetPos = pos < 0 ? enabled.length - 1 : (pos - 1 + enabled.length) % enabled.length;\n }\n\n // Focusing the button triggers focusin, which updates the roving tab stop.\n enabled[targetPos].b.focus();\n }\n\n /** Keep the roving tab stop in sync when a button gains focus (tab/click). */\n onFocusIn(event: FocusEvent) {\n const btns = this.toggleButtons;\n const idx = btns.indexOf(event.target as HTMLButtonElement);\n if (idx < 0) return;\n this.activeIndex = idx;\n btns.forEach((b, i) => { b.tabIndex = i === idx ? 0 : -1; });\n }\n\n writeValue(value: any): void {\n this._value = value;\n this.updateItemsState();\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.updateItemsState();\n }\n\n toggleItem(itemValue: string) {\n if (this.disabled) return;\n\n if (this.type === 'single') {\n this.value = this.value === itemValue ? null : itemValue;\n } else {\n const current = Array.isArray(this.value) ? [...this.value] : [];\n const index = current.indexOf(itemValue);\n if (index > -1) {\n current.splice(index, 1);\n } else {\n current.push(itemValue);\n }\n this.value = current;\n }\n }\n\n private updateItemsState() {\n if (!this.items) return;\n this.items.forEach(item => {\n if (this.type === 'single') {\n item.pressed = this.value === item.value;\n } else {\n item.pressed = Array.isArray(this.value) && this.value.includes(item.value);\n }\n item.variant = this.variant;\n item.size = this.size;\n item.disabled = this.disabled || item.disabled;\n item.cdr.markForCheck();\n });\n }\n}\n\n@Component({\n selector: 'tolle-toggle-group-item',\n standalone: true,\n imports: [CommonModule, ToggleComponent],\n template: `\n <tolle-toggle\n [pressed]=\"pressed\"\n [variant]=\"variant\"\n [size]=\"size\"\n [disabled]=\"disabled\"\n (pressedChange)=\"onToggle()\"\n [class]=\"class\"\n >\n <ng-content></ng-content>\n </tolle-toggle>\n `\n})\nexport class ToggleGroupItemComponent {\n @Input({ required: true }) value!: string;\n @Input() class: string = '';\n @Input() disabled: boolean = false;\n\n pressed = false;\n variant: 'default' | 'outline' = 'default';\n size: 'default' | 'sm' | 'lg' = 'default';\n\n private group = inject(ToggleGroupComponent);\n public cdr = inject(ChangeDetectorRef);\n\n onToggle() {\n this.group.toggleItem(this.value);\n }\n}\n"
|
|
17
|
+
"content": "import { Component, Input, Output, EventEmitter, forwardRef, ContentChildren, QueryList, AfterContentInit, AfterViewInit, inject, ChangeDetectorRef, ElementRef, HostBinding } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { ToggleComponent } from './toggle.component';\n\n@Component({\n selector: 'tolle-toggle-group',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ToggleGroupComponent),\n multi: true\n }\n ],\n host: {\n '[class]': 'computedClass',\n 'role': 'group',\n '(keydown)': 'onKeydown($event)',\n '(focusin)': 'onFocusIn($event)'\n },\n styles: [`\n :host {\n display: inline-flex;\n }\n\n /* 1. First Item: Remove right rounding */\n :host ::ng-deep tolle-toggle-group-item:first-child tolle-toggle button {\n @apply rounded-r-none;\n }\n\n /* 2. Middle Items: Remove all rounding and left border */\n :host ::ng-deep tolle-toggle-group-item:not(:first-child):not(:last-child) tolle-toggle button {\n @apply rounded-none border-l-0;\n }\n\n /* 3. Last Item: Remove left rounding and left border */\n :host ::ng-deep tolle-toggle-group-item:last-child:not(:first-child) tolle-toggle button {\n @apply rounded-l-none border-l-0;\n }\n\n /* 4. Hover/Focus State: Higher z-index to show borders/rings correctly */\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button:hover,\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button:focus,\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button[data-state=on] {\n @apply z-10 relative;\n }\n `]\n})\nexport class ToggleGroupComponent implements AfterContentInit, AfterViewInit, ControlValueAccessor {\n @Input() type: 'single' | 'multiple' = 'single';\n @Input() variant: 'default' | 'outline' = 'default';\n @Input() size: 'default' | 'sm' | 'lg' = 'default';\n @Input() class: string = '';\n @Input() disabled: boolean = false;\n\n @Output() valueChange = new EventEmitter<any>();\n\n @ContentChildren(forwardRef(() => ToggleGroupItemComponent), { descendants: true }) items!: QueryList<ToggleGroupItemComponent>;\n\n private _value: any;\n private cdr = inject(ChangeDetectorRef);\n private host = inject(ElementRef<HTMLElement>);\n\n /** Index (into the full button list) of the current roving tab stop. */\n private activeIndex = 0;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n get value() { return this._value; }\n set value(v: any) {\n if (v !== this._value) {\n this._value = v;\n this.updateItemsState();\n this.onChange(v);\n this.valueChange.emit(v);\n }\n }\n\n ngAfterContentInit() {\n this.updateItemsState();\n this.items.changes.subscribe(() => {\n this.updateItemsState();\n this.cdr.markForCheck();\n // Re-apply roving tabindex once the new toggle views have rendered.\n Promise.resolve().then(() => this.applyRovingTabindex());\n });\n }\n\n ngAfterViewInit() {\n this.applyRovingTabindex();\n }\n\n get computedClass() {\n return cn(\"inline-flex items-center justify-center -space-x-px rounded-md\", this.class);\n }\n\n /** All toggle button elements in DOM order. */\n private get toggleButtons(): HTMLButtonElement[] {\n return Array.from(\n this.host.nativeElement.querySelectorAll('tolle-toggle button')\n ) as HTMLButtonElement[];\n }\n\n /**\n * Roving tabindex: exactly one toggle button is tab-reachable at a time.\n * Defaults to the current active button, falling back to the first enabled.\n */\n private applyRovingTabindex() {\n const btns = this.toggleButtons;\n if (!btns.length) return;\n\n let active = this.activeIndex;\n if (active < 0 || active >= btns.length || btns[active].disabled) {\n active = btns.findIndex(b => !b.disabled);\n if (active < 0) active = 0;\n }\n this.activeIndex = active;\n btns.forEach((b, i) => { b.tabIndex = i === active ? 0 : -1; });\n }\n\n /**\n * Toolbar keyboard nav: Arrow Left/Right (and Home/End) move focus between\n * toggle buttons without activating them (toggles activate on Enter/Space/click).\n */\n onKeydown(event: KeyboardEvent) {\n const key = event.key;\n if (key !== 'ArrowRight' && key !== 'ArrowLeft' && key !== 'Home' && key !== 'End') return;\n\n const btns = this.toggleButtons;\n const enabled = btns.map((b, i) => ({ b, i })).filter(x => !x.b.disabled);\n if (!enabled.length) return;\n\n event.preventDefault();\n\n const active = (typeof document !== 'undefined' ? document.activeElement : null) as HTMLButtonElement | null;\n const focusedIndex = active ? btns.indexOf(active) : -1;\n let pos = enabled.findIndex(x => x.i === focusedIndex);\n\n let targetPos: number;\n if (key === 'Home') {\n targetPos = 0;\n } else if (key === 'End') {\n targetPos = enabled.length - 1;\n } else if (key === 'ArrowRight') {\n targetPos = pos < 0 ? 0 : (pos + 1) % enabled.length;\n } else {\n targetPos = pos < 0 ? enabled.length - 1 : (pos - 1 + enabled.length) % enabled.length;\n }\n\n // Focusing the button triggers focusin, which updates the roving tab stop.\n enabled[targetPos].b.focus();\n }\n\n /** Keep the roving tab stop in sync when a button gains focus (tab/click). */\n onFocusIn(event: FocusEvent) {\n const btns = this.toggleButtons;\n const idx = btns.indexOf(event.target as HTMLButtonElement);\n if (idx < 0) return;\n this.activeIndex = idx;\n btns.forEach((b, i) => { b.tabIndex = i === idx ? 0 : -1; });\n }\n\n writeValue(value: any): void {\n this._value = value;\n this.updateItemsState();\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.updateItemsState();\n }\n\n toggleItem(itemValue: string) {\n if (this.disabled) return;\n\n if (this.type === 'single') {\n this.value = this.value === itemValue ? null : itemValue;\n } else {\n const current = Array.isArray(this.value) ? [...this.value] : [];\n const index = current.indexOf(itemValue);\n if (index > -1) {\n current.splice(index, 1);\n } else {\n current.push(itemValue);\n }\n this.value = current;\n }\n }\n\n private updateItemsState() {\n if (!this.items) return;\n this.items.forEach(item => {\n if (this.type === 'single') {\n item.pressed = this.value === item.value;\n } else {\n item.pressed = Array.isArray(this.value) && this.value.includes(item.value);\n }\n item.variant = this.variant;\n item.size = this.size;\n item.disabled = this.disabled || item.disabled;\n item.cdr.markForCheck();\n });\n }\n}\n\n@Component({\n selector: 'tolle-toggle-group-item',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule, ToggleComponent],\n template: `\n <tolle-toggle\n [pressed]=\"pressed\"\n [variant]=\"variant\"\n [size]=\"size\"\n [disabled]=\"disabled\"\n (pressedChange)=\"onToggle()\"\n [class]=\"class\"\n >\n <ng-content></ng-content>\n </tolle-toggle>\n `\n})\nexport class ToggleGroupItemComponent {\n @Input({ required: true }) value!: string;\n @Input() class: string = '';\n @Input() disabled: boolean = false;\n\n pressed = false;\n variant: 'default' | 'outline' = 'default';\n size: 'default' | 'sm' | 'lg' = 'default';\n\n private group = inject(ToggleGroupComponent);\n public cdr = inject(ChangeDetectorRef);\n\n onToggle() {\n this.group.toggleItem(this.value);\n }\n}\n"
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"path": "utils/cn.ts",
|
package/registry/r/toggle.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
{
|
|
13
13
|
"path": "toggle.component.ts",
|
|
14
14
|
"target": "ui/toggle.component.ts",
|
|
15
|
-
"content": "import { Component, Input, Output, EventEmitter, forwardRef } 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 toggleVariants = cva(\n \"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground\",\n {\n variants: {\n variant: {\n default: \"bg-transparent\",\n outline: \"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground\",\n },\n size: {\n default: \"h-10 px-3\",\n sm: \"h-9 px-2.5\",\n lg: \"h-11 px-5\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n);\n\nexport type ToggleProps = VariantProps<typeof toggleVariants>;\n\n@Component({\n selector: 'tolle-toggle',\n standalone: true,\n imports: [CommonModule],\n template: `\n <button\n type=\"button\"\n [class]=\"computedClass\"\n [disabled]=\"disabled\"\n [attr.aria-pressed]=\"pressed\"\n [attr.data-state]=\"pressed ? 'on' : 'off'\"\n (click)=\"toggle()\"\n >\n <ng-content></ng-content>\n </button>\n `,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ToggleComponent),\n multi: true\n }\n ]\n})\nexport class ToggleComponent implements ControlValueAccessor {\n @Input() variant: ToggleProps['variant'] = 'default';\n @Input() size: ToggleProps['size'] = 'default';\n @Input() disabled: boolean = false;\n @Input() class: string = '';\n\n @Input() set pressed(val: boolean) {\n this._pressed = val;\n this.onChange(val);\n }\n get pressed() { return this._pressed; }\n private _pressed = false;\n\n @Output() pressedChange = new EventEmitter<boolean>();\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n get computedClass() {\n return cn(\n toggleVariants({\n variant: this.variant,\n size: this.size,\n }),\n this.class\n );\n }\n\n toggle() {\n if (this.disabled) return;\n this.pressed = !this.pressed;\n this.pressedChange.emit(this.pressed);\n this.onTouched();\n }\n\n writeValue(value: any): void {\n this._pressed = !!value;\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState?(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
|
|
15
|
+
"content": "import { Component, Input, Output, EventEmitter, forwardRef } 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 toggleVariants = cva(\n \"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground\",\n {\n variants: {\n variant: {\n default: \"bg-transparent\",\n outline: \"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground\",\n },\n size: {\n default: \"h-10 px-3\",\n sm: \"h-9 px-2.5\",\n lg: \"h-11 px-5\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n);\n\nexport type ToggleProps = VariantProps<typeof toggleVariants>;\n\n@Component({\n selector: 'tolle-toggle',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <button\n type=\"button\"\n [class]=\"computedClass\"\n [disabled]=\"disabled\"\n [attr.aria-pressed]=\"pressed\"\n [attr.data-state]=\"pressed ? 'on' : 'off'\"\n (click)=\"toggle()\"\n >\n <ng-content></ng-content>\n </button>\n `,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ToggleComponent),\n multi: true\n }\n ]\n})\nexport class ToggleComponent implements ControlValueAccessor {\n @Input() variant: ToggleProps['variant'] = 'default';\n @Input() size: ToggleProps['size'] = 'default';\n @Input() disabled: boolean = false;\n @Input() class: string = '';\n\n @Input() set pressed(val: boolean) {\n this._pressed = val;\n this.onChange(val);\n }\n get pressed() { return this._pressed; }\n private _pressed = false;\n\n @Output() pressedChange = new EventEmitter<boolean>();\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n get computedClass() {\n return cn(\n toggleVariants({\n variant: this.variant,\n size: this.size,\n }),\n this.class\n );\n }\n\n toggle() {\n if (this.disabled) return;\n this.pressed = !this.pressed;\n this.pressedChange.emit(this.pressed);\n this.onTouched();\n }\n\n writeValue(value: any): void {\n this._pressed = !!value;\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState?(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
"path": "utils/cn.ts",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tool",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Tool",
|
|
5
|
+
"description": "Tool component.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "tool.component.ts",
|
|
14
|
+
"target": "ui/tool.component.ts",
|
|
15
|
+
"content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, 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\nconst toolStateVariants = cva(\n 'inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',\n {\n variants: {\n state: {\n pending: 'bg-muted text-muted-foreground',\n running: 'bg-info/10 text-info',\n success: 'bg-success/10 text-success',\n error: 'bg-destructive/10 text-destructive',\n },\n },\n defaultVariants: {\n state: 'pending',\n },\n }\n);\n\nexport type ToolStateProps = VariantProps<typeof toolStateVariants>;\n\n/** Lifecycle of a tool call. */\nexport type ToolState = NonNullable<ToolStateProps['state']>;\n\n/** Remixicon class shown in the state chip for each state. */\nconst TOOL_STATE_ICONS: Record<ToolState, string> = {\n pending: 'ri-time-line',\n running: 'ri-loader-4-line animate-spin',\n success: 'ri-check-line',\n error: 'ri-error-warning-line',\n};\n\n/** Default chip text for each state. */\nconst TOOL_STATE_LABELS: Record<ToolState, string> = {\n pending: 'Pending',\n running: 'Running',\n success: 'Completed',\n error: 'Error',\n};\n\n/**\n * Coordinates a `tolle-tool` with its header, input and output.\n *\n * Header/input/output are OnPush siblings projected into the tool, so the\n * shared open + state they render from is published here.\n *\n * Provided on `ToolComponent`, so each tool call gets its own instance.\n */\n@Injectable()\nexport class ToolService {\n private readonly openSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the tool call is expanded. */\n readonly open$ = this.openSource.asObservable();\n\n private readonly stateSource = new BehaviorSubject<ToolState>('pending');\n /** Emits the tool call's lifecycle state. */\n readonly state$ = this.stateSource.asObservable();\n\n /** True while the tool call is expanded. */\n get open(): boolean {\n return this.openSource.value;\n }\n\n /** The tool call's current state. */\n get state(): ToolState {\n return this.stateSource.value;\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 setState(state: ToolState): void {\n if (this.stateSource.value !== state) this.stateSource.next(state);\n }\n}\n\nconst toolVariants = cva('w-full overflow-hidden rounded-md border bg-card text-card-foreground', {\n variants: {\n state: {\n pending: 'border-border',\n running: 'border-info/40',\n success: 'border-border',\n error: 'border-destructive/40',\n },\n },\n defaultVariants: {\n state: 'pending',\n },\n});\n\nexport type ToolProps = VariantProps<typeof toolVariants>;\n\n/**\n * A collapsible record of one tool/function call: what was invoked, the\n * arguments it received and the result it returned.\n *\n * Compose it from `tolle-tool-header`, `tolle-tool-input` and\n * `tolle-tool-output`.\n * @new\n */\n@Component({\n selector: 'tolle-tool',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [ToolService],\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 ToolComponent implements OnInit, OnChanges, OnDestroy {\n /** Lifecycle of the call, driving the header chip and border. @default 'pending' */\n @Input() state: ToolState = 'pending';\n /** Whether the call's input and output are expanded. @default false */\n @Input() open = false;\n /** Extra Tailwind classes merged onto the tool via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new state whenever the call is expanded or collapsed. */\n @Output() openChange = new EventEmitter<boolean>();\n\n private readonly service = inject(ToolService);\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 this.service.setState(this.state);\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 this.service.setState(this.state);\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(toolVariants({ state: this.state }), this.class);\n }\n}\n\n/** Clickable header of a `tolle-tool`: the tool's name plus its state chip. */\n@Component({\n selector: 'tolle-tool-header',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n data-tool-header\n [attr.aria-expanded]=\"open\"\n [attr.data-state]=\"open ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onToggle()\"\n >\n <i [class]=\"icon\" class=\"shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n\n <span class=\"truncate font-medium text-foreground\" data-tool-name>{{ name }}</span>\n\n <span [class]=\"chipClass\" data-tool-state>\n <i [class]=\"stateIcon\" aria-hidden=\"true\"></i>\n {{ stateLabel }}\n </span>\n\n <i\n class=\"ri-arrow-down-s-line shrink-0 text-muted-foreground 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 ToolHeaderComponent 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 /** Name of the tool that was called. @default '' */\n @Input() name = '';\n /** Remixicon class shown before the tool name. @default 'ri-tools-line' */\n @Input() icon = 'ri-tools-line';\n /** Overrides the chip text; defaults to a label derived from the state. @default '' */\n @Input() stateLabelOverride = '';\n /** Extra Tailwind classes merged onto the header via `cn()` (last-wins). */\n @Input() class = '';\n /** Extra Tailwind classes merged onto the state chip via `cn()` (last-wins). */\n @Input() chipClassName = '';\n\n private readonly service = inject(ToolService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n state: ToolState = 'pending';\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.state$.subscribe((state) => {\n this.state = state;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get stateIcon(): string {\n return TOOL_STATE_ICONS[this.state];\n }\n\n get stateLabel(): string {\n return this.stateLabelOverride || TOOL_STATE_LABELS[this.state];\n }\n\n get chipClass() {\n return cn(toolStateVariants({ state: this.state }), this.chipClassName);\n }\n\n get computedClass() {\n return cn(\n 'flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors',\n 'hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',\n this.class\n );\n }\n\n onToggle(): void {\n this.service.toggle();\n }\n}\n\n/**\n * Base for the two payload panels. Owns the shared \"visible only while the\n * tool is expanded\" wiring and the payload-to-text formatting.\n *\n * Decorated with a selector-less `@Directive()` because it uses Angular\n * features (DI and lifecycle hooks) on behalf of its subclasses.\n */\n@Directive()\nexport abstract class ToolPayloadBase 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 subclasses keep\n * rendering the class they were born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Payload to render; objects are pretty-printed as JSON, strings are shown as-is. */\n payload: unknown = null;\n\n protected readonly service = inject(ToolService);\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 /** True once there is a payload worth rendering. */\n get hasPayload(): boolean {\n return this.payload !== null && this.payload !== undefined && this.payload !== '';\n }\n\n /** The payload as display text. Falls back to `String()` for cyclic values. */\n get formatted(): string {\n if (typeof this.payload === 'string') return this.payload;\n try {\n return JSON.stringify(this.payload, null, 2) ?? String(this.payload);\n } catch {\n return String(this.payload);\n }\n }\n}\n\n/** Arguments the tool was called with. Visible while the `tolle-tool` is expanded. */\n@Component({\n selector: 'tolle-tool-input',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div *ngIf=\"open\" data-tool-input [class]=\"computedClass\">\n <p *ngIf=\"label\" class=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">\n {{ label }}\n </p>\n\n <pre\n *ngIf=\"hasPayload; else projected\"\n class=\"max-h-64 overflow-auto rounded-md bg-muted p-3 font-mono text-xs text-foreground\"\n >{{ formatted }}</pre>\n\n <ng-template #projected>\n <div class=\"max-h-64 overflow-auto rounded-md bg-muted p-3 font-mono text-xs text-foreground\">\n <ng-content></ng-content>\n </div>\n </ng-template>\n </div>\n `,\n})\nexport class ToolInputComponent extends ToolPayloadBase {\n /** Arguments passed to the tool; objects are pretty-printed as JSON. @default null */\n @Input() override payload: unknown = null;\n /** Heading above the payload. @default 'Parameters' */\n @Input() label = 'Parameters';\n /** Extra Tailwind classes merged onto the panel via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn('flex flex-col gap-1.5 border-t border-border px-3 py-2', this.class);\n }\n}\n\n/** Result the tool returned. Visible while the `tolle-tool` is expanded. */\n@Component({\n selector: 'tolle-tool-output',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div *ngIf=\"open\" data-tool-output [class]=\"computedClass\">\n <p *ngIf=\"label\" class=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">\n {{ label }}\n </p>\n\n <pre *ngIf=\"hasPayload; else projected\" [class]=\"payloadClass\">{{ formatted }}</pre>\n\n <ng-template #projected>\n <div [class]=\"payloadClass\">\n <ng-content></ng-content>\n </div>\n </ng-template>\n </div>\n `,\n})\nexport class ToolOutputComponent extends ToolPayloadBase {\n /** Result returned by the tool; objects are pretty-printed as JSON. @default null */\n @Input() override payload: unknown = null;\n /** Heading above the payload. @default 'Result' */\n @Input() label = 'Result';\n /** Styles the payload as a failure. @default false */\n @Input() error = false;\n /** Extra Tailwind classes merged onto the panel via `cn()` (last-wins). */\n @Input() class = '';\n\n get payloadClass() {\n return cn(\n 'max-h-64 overflow-auto rounded-md bg-muted p-3 font-mono text-xs text-foreground',\n this.error && 'bg-destructive/10 text-destructive'\n );\n }\n\n get computedClass() {\n return cn('flex flex-col gap-1.5 border-t border-border px-3 py-2', 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
|
+
}
|