@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": "reasoning",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Reasoning",
|
|
5
|
+
"description": "Reasoning component with size options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "reasoning.component.ts",
|
|
14
|
+
"target": "ui/reasoning.component.ts",
|
|
15
|
+
"content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Injectable, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { skip } from 'rxjs/operators';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/**\n * Coordinates a `tolle-reasoning` with its trigger and content.\n *\n * The trigger and content are OnPush children, so the open/streaming/duration\n * state they render from is published here rather than read off the parent.\n *\n * Provided on `ReasoningComponent`, so each trace gets its own instance.\n */\n@Injectable()\nexport class ReasoningService {\n private readonly openSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the trace is expanded. */\n readonly open$ = this.openSource.asObservable();\n\n private readonly streamingSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the model is still producing the trace. */\n readonly streaming$ = this.streamingSource.asObservable();\n\n private readonly durationSource = new BehaviorSubject<number>(0);\n /** Emits how long the model spent reasoning, in seconds. */\n readonly duration$ = this.durationSource.asObservable();\n\n /** True while the trace is expanded. */\n get open(): boolean {\n return this.openSource.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 setStreaming(value: boolean): void {\n if (this.streamingSource.value !== value) this.streamingSource.next(value);\n }\n\n setDuration(value: number): void {\n if (this.durationSource.value !== value) this.durationSource.next(value);\n }\n}\n\nconst reasoningVariants = cva('flex w-full flex-col gap-2', {\n variants: {\n size: {\n sm: 'text-xs',\n default: 'text-sm',\n lg: 'text-base',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n});\n\nexport type ReasoningProps = VariantProps<typeof reasoningVariants>;\n\n/**\n * A collapsible view of the model's reasoning, collapsed by default.\n *\n * While `streaming` is true the trigger reads \"Thinking…\"; once it finishes the\n * trigger reports how long the model thought, using `duration`.\n *\n * Compose it from `tolle-reasoning-trigger` and `tolle-reasoning-content`.\n * @new\n */\n@Component({\n selector: 'tolle-reasoning',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [ReasoningService],\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 ReasoningComponent implements OnInit, OnChanges, OnDestroy {\n /** Whether the trace is expanded. @default false */\n @Input() open = false;\n /** True while the model is still producing the trace. @default false */\n @Input() streaming = false;\n /** Seconds the model spent reasoning, shown once streaming finishes. @default 0 */\n @Input() duration = 0;\n /** Text scale of the trace. @default 'default' */\n @Input() size: ReasoningProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the trace via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new state whenever the trace is expanded or collapsed. */\n @Output() openChange = new EventEmitter<boolean>();\n\n private readonly service = inject(ReasoningService);\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.sync();\n // `skip(1)` drops the BehaviorSubject's current value so simply mounting\n // the component never 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.setStreaming(this.streaming);\n this.service.setDuration(this.duration);\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(reasoningVariants({ size: this.size }), this.class);\n }\n\n private sync(): void {\n this.service.setOpen(this.open);\n this.service.setStreaming(this.streaming);\n this.service.setDuration(this.duration);\n }\n}\n\n/**\n * Toggle for a `tolle-reasoning`. Shows a live \"Thinking…\" label while the\n * trace is streaming and the elapsed thinking time once it is done.\n */\n@Component({\n selector: 'tolle-reasoning-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n data-reasoning-trigger\n [attr.aria-expanded]=\"open\"\n [attr.data-state]=\"open ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onToggle()\"\n >\n <i\n [class]=\"icon\"\n [class.animate-pulse]=\"streaming\"\n aria-hidden=\"true\"\n ></i>\n\n <span data-reasoning-label>{{ label }}</span>\n\n <i\n class=\"ri-arrow-down-s-line transition-transform duration-200\"\n [class.rotate-180]=\"open\"\n aria-hidden=\"true\"\n ></i>\n\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class ReasoningTriggerComponent 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 /** Remixicon class shown before the label. @default 'ri-brain-line' */\n @Input() icon = 'ri-brain-line';\n /** Label shown while the trace is streaming. @default 'Thinking…' */\n @Input() thinkingLabel = 'Thinking…';\n /** Label used when the trace finished without a measured duration. @default 'Thought for a few seconds' */\n @Input() fallbackLabel = 'Thought for a few seconds';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ReasoningService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n streaming = false;\n duration = 0;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.streaming$.subscribe((streaming) => {\n this.streaming = streaming;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.duration$.subscribe((duration) => {\n this.duration = duration;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** \"Thinking…\" while streaming, otherwise how long the model thought. */\n get label(): string {\n if (this.streaming) return this.thinkingLabel;\n if (!this.duration || this.duration < 1) return this.fallbackLabel;\n const rounded = Math.round(this.duration);\n return 'Thought for ' + rounded + (rounded === 1 ? ' second' : ' seconds');\n }\n\n get computedClass() {\n return cn(\n 'flex w-fit items-center gap-1.5 rounded-md px-1.5 py-1 text-muted-foreground transition-colors',\n 'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n this.class\n );\n }\n\n onToggle(): void {\n this.service.toggle();\n }\n}\n\n/** The reasoning text itself. Rendered only while the trace is expanded. */\n@Component({\n selector: 'tolle-reasoning-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div *ngIf=\"open\" data-reasoning-content [class]=\"computedClass\">\n <p *ngIf=\"text\" class=\"whitespace-pre-wrap\">{{ text }}</p>\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ReasoningContentComponent 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 /** Reasoning text to render; project your own markup instead when richer. @default '' */\n @Input() text = '';\n /** Extra Tailwind classes merged onto the content via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ReasoningService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(\n 'border-l-2 border-border pl-4 text-muted-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
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
{
|
|
13
13
|
"path": "resizable-panel.component.ts",
|
|
14
14
|
"target": "ui/resizable-panel.component.ts",
|
|
15
|
-
"content": "import { Component, Input, ContentChildren, QueryList, AfterContentInit, ElementRef, inject, ChangeDetectorRef, OnDestroy, forwardRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport { ResizablePanelItemComponent } from './resizable-panel-item.component';\n\n@Component({\n selector: 'tolle-resizable-panel',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"computedContainerClass\">\n <div class=\"flex\" [class.flex-col]=\"direction === 'vertical'\" [class.flex-row]=\"direction === 'horizontal'\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n styles: [`\n :host {\n display: block;\n }\n `]\n})\nexport class ResizablePanelComponent implements AfterContentInit, OnDestroy {\n @Input() direction: 'horizontal' | 'vertical' = 'horizontal';\n @Input() class: string = '';\n\n // forwardRef breaks the panel <-> item circular import (TDZ at class-decoration time).\n @ContentChildren(forwardRef(() => ResizablePanelItemComponent)) panels!: QueryList<ResizablePanelItemComponent>;\n\n private el = inject(ElementRef);\n private cdr = inject(ChangeDetectorRef);\n\n private isResizing = false;\n private activePanelIndex = -1;\n private startCursorPosition = 0;\n private startPanelSizes: number[] = [];\n\n private mouseMoveListener?: (e: MouseEvent) => void;\n private mouseUpListener?: (e: MouseEvent) => void;\n\n ngAfterContentInit() {\n // Initialize panel sizes if needed\n setTimeout(() => {\n this.updatePanelSizes();\n this.updateLastStatus();\n });\n\n this.panels.changes.subscribe(() => {\n this.updatePanelSizes();\n this.updateLastStatus();\n });\n }\n\n ngOnDestroy() {\n this.cleanupListeners();\n }\n\n private updateLastStatus() {\n const panels = this.panels.toArray();\n panels.forEach((p, i) => p.isLast = i === panels.length - 1);\n }\n\n startResize(item: ResizablePanelItemComponent, event: MouseEvent) {\n const panels = this.panels.toArray();\n this.activePanelIndex = panels.indexOf(item);\n if (this.activePanelIndex === -1 || this.activePanelIndex === panels.length - 1) return;\n\n this.isResizing = true;\n this.startCursorPosition = this.direction === 'horizontal' ? event.clientX : event.clientY;\n this.startPanelSizes = panels.map(p => p.size);\n\n this.setupListeners();\n document.body.style.cursor = this.direction === 'horizontal' ? 'col-resize' : 'row-resize';\n document.body.style.userSelect = 'none';\n }\n\n private setupListeners() {\n this.mouseMoveListener = (e: MouseEvent) => this.onMouseMove(e);\n this.mouseUpListener = () => this.stopResize();\n\n document.addEventListener('mousemove', this.mouseMoveListener);\n document.addEventListener('mouseup', this.mouseUpListener);\n }\n\n private cleanupListeners() {\n if (this.mouseMoveListener) document.removeEventListener('mousemove', this.mouseMoveListener);\n if (this.mouseUpListener) document.removeEventListener('mouseup', this.mouseUpListener);\n }\n\n private onMouseMove(event: MouseEvent) {\n if (!this.isResizing) return;\n\n const currentPos = this.direction === 'horizontal' ? event.clientX : event.clientY;\n const deltaPx = currentPos - this.startCursorPosition;\n\n const containerSize = this.direction === 'horizontal'\n ? this.el.nativeElement.offsetWidth\n : this.el.nativeElement.offsetHeight;\n\n const deltaPercent = (deltaPx / containerSize) * 100;\n\n const panels = this.panels.toArray();\n const panelA = panels[this.activePanelIndex];\n const panelB = panels[this.activePanelIndex + 1];\n\n const newSizeA = Math.max(panelA.minSize, this.startPanelSizes[this.activePanelIndex] + deltaPercent);\n const actualDelta = newSizeA - this.startPanelSizes[this.activePanelIndex];\n const newSizeB = Math.max(panelB.minSize, this.startPanelSizes[this.activePanelIndex + 1] - actualDelta);\n\n panelA.size = newSizeA;\n panelB.size = this.startPanelSizes[this.activePanelIndex] + this.startPanelSizes[this.activePanelIndex + 1] - panelA.size;\n\n if (panelB.size < panelB.minSize) {\n panelB.size = panelB.minSize;\n panelA.size = (this.startPanelSizes[this.activePanelIndex] + this.startPanelSizes[this.activePanelIndex + 1]) - panelB.minSize;\n }\n\n this.cdr.detectChanges();\n }\n\n private stopResize() {\n this.isResizing = false;\n this.cleanupListeners();\n document.body.style.cursor = '';\n document.body.style.userSelect = '';\n }\n\n private updatePanelSizes() {\n const panels = this.panels.toArray();\n const totalPanels = panels.length;\n\n if (totalPanels > 0) {\n const defaultSize = 100 / totalPanels;\n panels.forEach(panel => {\n if (!panel.size) {\n panel.size = defaultSize;\n }\n });\n }\n }\n\n get computedContainerClass() {\n return cn(\n 'w-full h-full',\n this.class\n );\n }\n}\n"
|
|
15
|
+
"content": "import { Component, Input, ContentChildren, QueryList, AfterContentInit, ElementRef, inject, ChangeDetectorRef, OnDestroy, forwardRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport { ResizablePanelItemComponent } from './resizable-panel-item.component';\n\n@Component({\n selector: 'tolle-resizable-panel',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"computedContainerClass\">\n <div class=\"flex h-full w-full\" [class.flex-col]=\"direction === 'vertical'\" [class.flex-row]=\"direction === 'horizontal'\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n styles: [`\n /* Fills its parent by default so consumers don't have to remember a\n manual h-full — the flex row/col wrapper below only has something to\n distribute once this and the container div actually have a height. */\n :host {\n display: block;\n width: 100%;\n height: 100%;\n }\n `]\n})\nexport class ResizablePanelComponent implements AfterContentInit, OnDestroy {\n @Input() direction: 'horizontal' | 'vertical' = 'horizontal';\n @Input() class: string = '';\n\n // forwardRef breaks the panel <-> item circular import (TDZ at class-decoration time).\n @ContentChildren(forwardRef(() => ResizablePanelItemComponent)) panels!: QueryList<ResizablePanelItemComponent>;\n\n private el = inject(ElementRef);\n private cdr = inject(ChangeDetectorRef);\n\n private isResizing = false;\n private activePanelIndex = -1;\n private startCursorPosition = 0;\n private startPanelSizes: number[] = [];\n\n private mouseMoveListener?: (e: MouseEvent) => void;\n private mouseUpListener?: (e: MouseEvent) => void;\n\n ngAfterContentInit() {\n // Initialize panel sizes if needed\n setTimeout(() => {\n this.updatePanelSizes();\n this.updateLastStatus();\n });\n\n this.panels.changes.subscribe(() => {\n this.updatePanelSizes();\n this.updateLastStatus();\n });\n }\n\n ngOnDestroy() {\n this.cleanupListeners();\n }\n\n private updateLastStatus() {\n const panels = this.panels.toArray();\n panels.forEach((p, i) => p.isLast = i === panels.length - 1);\n }\n\n startResize(item: ResizablePanelItemComponent, event: MouseEvent) {\n const panels = this.panels.toArray();\n this.activePanelIndex = panels.indexOf(item);\n if (this.activePanelIndex === -1 || this.activePanelIndex === panels.length - 1) return;\n\n this.isResizing = true;\n this.startCursorPosition = this.direction === 'horizontal' ? event.clientX : event.clientY;\n this.startPanelSizes = panels.map(p => p.size);\n\n this.setupListeners();\n document.body.style.cursor = this.direction === 'horizontal' ? 'col-resize' : 'row-resize';\n document.body.style.userSelect = 'none';\n }\n\n private setupListeners() {\n this.mouseMoveListener = (e: MouseEvent) => this.onMouseMove(e);\n this.mouseUpListener = () => this.stopResize();\n\n document.addEventListener('mousemove', this.mouseMoveListener);\n document.addEventListener('mouseup', this.mouseUpListener);\n }\n\n private cleanupListeners() {\n if (this.mouseMoveListener) document.removeEventListener('mousemove', this.mouseMoveListener);\n if (this.mouseUpListener) document.removeEventListener('mouseup', this.mouseUpListener);\n }\n\n private onMouseMove(event: MouseEvent) {\n if (!this.isResizing) return;\n\n const currentPos = this.direction === 'horizontal' ? event.clientX : event.clientY;\n const deltaPx = currentPos - this.startCursorPosition;\n\n const containerSize = this.direction === 'horizontal'\n ? this.el.nativeElement.offsetWidth\n : this.el.nativeElement.offsetHeight;\n\n const deltaPercent = (deltaPx / containerSize) * 100;\n\n const panels = this.panels.toArray();\n const panelA = panels[this.activePanelIndex];\n const panelB = panels[this.activePanelIndex + 1];\n\n const newSizeA = Math.max(panelA.minSize, this.startPanelSizes[this.activePanelIndex] + deltaPercent);\n const actualDelta = newSizeA - this.startPanelSizes[this.activePanelIndex];\n const newSizeB = Math.max(panelB.minSize, this.startPanelSizes[this.activePanelIndex + 1] - actualDelta);\n\n panelA.size = newSizeA;\n panelB.size = this.startPanelSizes[this.activePanelIndex] + this.startPanelSizes[this.activePanelIndex + 1] - panelA.size;\n\n if (panelB.size < panelB.minSize) {\n panelB.size = panelB.minSize;\n panelA.size = (this.startPanelSizes[this.activePanelIndex] + this.startPanelSizes[this.activePanelIndex + 1]) - panelB.minSize;\n }\n\n this.cdr.detectChanges();\n }\n\n private stopResize() {\n this.isResizing = false;\n this.cleanupListeners();\n document.body.style.cursor = '';\n document.body.style.userSelect = '';\n }\n\n private updatePanelSizes() {\n const panels = this.panels.toArray();\n const totalPanels = panels.length;\n\n if (totalPanels > 0) {\n const defaultSize = 100 / totalPanels;\n panels.forEach(panel => {\n if (!panel.size) {\n panel.size = defaultSize;\n }\n });\n }\n }\n\n get computedContainerClass() {\n return cn(\n 'w-full h-full',\n this.class\n );\n }\n}\n"
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
"path": "utils/cn.ts",
|
package/registry/r/segment.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "segment.component.ts",
|
|
12
12
|
"target": "ui/segment.component.ts",
|
|
13
|
-
"content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChildren,\n QueryList,\n AfterViewInit,\n ChangeDetectorRef,\n OnChanges,\n OnDestroy,\n SimpleChanges,\n TemplateRef,\n ViewChild,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\nexport type SegmentItem = {\n label: string;\n value: any;\n disabled?: boolean;\n icon?: string; // Added: Optional icon class (e.g., 'ri-home-line')\n class?: string; // Added: Custom class for specific item wrapper\n data?: any; // Added: Extra data payload for custom templates\n}\n\n@Component({\n selector: 'tolle-segment',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() =>
|
|
13
|
+
"content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChildren,\n QueryList,\n AfterViewInit,\n ChangeDetectorRef,\n OnChanges,\n OnDestroy,\n SimpleChanges,\n TemplateRef,\n ViewChild,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\nexport type SegmentItem = {\n label: string;\n value: any;\n disabled?: boolean;\n icon?: string; // Added: Optional icon class (e.g., 'ri-home-line')\n class?: string; // Added: Custom class for specific item wrapper\n data?: any; // Added: Extra data payload for custom templates\n}\n\n@Component({\n selector: 'tolle-segment',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SegmentComponent),\n multi: true\n }\n ],\n template: `\n <div\n #container\n [class]=\"cn(\n 'relative flex items-center p-1 bg-muted rounded-lg select-none w-full gap-1',\n class\n )\"\n role=\"radiogroup\"\n (keydown)=\"onKeydown($event)\"\n >\n <div\n class=\"absolute top-1 bottom-1 bg-primary shadow-sm rounded-md transition-all duration-300 ease-tolle\"\n [style.left.px]=\"gliderLeft\"\n [style.width.px]=\"gliderWidth\"\n [class.opacity-0]=\"!hasValue\"\n ></div>\n\n <button\n *ngFor=\"let item of items\"\n #itemEls\n type=\"button\"\n role=\"radio\"\n [disabled]=\"item.disabled || disabled\"\n [attr.aria-checked]=\"value === item.value\"\n [attr.tabindex]=\"getTabIndex(item)\"\n (click)=\"select(item.value)\"\n [class]=\"cn(\n 'relative z-10 flex-1 px-3 py-1.5 text-sm font-medium transition-colors duration-200 rounded-md text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n 'flex items-center justify-center gap-2',\n value === item.value\n ? 'text-primary-foreground'\n : 'text-muted-foreground hover:text-foreground/70',\n item.disabled && 'opacity-50 cursor-not-allowed',\n item.class\n )\"\n >\n <ng-container *ngIf=\"itemTemplate; else defaultContent\">\n <ng-container *ngTemplateOutlet=\"itemTemplate; context: { $implicit: item, selected: value === item.value }\">\n </ng-container>\n </ng-container>\n\n <ng-template #defaultContent>\n <i *ngIf=\"item.icon\" [class]=\"item.icon\"></i>\n <span class=\"truncate\">{{ item.label }}</span>\n </ng-template>\n </button>\n </div>\n `,\n styles: [`\n :host {\n display: block;\n }\n `]\n})\nexport class SegmentComponent implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy {\n @Input() items: SegmentItem[] = [];\n @Input() class = '';\n @Input() disabled = false;\n @Input() itemTemplate?: TemplateRef<any>; // Allow custom content\n\n value: any = null;\n gliderLeft = 0;\n gliderWidth = 0;\n hasValue = false;\n\n @ViewChild('container') containerEl!: ElementRef<HTMLElement>;\n @ViewChildren('itemEls') itemElements!: QueryList<ElementRef<HTMLElement>>;\n\n private resizeObserver?: ResizeObserver;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n ngAfterViewInit() {\n setTimeout(() => this.updateGlider());\n\n if (typeof ResizeObserver !== 'undefined' && this.containerEl) {\n this.resizeObserver = new ResizeObserver(() => {\n this.updateGlider();\n });\n this.resizeObserver.observe(this.containerEl.nativeElement);\n }\n }\n\n ngOnDestroy() {\n if (this.resizeObserver) {\n this.resizeObserver.disconnect();\n }\n }\n\n ngOnChanges(changes: SimpleChanges) {\n // Recalculate if items change or if value changes externally\n if (changes['items'] && !changes['items'].firstChange) {\n setTimeout(() => this.updateGlider());\n }\n }\n\n select(val: any) {\n if (this.disabled) return;\n const item = this.items.find(i => i.value === val);\n if (item?.disabled) return;\n\n this.value = val;\n this.onChange(val);\n this.onTouched();\n this.updateGlider();\n }\n\n /** True when the current value matches one of the items. */\n private hasSelectedValue(): boolean {\n return this.items.some(i => i.value === this.value);\n }\n\n private firstEnabledItem(): SegmentItem | undefined {\n if (this.disabled) return undefined;\n return this.items.find(i => !i.disabled);\n }\n\n /**\n * Roving tabindex: the selected item is tab-reachable, or - when nothing is\n * selected - the first enabled item. All other items get -1.\n */\n getTabIndex(item: SegmentItem): number {\n if (this.disabled || item.disabled) return -1;\n if (this.value === item.value) return 0;\n if (!this.hasSelectedValue() && this.firstEnabledItem() === item) return 0;\n return -1;\n }\n\n /**\n * WAI-ARIA radio group keyboard nav: Arrow Left/Right (and Home/End) move\n * selection + focus across the enabled items, wrapping and skipping disabled.\n */\n onKeydown(event: KeyboardEvent) {\n if (this.disabled) return;\n\n const key = event.key;\n const forward = key === 'ArrowRight';\n const backward = key === 'ArrowLeft';\n const home = key === 'Home';\n const end = key === 'End';\n if (!forward && !backward && !home && !end) return;\n\n const enabled = this.items.map((it, i) => ({ it, i })).filter(x => !x.it.disabled);\n if (!enabled.length) return;\n\n event.preventDefault();\n\n const buttons = this.itemElements?.toArray().map(r => r.nativeElement) ?? [];\n const focusedIndex = buttons.indexOf(event.target as HTMLElement);\n let pos = enabled.findIndex(x => x.i === focusedIndex);\n if (pos < 0) pos = enabled.findIndex(x => x.it.value === this.value);\n\n let targetPos: number;\n if (home) {\n targetPos = 0;\n } else if (end) {\n targetPos = enabled.length - 1;\n } else if (forward) {\n targetPos = pos < 0 ? 0 : (pos + 1) % enabled.length;\n } else {\n targetPos = pos < 0 ? enabled.length - 1 : (pos - 1 + enabled.length) % enabled.length;\n }\n\n const target = enabled[targetPos];\n this.select(target.it.value);\n this.itemElements.get(target.i)?.nativeElement.focus();\n }\n\n updateGlider() {\n if (!this.itemElements || !this.items.length) return;\n\n const index = this.items.findIndex(i => i.value === this.value);\n\n if (index === -1) {\n this.hasValue = false;\n this.gliderWidth = 0;\n return;\n }\n\n const activeElement = this.itemElements.get(index)?.nativeElement;\n\n if (activeElement) {\n this.hasValue = true;\n this.gliderLeft = activeElement.offsetLeft;\n this.gliderWidth = activeElement.offsetWidth;\n this.cdr.detectChanges();\n }\n }\n\n // CVA Implementation\n writeValue(val: any): void {\n this.value = val;\n setTimeout(() => this.updateGlider());\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n protected cn = cn;\n}\n\n/**\n * Back-compat alias: the class was renamed to match its `tolle-segment` selector.\n *\n * A re-export binding rather than `const SegmentedComponent = SegmentComponent`,\n * because Angular's AOT compiler resolves an aliased export straight back to the\n * class declaration — so it still works in a consumer's `imports: []` array.\n *\n * @deprecated Use `SegmentComponent`. This alias will be removed in a future major.\n */\nexport { SegmentComponent as SegmentedComponent };\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "utils/cn.ts",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "select-group.component.ts",
|
|
12
12
|
"target": "ui/select-group.component.ts",
|
|
13
|
-
"content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-select-group',\n standalone: true,\n template: `\n <div [class]=\"cn('px-2 py-1.5 text-sm font-semibold text-muted-foreground', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SelectGroupComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-select-group',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <div [class]=\"cn('px-2 py-1.5 text-sm font-semibold text-muted-foreground', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SelectGroupComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "utils/cn.ts",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "select-item.component.ts",
|
|
12
12
|
"target": "ui/select-item.component.ts",
|
|
13
|
-
"content": "import {Component, Input, HostListener, ElementRef, Optional} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport {SelectService} from './select.service';\n\n@Component({\n selector: 'tolle-select-item',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n *ngIf=\"!hidden\"\n [id]=\"id\"\n [class]=\"cn(\n 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors',\n getItemClasses(),\n class\n )\"\n [attr.aria-disabled]=\"disabled\"\n [attr.aria-selected]=\"selected\"\n [attr.data-highlighted]=\"active ? '' : null\"\n role=\"option\"\n >\n <!-- Single Select: Checkmark -->\n <span *ngIf=\"!multiSelect && selected\" class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <i class=\"ri-check-line text-primary\"></i>\n </span>\n\n <!-- Multi-Select: Checkbox -->\n <span *ngIf=\"multiSelect\" class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <div [class]=\"cn(\n 'flex h-4 w-4 items-center justify-center rounded-sm border transition-all duration-200',\n selected ? 'border-primary bg-primary text-primary-foreground' : 'border-input'\n )\">\n <i [class]=\"cn(\n 'ri-check-line text-xs transition-all duration-200',\n selected ? 'opacity-100 scale-100' : 'opacity-0 scale-75'\n )\"></i>\n </div>\n </span>\n\n <!-- Content -->\n <span class=\"flex-1 truncate\">\n <ng-content></ng-content>\n </span>\n\n <!-- Disabled indicator -->\n <span *ngIf=\"disabled && !selected\" class=\"ml-2 text-xs text-muted-foreground/50\">\n <i class=\"ri-forbid-line\"></i>\n </span>\n </div>\n `,\n})\nexport class SelectItemComponent {\n @Input() value: any;\n @Input() class = '';\n @Input() selected = false;\n @Input() disabled = false;\n @Input() multiSelect = false; // Will be set by parent component\n // Stable option id used by the parent for aria-activedescendant wiring.\n @Input() id = `select-item-${Math.random().toString(36).substr(2, 9)}`;\n // Active-descendant highlight state, driven by the parent by index.\n @Input() active = false;\n\n hidden = false;\n\n constructor(\n @Optional() private selectService: SelectService,\n private el: ElementRef\n ) {}\n\n // Helper method for the parent to get the searchable text\n getLabel(): string {\n return this.el.nativeElement.textContent?.trim() || '';\n }\n\n // Scroll this option into view when it becomes the active descendant.\n scrollIntoActiveView(): void {\n this.el.nativeElement.scrollIntoView({ block: 'nearest' });\n }\n\n getItemClasses(): string {\n return cn(\n // Base state\n 'focus:bg-accent focus:text-accent-foreground',\n\n // Active-descendant (keyboard) highlight\n 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',\n\n // Hover states (only if not disabled)\n !this.disabled && [\n 'cursor-pointer',\n 'hover:bg-accent hover:text-accent-foreground'\n ],\n\n // Selected state\n this.selected && [\n this.multiSelect\n ? 'bg-primary/5 text-foreground'\n : 'bg-accent text-accent-foreground'\n ],\n\n // Disabled state\n this.disabled && [\n 'opacity-50',\n 'cursor-not-allowed',\n 'hover:bg-transparent hover:text-foreground'\n ]\n );\n }\n\n @HostListener('click', ['$event'])\n onClick(event: MouseEvent) {\n if (this.hidden || this.disabled) return;\n\n event.stopPropagation();\n if (this.selectService) {\n const label = this.getLabel();\n\n if (this.multiSelect) {\n // For multi-select, toggle selection\n this.selected = !this.selected;\n }\n // For both single and multi-select, notify parent\n this.selectService.registerClick(this.value, label);\n }\n }\n\n protected cn = cn;\n}\n"
|
|
13
|
+
"content": "import {Component, Input, HostListener, ElementRef, Optional} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport {SelectService} from './select.service';\n\n@Component({\n selector: 'tolle-select-item',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n *ngIf=\"!hidden\"\n [id]=\"id\"\n [class]=\"cn(\n 'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors',\n getItemClasses(),\n class\n )\"\n [attr.aria-disabled]=\"disabled\"\n [attr.aria-selected]=\"selected\"\n [attr.data-highlighted]=\"active ? '' : null\"\n role=\"option\"\n >\n <!-- Single Select: Checkmark -->\n <span *ngIf=\"!multiSelect && selected\" class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <i class=\"ri-check-line text-primary\"></i>\n </span>\n\n <!-- Multi-Select: Checkbox -->\n <span *ngIf=\"multiSelect\" class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <div [class]=\"cn(\n 'flex h-4 w-4 items-center justify-center rounded-sm border transition-all duration-200',\n selected ? 'border-primary bg-primary text-primary-foreground' : 'border-input'\n )\">\n <i [class]=\"cn(\n 'ri-check-line text-xs transition-all duration-200',\n selected ? 'opacity-100 scale-100' : 'opacity-0 scale-75'\n )\"></i>\n </div>\n </span>\n\n <!-- Content -->\n <span class=\"flex-1 truncate\">\n <ng-content></ng-content>\n </span>\n\n <!-- Disabled indicator -->\n <span *ngIf=\"disabled && !selected\" class=\"ml-2 text-xs text-muted-foreground/50\">\n <i class=\"ri-forbid-line\"></i>\n </span>\n </div>\n `,\n})\nexport class SelectItemComponent {\n @Input() value: any;\n @Input() class = '';\n @Input() selected = false;\n @Input() disabled = false;\n @Input() multiSelect = false; // Will be set by parent component\n // Stable option id used by the parent for aria-activedescendant wiring.\n @Input() id = `select-item-${Math.random().toString(36).substr(2, 9)}`;\n // Active-descendant highlight state, driven by the parent by index.\n @Input() active = false;\n\n hidden = false;\n\n constructor(\n @Optional() private selectService: SelectService,\n private el: ElementRef\n ) {}\n\n // Helper method for the parent to get the searchable text\n getLabel(): string {\n return this.el.nativeElement.textContent?.trim() || '';\n }\n\n // Scroll this option into view when it becomes the active descendant.\n scrollIntoActiveView(): void {\n this.el.nativeElement.scrollIntoView({ block: 'nearest' });\n }\n\n getItemClasses(): string {\n return cn(\n // Base state\n 'focus:bg-accent focus:text-accent-foreground',\n\n // Active-descendant (keyboard) highlight\n 'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground',\n\n // Hover states (only if not disabled)\n !this.disabled && [\n 'cursor-pointer',\n 'hover:bg-accent hover:text-accent-foreground'\n ],\n\n // Selected state\n this.selected && [\n this.multiSelect\n ? 'bg-primary/5 text-foreground'\n : 'bg-accent text-accent-foreground'\n ],\n\n // Disabled state\n this.disabled && [\n 'opacity-50',\n 'cursor-not-allowed',\n 'hover:bg-transparent hover:text-foreground'\n ]\n );\n }\n\n @HostListener('click', ['$event'])\n onClick(event: MouseEvent) {\n if (this.hidden || this.disabled) return;\n\n event.stopPropagation();\n if (this.selectService) {\n const label = this.getLabel();\n\n if (this.multiSelect) {\n // For multi-select, toggle selection\n this.selected = !this.selected;\n }\n // For both single and multi-select, notify parent\n this.selectService.registerClick(this.value, label);\n }\n }\n\n protected cn = cn;\n}\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "select.service.ts",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "select-separator.component.ts",
|
|
12
12
|
"target": "ui/select-separator.component.ts",
|
|
13
|
-
"content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-select-separator',\n standalone: true,\n template: `\n <div role=\"separator\" aria-hidden=\"true\" [class]=\"cn('-mx-1 my-1 h-px bg-border', class)\"></div>\n `,\n})\nexport class SelectSeparatorComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-select-separator',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <div role=\"separator\" aria-hidden=\"true\" [class]=\"cn('-mx-1 my-1 h-px bg-border', class)\"></div>\n `,\n})\nexport class SelectSeparatorComponent {\n @Input() class = '';\n protected cn = cn;\n}\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "utils/cn.ts",
|
package/registry/r/select.json
CHANGED
|
@@ -2,20 +2,21 @@
|
|
|
2
2
|
"name": "select",
|
|
3
3
|
"type": "registry:ui",
|
|
4
4
|
"title": "Select",
|
|
5
|
-
"description": "Select component.",
|
|
5
|
+
"description": "Select component with size and disabled and readonly options.",
|
|
6
6
|
"category": "forms",
|
|
7
7
|
"registryDependencies": [
|
|
8
8
|
"input",
|
|
9
9
|
"select-item"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": [
|
|
12
|
-
"@floating-ui/dom"
|
|
12
|
+
"@floating-ui/dom",
|
|
13
|
+
"class-variance-authority"
|
|
13
14
|
],
|
|
14
15
|
"files": [
|
|
15
16
|
{
|
|
16
17
|
"path": "select.component.ts",
|
|
17
18
|
"target": "ui/select.component.ts",
|
|
18
|
-
"content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChild,\n OnDestroy,\n ContentChildren,\n QueryList,\n AfterContentInit,\n ChangeDetectorRef\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\n// 1. Import 'size' middleware\nimport { computePosition, flip, shift, offset, autoUpdate, size } from '@floating-ui/dom';\nimport { cn } from './utils/cn';\nimport { SelectItemComponent } from './select-item.component';\nimport { Subscription } from 'rxjs';\nimport { SelectService } from './select.service';\nimport { InputComponent } from './input.component';\n\n@Component({\n selector: 'tolle-select',\n standalone: true,\n imports: [CommonModule, FormsModule, InputComponent],\n providers: [\n SelectService,\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SelectComponent),\n multi: true\n }\n ],\n styles: [`\n .hidden-dropdown {\n display: none !important;\n }\n `],\n template: `\n <div class=\"relative w-full\" #container>\n <button\n type=\"button\"\n #trigger\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-activedescendant]=\"isOpen ? activeDescendantId : null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n >\n <span class=\"truncate\" [class.text-muted-foreground]=\"!selectedLabel\">\n {{ selectedLabel || placeholder }}\n </span>\n <i [class]=\"iconClass\"></i>\n </button>\n\n <div\n #popover\n [class.hidden-dropdown]=\"!isOpen\"\n class=\"fixed bg-popover z-[999] overflow-auto flex flex-col rounded-md border border-border text-popover-foreground shadow-md\"\n style=\"visibility: hidden; top: 0; left: 0;\">\n <div *ngIf=\"searchable\" class=\"p-2 border-b border-border bg-popover h-auto\">\n <tolle-input\n #searchInput\n size=\"xs\"\n placeholder=\"Search...\"\n [(ngModel)]=\"searchQuery\"\n (ngModelChange)=\"onSearchChange($event)\"\n (keydown)=\"onSearchKeyDown($event)\"\n class=\"w-full\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n </div>\n\n <div role=\"listbox\" class=\"p-1 overflow-y-auto grow h-full w-full\">\n <ng-content></ng-content>\n <div *ngIf=\"noResults\" class=\"py-6 text-center text-sm text-muted-foreground\">\n No results found.\n </div>\n </div>\n </div>\n </div>\n `,\n})\nexport class SelectComponent implements ControlValueAccessor, AfterContentInit, OnDestroy {\n @Input() placeholder = 'Select an option';\n @Input() class = '';\n @Input() disabled = false;\n @Input() searchable = false;\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() readonly = false;\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n @ViewChild('container') container!: ElementRef;\n @ViewChild('searchInput', { read: ElementRef }) searchInput?: ElementRef<HTMLElement>;\n @ContentChildren(SelectItemComponent, { descendants: true }) items!: QueryList<SelectItemComponent>;\n\n private sub = new Subscription();\n private itemsChangeSub?: Subscription;\n private pendingValue: any = undefined;\n\n searchQuery = '';\n noResults = false;\n isOpen = false;\n value: any = null;\n selectedLabel = '';\n cleanupAutoUpdate?: () => void;\n\n // Keyboard navigation (active-descendant) state\n activeIndex = -1;\n private typeaheadBuffer = '';\n private typeaheadTime = 0;\n private pendingTypeaheadChar = '';\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n protected cn = cn;\n\n constructor(\n private selectService: SelectService,\n private cdr: ChangeDetectorRef\n ) {\n this.sub.add(\n this.selectService.selectedValue$.subscribe(val => {\n this.value = val;\n this.onChange(val);\n this.updateItemSelection();\n })\n );\n\n this.sub.add(\n this.selectService.selectedLabel$.subscribe(label => {\n this.selectedLabel = label;\n this.close();\n })\n );\n }\n\n get computedTriggerClass() {\n return cn(\n 'flex w-full items-center justify-between rounded-md border transition-all duration-200',\n 'bg-background text-foreground',\n 'border-input shadow-sm',\n this.size === 'xs' && 'h-8 px-2 text-xs',\n this.size === 'sm' && 'h-9 px-3 text-sm',\n this.size === 'default' && 'h-10 px-3 text-sm',\n this.size === 'lg' && 'h-11 px-4 text-base',\n !(this.readonly || this.disabled) && [\n 'focus-visible:outline-none',\n 'focus-visible:ring-4',\n 'focus-visible:ring-ring/30',\n 'focus-visible:ring-offset-0',\n 'focus-visible:shadow-none',\n 'focus-visible:border-primary/80'\n ],\n !(this.readonly || this.disabled) && 'hover:border-accent',\n this.disabled && [\n 'cursor-not-allowed opacity-50',\n 'border-opacity-50'\n ],\n this.readonly && [\n 'cursor-default',\n 'border-dashed',\n !this.disabled && 'focus-visible:ring-0 focus-visible:border-opacity-100'\n ],\n this.class\n );\n }\n\n get iconClass() {\n return cn(\n 'ri-arrow-down-s-line text-muted-foreground ml-2 transition-transform duration-200',\n this.isOpen ? 'rotate-180' : '',\n (this.size === 'xs' || this.size === 'sm') ? 'text-[14px]' : 'text-[18px]',\n (this.disabled || this.readonly) && 'opacity-30'\n );\n }\n\n ngAfterContentInit() {\n // Subscribe to items changes to handle dynamic content\n this.itemsChangeSub = this.items.changes.subscribe(() => {\n this.updateItemSelection();\n this.applyPendingValue();\n });\n\n // Apply initial selection if items are already available\n this.updateItemSelection();\n this.applyPendingValue();\n }\n\n private applyPendingValue(): void {\n if (this.pendingValue !== undefined && this.items && this.items.length > 0) {\n const found = this.items.find(i => i.value === this.pendingValue);\n if (found) {\n this.selectedLabel = found.getLabel();\n this.cdr.markForCheck();\n }\n this.pendingValue = undefined;\n }\n }\n\n private updateItemSelection() {\n if (this.items) {\n this.items.forEach(item => {\n item.selected = item.value === this.value;\n });\n }\n }\n\n private syncSelectedLabel(): void {\n if (this.items) {\n const found = this.items.find(i => i.value === this.value);\n if (found) {\n this.selectedLabel = found.getLabel();\n this.cdr.markForCheck();\n }\n }\n }\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.trigger.nativeElement.contains(event.target) && !this.popover?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n toggle() {\n if (this.disabled || this.readonly) return;\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n this.isOpen = true;\n this.trigger.nativeElement.focus();\n requestAnimationFrame(() => {\n this.updatePosition();\n document.addEventListener('mousedown', this._outsideClickHandler);\n this.initActiveAfterOpen();\n });\n }\n\n close() {\n this.isOpen = false;\n this.searchQuery = '';\n this.onSearchChange('');\n this.resetActive();\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n private updatePosition() {\n if (!this.trigger || !this.popover) return;\n\n this.cleanupAutoUpdate = autoUpdate(\n this.trigger.nativeElement,\n this.popover.nativeElement,\n () => {\n computePosition(this.trigger.nativeElement, this.popover.nativeElement, {\n strategy: 'fixed', // 3. Use fixed strategy\n placement: 'bottom-start',\n middleware: [\n offset(4),\n flip(),\n shift({ padding: 8 }),\n // 4. Use size middleware to sync width and handle constraints\n size({\n apply({ rects, elements, availableHeight }) {\n Object.assign(elements.floating.style, {\n width: `${rects.reference.width}px`,\n maxHeight: `${availableHeight}px`\n });\n },\n }),\n ],\n }).then(({ x, y, strategy }) => {\n Object.assign(this.popover.nativeElement.style, {\n position: strategy, // 5. Apply strategy to style\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n });\n }\n );\n }\n\n onSearchChange(query: string) {\n const filter = (query || '').toLowerCase().trim();\n let visibleCount = 0;\n\n this.items.forEach(item => {\n const text = item.getLabel().toLowerCase();\n const isVisible = text.includes(filter);\n item.hidden = !isVisible;\n if (isVisible) visibleCount++;\n });\n\n this.noResults = visibleCount === 0 && filter !== '';\n\n // Re-anchor the active option to the first visible match while open.\n if (this.isOpen) this.setActive(0);\n }\n\n // ---- Keyboard navigation (WAI-ARIA listbox pattern) ----\n\n private get navigableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(i => !i.hidden && !i.disabled) : [];\n }\n\n get activeDescendantId(): string | null {\n const list = this.navigableItems;\n return this.activeIndex >= 0 && this.activeIndex < list.length\n ? list[this.activeIndex].id\n : null;\n }\n\n private updateActiveStates() {\n const list = this.navigableItems;\n const activeItem = this.activeIndex >= 0 ? list[this.activeIndex] ?? null : null;\n this.items?.forEach(i => (i.active = i === activeItem));\n this.syncActiveDescendant();\n }\n\n private syncActiveDescendant() {\n const input = this.searchInput?.nativeElement?.querySelector('input');\n if (!input) return;\n const id = this.activeDescendantId;\n if (id) input.setAttribute('aria-activedescendant', id);\n else input.removeAttribute('aria-activedescendant');\n }\n\n private setActive(index: number) {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n this.activeIndex = Math.max(0, Math.min(index, list.length - 1));\n this.updateActiveStates();\n list[this.activeIndex]?.scrollIntoActiveView();\n }\n\n private setActiveItem(item: SelectItemComponent) {\n const idx = this.navigableItems.indexOf(item);\n if (idx >= 0) this.setActive(idx);\n }\n\n private moveActive(delta: number) {\n const list = this.navigableItems;\n if (!list.length) return;\n const next = this.activeIndex < 0 ? (delta > 0 ? 0 : list.length - 1) : this.activeIndex + delta;\n this.setActive(next); // setActive clamps (no wrap, matching shadcn)\n }\n\n private setActiveToSelectedOrFirst() {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n const selIdx = list.findIndex(i => i.value === this.value);\n this.setActive(selIdx >= 0 ? selIdx : 0);\n }\n\n private initActiveAfterOpen() {\n if (this.pendingTypeaheadChar) {\n const c = this.pendingTypeaheadChar;\n this.pendingTypeaheadChar = '';\n this.setActiveToSelectedOrFirst();\n this.typeahead(c);\n } else {\n this.setActiveToSelectedOrFirst();\n }\n }\n\n private resetActive() {\n this.activeIndex = -1;\n this.typeaheadBuffer = '';\n this.pendingTypeaheadChar = '';\n this.items?.forEach(i => (i.active = false));\n }\n\n private isTypeaheadChar(event: KeyboardEvent): boolean {\n return event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;\n }\n\n private typeahead(char: string) {\n const now = Date.now();\n // Reset the buffer based on elapsed time (correctness is not timer-driven).\n if (now - this.typeaheadTime > 500) this.typeaheadBuffer = '';\n this.typeaheadTime = now;\n this.typeaheadBuffer += char.toLowerCase();\n const match = this.navigableItems.find(i =>\n i.getLabel().toLowerCase().startsWith(this.typeaheadBuffer)\n );\n if (match) this.setActiveItem(match);\n }\n\n private selectActive() {\n const item = this.navigableItems[this.activeIndex];\n if (!item) return;\n // Single-select: notify parent (which closes and syncs the label).\n this.selectService.registerClick(item.value, item.getLabel());\n this.trigger.nativeElement.focus();\n }\n\n onTriggerKeyDown(event: KeyboardEvent) {\n if (this.disabled || this.readonly) return;\n const key = event.key;\n\n if (!this.isOpen) {\n if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ' || key === 'Home' || key === 'End') {\n event.preventDefault();\n this.open();\n return;\n }\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.pendingTypeaheadChar = key;\n this.open();\n }\n return;\n }\n\n switch (key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Home': event.preventDefault(); this.setActive(0); break;\n case 'End': event.preventDefault(); this.setActive(this.navigableItems.length - 1); break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n this.selectActive();\n break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n case 'Tab':\n this.close();\n break;\n default:\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.typeahead(key);\n }\n }\n }\n\n // Keydown while the (optional) search input is focused. Arrows/Enter/Escape\n // drive the list; other keys fall through so the user can type to filter.\n onSearchKeyDown(event: KeyboardEvent) {\n if (!this.isOpen) return;\n switch (event.key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Enter': event.preventDefault(); this.selectActive(); break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n }\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.updateItemSelection();\n if (this.items && this.items.length > 0) {\n const found = this.items.find(i => i.value === value);\n if (found) {\n this.selectedLabel = found.getLabel();\n this.cdr.markForCheck();\n }\n } else {\n // Queue the value for when items become available\n this.pendingValue = value;\n }\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; }\n\n ngOnDestroy() {\n this.sub.unsubscribe();\n this.itemsChangeSub?.unsubscribe();\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n}\n"
|
|
19
|
+
"content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n forwardRef,\n ElementRef,\n ViewChild,\n OnDestroy,\n ContentChildren,\n QueryList,\n AfterContentInit,\n OnChanges,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate, size } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { SelectItemComponent } from './select-item.component';\nimport { Subscription } from 'rxjs';\nimport { SelectService } from './select.service';\nimport { InputComponent } from './input.component';\n\nconst selectTriggerVariants = cva(\n 'flex w-full items-center justify-between rounded-md border border-input bg-background text-foreground shadow-sm transition-all duration-200',\n {\n variants: {\n size: {\n xs: 'h-8 px-2 text-xs',\n sm: 'h-9 px-3 text-sm',\n default: 'h-10 px-3 text-sm',\n lg: 'h-11 px-4 text-base',\n },\n disabled: {\n true: 'cursor-not-allowed border-opacity-50 opacity-50',\n false: '',\n },\n readonly: {\n true: 'cursor-default border-dashed',\n false: '',\n },\n },\n compoundVariants: [\n // Only an interactive trigger gets the hover border and the focus ring.\n {\n disabled: false,\n readonly: false,\n class:\n 'hover:border-accent focus-visible:border-primary/80 focus-visible:shadow-none focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/30 focus-visible:ring-offset-0',\n },\n // Readonly still takes focus, but without the emphasis ring.\n {\n disabled: false,\n readonly: true,\n class: 'focus-visible:border-opacity-100 focus-visible:ring-0',\n },\n ],\n defaultVariants: { size: 'default', disabled: false, readonly: false },\n }\n);\n\nconst selectIconVariants = cva(\n 'ri-arrow-down-s-line ml-2 text-muted-foreground transition-transform duration-200',\n {\n variants: {\n size: {\n xs: 'text-[14px]',\n sm: 'text-[14px]',\n default: 'text-[18px]',\n lg: 'text-[18px]',\n },\n open: { true: 'rotate-180', false: '' },\n muted: { true: 'opacity-30', false: '' },\n },\n defaultVariants: { size: 'default', open: false, muted: false },\n }\n);\n\nexport type SelectProps = VariantProps<typeof selectTriggerVariants>;\n\n/**\n * A single-select listbox: a trigger that opens a floating panel of\n * `tolle-select-item` options, with optional inline search. Implements\n * `ControlValueAccessor`, so it works with `ngModel` and reactive forms.\n */\n@Component({\n selector: 'tolle-select',\n standalone: true,\n imports: [CommonModule, FormsModule, InputComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n SelectService,\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SelectComponent),\n multi: true,\n },\n ],\n styles: [':host { display: block; }', '.hidden-dropdown { display: none !important; }'],\n template: `\n <div class=\"relative w-full\" #container>\n <button\n type=\"button\"\n #trigger\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-activedescendant]=\"isOpen ? activeDescendantId : null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n >\n <span class=\"truncate\" [class.text-muted-foreground]=\"!selectedLabel\">\n {{ selectedLabel || placeholder }}\n </span>\n <i [class]=\"iconClass\"></i>\n </button>\n\n <div\n #popover\n [class.hidden-dropdown]=\"!isOpen\"\n class=\"fixed bg-popover z-[999] overflow-auto flex flex-col rounded-md border border-border text-popover-foreground shadow-md\"\n style=\"visibility: hidden; top: 0; left: 0;\">\n <div *ngIf=\"searchable\" class=\"p-2 border-b border-border bg-popover h-auto\">\n <tolle-input\n #searchInput\n size=\"xs\"\n placeholder=\"Search...\"\n [(ngModel)]=\"searchQuery\"\n (ngModelChange)=\"onSearchChange($event)\"\n (keydown)=\"onSearchKeyDown($event)\"\n class=\"w-full\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n </div>\n\n <div role=\"listbox\" class=\"p-1 overflow-y-auto grow h-full w-full\">\n <ng-content></ng-content>\n <div *ngIf=\"noResults\" class=\"py-6 text-center text-sm text-muted-foreground\">\n No results found.\n </div>\n </div>\n </div>\n </div>\n `,\n})\nexport class SelectComponent\n implements ControlValueAccessor, AfterContentInit, OnChanges, OnDestroy {\n /** Text shown on the trigger when nothing is selected. @default 'Select an option' */\n @Input() placeholder = 'Select an option';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). @default '' */\n @Input() class = '';\n /** Disables the control and blocks opening the panel. @default false */\n @Input() disabled = false;\n /** Adds a search box that filters the projected options. @default false */\n @Input() searchable = false;\n /** Height and text size of the trigger. @default 'default' */\n @Input() size: SelectProps['size'] = 'default';\n /** Shows the current value but blocks opening the panel. @default false */\n @Input() readonly = false;\n\n /** Emitted with the chosen value whenever the selection changes. */\n @Output() valueChange = new EventEmitter<any>();\n /** Emitted when the panel opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the panel closes. */\n @Output() closed = new EventEmitter<void>();\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n @ViewChild('container') container!: ElementRef;\n @ViewChild('searchInput', { read: ElementRef }) searchInput?: ElementRef<HTMLElement>;\n @ContentChildren(SelectItemComponent, { descendants: true }) items!: QueryList<SelectItemComponent>;\n\n private sub = new Subscription();\n private itemsChangeSub?: Subscription;\n private pendingValue: any = undefined;\n\n searchQuery = '';\n noResults = false;\n isOpen = false;\n value: any = null;\n selectedLabel = '';\n cleanupAutoUpdate?: () => void;\n\n // Keyboard navigation (active-descendant) state\n activeIndex = -1;\n private typeaheadBuffer = '';\n private typeaheadTime = 0;\n private pendingTypeaheadChar = '';\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n protected cn = cn;\n\n constructor(\n private selectService: SelectService,\n private cdr: ChangeDetectorRef\n ) {\n this.sub.add(\n this.selectService.selectedValue$.subscribe(val => {\n this.value = val;\n this.onChange(val);\n this.valueChange.emit(val);\n this.updateItemSelection();\n this.cdr.markForCheck();\n })\n );\n\n this.sub.add(\n this.selectService.selectedLabel$.subscribe(label => {\n this.selectedLabel = label;\n this.close();\n this.cdr.markForCheck();\n })\n );\n }\n\n get computedTriggerClass() {\n return cn(\n selectTriggerVariants({\n size: this.size,\n disabled: this.disabled,\n readonly: this.readonly,\n }),\n this.class\n );\n }\n\n get iconClass() {\n return cn(\n selectIconVariants({\n size: this.size,\n open: this.isOpen,\n muted: this.disabled || this.readonly,\n })\n );\n }\n\n /**\n * Under OnPush a `[class]` binding is applied through Angular's *styling*\n * path, which writes the input but — unlike a plain property binding — never\n * marks the component dirty. Without this the trigger would keep rendering\n * the class it was born with. Covering every input here is cheap and leaves\n * no second instance of that trap.\n */\n ngOnChanges() {\n this.cdr.markForCheck();\n }\n\n ngAfterContentInit() {\n // Subscribe to items changes to handle dynamic content\n this.itemsChangeSub = this.items.changes.subscribe(() => {\n this.updateItemSelection();\n this.applyPendingValue();\n this.cdr.markForCheck();\n });\n\n // Apply initial selection if items are already available\n this.updateItemSelection();\n this.applyPendingValue();\n }\n\n private applyPendingValue(): void {\n if (this.pendingValue !== undefined && this.items && this.items.length > 0) {\n const found = this.items.find(i => i.value === this.pendingValue);\n if (found) {\n this.selectedLabel = found.getLabel();\n this.cdr.markForCheck();\n }\n this.pendingValue = undefined;\n }\n }\n\n private updateItemSelection() {\n if (this.items) {\n this.items.forEach(item => {\n item.selected = item.value === this.value;\n });\n }\n }\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.trigger.nativeElement.contains(event.target) && !this.popover?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n toggle() {\n if (this.disabled || this.readonly) return;\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n const wasOpen = this.isOpen;\n this.isOpen = true;\n this.trigger.nativeElement.focus();\n if (!wasOpen) this.opened.emit();\n this.cdr.markForCheck();\n requestAnimationFrame(() => {\n this.updatePosition();\n document.addEventListener('pointerdown', this._outsideClickHandler, true);\n this.initActiveAfterOpen();\n this.cdr.markForCheck();\n });\n }\n\n close() {\n const wasOpen = this.isOpen;\n this.isOpen = false;\n this.searchQuery = '';\n this.onSearchChange('');\n this.resetActive();\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n if (wasOpen) this.closed.emit();\n this.cdr.markForCheck();\n }\n\n private updatePosition() {\n if (!this.trigger || !this.popover) return;\n\n this.cleanupAutoUpdate = autoUpdate(\n this.trigger.nativeElement,\n this.popover.nativeElement,\n () => {\n computePosition(this.trigger.nativeElement, this.popover.nativeElement, {\n strategy: 'fixed',\n placement: 'bottom-start',\n middleware: [\n offset(4),\n flip(),\n shift({ padding: 8 }),\n // Match the panel to the trigger width and cap its height to the\n // space actually available, so the list scrolls instead of\n // overflowing.\n size({\n apply({ rects, elements, availableHeight }) {\n Object.assign(elements.floating.style, {\n width: rects.reference.width + 'px',\n maxHeight: availableHeight + 'px'\n });\n },\n }),\n ],\n }).then(({ x, y, strategy }) => {\n Object.assign(this.popover.nativeElement.style, {\n position: strategy,\n left: x + 'px',\n top: y + 'px',\n visibility: 'visible',\n });\n });\n }\n );\n }\n\n onSearchChange(query: string) {\n const filter = (query || '').toLowerCase().trim();\n let visibleCount = 0;\n\n this.items.forEach(item => {\n const text = item.getLabel().toLowerCase();\n const isVisible = text.includes(filter);\n item.hidden = !isVisible;\n if (isVisible) visibleCount++;\n });\n\n this.noResults = visibleCount === 0 && filter !== '';\n\n // Re-anchor the active option to the first visible match while open.\n if (this.isOpen) this.setActive(0);\n this.cdr.markForCheck();\n }\n\n // ---- Keyboard navigation (WAI-ARIA listbox pattern) ----\n\n private get navigableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(i => !i.hidden && !i.disabled) : [];\n }\n\n get activeDescendantId(): string | null {\n const list = this.navigableItems;\n return this.activeIndex >= 0 && this.activeIndex < list.length\n ? list[this.activeIndex].id\n : null;\n }\n\n private updateActiveStates() {\n const list = this.navigableItems;\n const activeItem = this.activeIndex >= 0 ? list[this.activeIndex] ?? null : null;\n this.items?.forEach(i => (i.active = i === activeItem));\n this.syncActiveDescendant();\n this.cdr.markForCheck();\n }\n\n private syncActiveDescendant() {\n const input = this.searchInput?.nativeElement?.querySelector('input');\n if (!input) return;\n const id = this.activeDescendantId;\n if (id) input.setAttribute('aria-activedescendant', id);\n else input.removeAttribute('aria-activedescendant');\n }\n\n private setActive(index: number) {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n this.activeIndex = Math.max(0, Math.min(index, list.length - 1));\n this.updateActiveStates();\n list[this.activeIndex]?.scrollIntoActiveView();\n }\n\n private setActiveItem(item: SelectItemComponent) {\n const idx = this.navigableItems.indexOf(item);\n if (idx >= 0) this.setActive(idx);\n }\n\n private moveActive(delta: number) {\n const list = this.navigableItems;\n if (!list.length) return;\n const next = this.activeIndex < 0 ? (delta > 0 ? 0 : list.length - 1) : this.activeIndex + delta;\n this.setActive(next); // setActive clamps (no wrap, matching shadcn)\n }\n\n private setActiveToSelectedOrFirst() {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n const selIdx = list.findIndex(i => i.value === this.value);\n this.setActive(selIdx >= 0 ? selIdx : 0);\n }\n\n private initActiveAfterOpen() {\n if (this.pendingTypeaheadChar) {\n const c = this.pendingTypeaheadChar;\n this.pendingTypeaheadChar = '';\n this.setActiveToSelectedOrFirst();\n this.typeahead(c);\n } else {\n this.setActiveToSelectedOrFirst();\n }\n }\n\n private resetActive() {\n this.activeIndex = -1;\n this.typeaheadBuffer = '';\n this.pendingTypeaheadChar = '';\n this.items?.forEach(i => (i.active = false));\n }\n\n private isTypeaheadChar(event: KeyboardEvent): boolean {\n return event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;\n }\n\n private typeahead(char: string) {\n const now = Date.now();\n // Reset the buffer based on elapsed time (correctness is not timer-driven).\n if (now - this.typeaheadTime > 500) this.typeaheadBuffer = '';\n this.typeaheadTime = now;\n this.typeaheadBuffer += char.toLowerCase();\n const match = this.navigableItems.find(i =>\n i.getLabel().toLowerCase().startsWith(this.typeaheadBuffer)\n );\n if (match) this.setActiveItem(match);\n }\n\n private selectActive() {\n const item = this.navigableItems[this.activeIndex];\n if (!item) return;\n // Single-select: notify parent (which closes and syncs the label).\n this.selectService.registerClick(item.value, item.getLabel());\n this.trigger.nativeElement.focus();\n }\n\n onTriggerKeyDown(event: KeyboardEvent) {\n if (this.disabled || this.readonly) return;\n const key = event.key;\n\n if (!this.isOpen) {\n if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ' || key === 'Home' || key === 'End') {\n event.preventDefault();\n this.open();\n return;\n }\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.pendingTypeaheadChar = key;\n this.open();\n }\n return;\n }\n\n switch (key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Home': event.preventDefault(); this.setActive(0); break;\n case 'End': event.preventDefault(); this.setActive(this.navigableItems.length - 1); break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n this.selectActive();\n break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n case 'Tab':\n this.close();\n break;\n default:\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.typeahead(key);\n }\n }\n }\n\n // Keydown while the (optional) search input is focused. Arrows/Enter/Escape\n // drive the list; other keys fall through so the user can type to filter.\n onSearchKeyDown(event: KeyboardEvent) {\n if (!this.isOpen) return;\n switch (event.key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Enter': event.preventDefault(); this.selectActive(); break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n }\n }\n\n writeValue(value: any): void {\n this.value = value;\n this.updateItemSelection();\n if (this.items && this.items.length > 0) {\n const found = this.items.find(i => i.value === value);\n if (found) {\n this.selectedLabel = found.getLabel();\n }\n } else {\n // Queue the value for when items become available\n this.pendingValue = value;\n }\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n ngOnDestroy() {\n this.sub.unsubscribe();\n this.itemsChangeSub?.unsubscribe();\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n }\n}\n"
|
|
19
20
|
},
|
|
20
21
|
{
|
|
21
22
|
"path": "select.service.ts",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "separator.component.ts",
|
|
12
12
|
"target": "ui/separator.component.ts",
|
|
13
|
-
"content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-separator',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n [class]=\"computedClass\"\n [attr.role]=\"decorative ? 'none' : 'separator'\"\n [attr.aria-hidden]=\"decorative ? 'true' : null\"\n [attr.aria-orientation]=\"!decorative && orientation === 'vertical' ? 'vertical' : null\"\n [attr.data-orientation]=\"orientation\"\n ></div>\n `\n})\nexport class SeparatorComponent {\n /** Orientation of the rule. @default 'horizontal' */\n @Input() orientation: 'horizontal' | 'vertical' = 'horizontal';\n /** When true, the separator is purely visual (aria-hidden, role=\"none\"). @default true */\n @Input() decorative: boolean = true;\n @Input() class: string = '';\n\n get computedClass() {\n return cn(\n \"shrink-0 bg-border\",\n this.orientation === \"horizontal\" ? \"h-px w-full\" : \"h-full w-px\",\n this.class\n );\n }\n}\n"
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-separator',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n [class]=\"computedClass\"\n [attr.role]=\"decorative ? 'none' : 'separator'\"\n [attr.aria-hidden]=\"decorative ? 'true' : null\"\n [attr.aria-orientation]=\"!decorative && orientation === 'vertical' ? 'vertical' : null\"\n [attr.data-orientation]=\"orientation\"\n ></div>\n `\n})\nexport class SeparatorComponent {\n /** Orientation of the rule. @default 'horizontal' */\n @Input() orientation: 'horizontal' | 'vertical' = 'horizontal';\n /** When true, the separator is purely visual (aria-hidden, role=\"none\"). @default true */\n @Input() decorative: boolean = true;\n @Input() class: string = '';\n\n get computedClass() {\n return cn(\n \"shrink-0 bg-border\",\n this.orientation === \"horizontal\" ? \"h-px w-full\" : \"h-full w-px\",\n this.class\n );\n }\n}\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "utils/cn.ts",
|
package/registry/r/sheet.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "sheet.component.ts",
|
|
12
12
|
"target": "ui/sheet.component.ts",
|
|
13
|
-
"content": "import { Component, Input, Output, EventEmitter, inject, TemplateRef, ViewChild, ViewContainerRef, OnDestroy, ContentChild, forwardRef, ElementRef, AfterContentInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Overlay, OverlayRef, OverlayConfig, ScrollStrategyOptions } from '@angular/cdk/overlay';\nimport { TemplatePortal } from '@angular/cdk/portal';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-sheet',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`\n})\nexport class SheetComponent {\n @Input() isOpen = false;\n @Input() hasBackdrop = true;\n @Output() isOpenChange = new EventEmitter<boolean>();\n\n private overlay = inject(Overlay);\n private viewContainerRef = inject(ViewContainerRef);\n private scrollStrategy = inject(ScrollStrategyOptions);\n\n private overlayRef?: OverlayRef;\n /** Element focused before the sheet opened, restored on close. */\n private previouslyFocused?: HTMLElement | null;\n\n @ContentChild(forwardRef(() => SheetContentComponent)) contentComponent?: any;\n\n open() {\n if (this.isOpen) return;\n this.isOpen = true;\n this.isOpenChange.emit(true);\n this.show();\n }\n\n close() {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.isOpenChange.emit(false);\n this.hide();\n }\n\n private show() {\n if (this.overlayRef || !this.contentComponent?.contentTemplate) return;\n\n // Remember the trigger so focus can be restored when the sheet closes.\n this.previouslyFocused = document.activeElement as HTMLElement | null;\n\n const overlayConfig = new OverlayConfig({\n hasBackdrop: this.hasBackdrop,\n backdropClass: 'tolle-modal-backdrop',\n scrollStrategy: this.scrollStrategy.block(),\n positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically() // This centers key, but sheet is fixed via classes\n });\n\n this.overlayRef = this.overlay.create(overlayConfig);\n\n const portal = new TemplatePortal(this.contentComponent.contentTemplate, this.viewContainerRef);\n this.overlayRef.attach(portal);\n\n if (this.hasBackdrop) {\n this.overlayRef.backdropClick().subscribe(() => this.close());\n }\n\n // Escape-to-close.\n this.overlayRef.keydownEvents().subscribe((event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n event.preventDefault();\n this.close();\n }\n });\n }\n\n private hide() {\n if (this.overlayRef) {\n const toRestore = this.previouslyFocused;\n setTimeout(() => {\n if (this.overlayRef) {\n this.overlayRef.detach();\n this.overlayRef.dispose();\n this.overlayRef = undefined;\n }\n // Restore focus to the trigger after the exit animation.\n toRestore?.focus?.();\n }, 300);\n }\n }\n\n ngOnDestroy() {\n // Immediate cleanup on destroy\n if (this.overlayRef) {\n this.overlayRef.dispose();\n }\n }\n}\n\n@Component({\n selector: 'tolle-sheet-trigger',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n '(click)': 'onClick()',\n 'class': 'inline-block'\n }\n})\nexport class SheetTriggerComponent {\n private sheet = inject(SheetComponent);\n\n onClick() {\n this.sheet.open();\n }\n}\n\n@Component({\n selector: 'tolle-sheet-content',\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `\n <ng-template #sheetContent>\n <div\n [class]=\"computedClass\"\n [attr.data-state]=\"sheet.isOpen ? 'open' : 'closed'\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"titleCmp?.id || null\"\n [attr.aria-describedby]=\"descriptionCmp?.id || null\"\n cdkTrapFocus cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n <button\n (click)=\"close()\"\n class=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none\"\n >\n <i class=\"ri-close-line text-xl\"></i>\n <span class=\"sr-only\">Close</span>\n </button>\n <ng-content></ng-content>\n </div>\n </ng-template>\n `\n})\nexport class SheetContentComponent {\n @Input() side: 'top' | 'bottom' | 'left' | 'right' = 'right';\n @Input() rounded: boolean = false;\n @Input() class: string = '';\n\n @ViewChild('sheetContent', { static: true }) contentTemplate!: TemplateRef<any>;\n\n /** Projected title/description used to build the dialog's accessible name. */\n @ContentChild(forwardRef(() => SheetTitleComponent)) titleCmp?: SheetTitleComponent;\n @ContentChild(forwardRef(() => SheetDescriptionComponent)) descriptionCmp?: SheetDescriptionComponent;\n\n public sheet = inject(SheetComponent);\n\n close() {\n this.sheet.close();\n }\n\n get computedClass() {\n const sideClasses = {\n top: \"inset-x-0 top-0 border-b data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n bottom: \"inset-x-0 bottom-0 border-t data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n left: \"inset-y-0 left-0 h-full w-3/4 border-r data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm\",\n right: \"inset-y-0 right-0 h-full w-3/4 border-l data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm\",\n };\n\n const roundingClasses = {\n top: \"rounded-b-2xl\",\n bottom: \"rounded-t-2xl\",\n left: \"rounded-r-2xl\",\n right: \"rounded-l-2xl\",\n };\n\n return cn(\n \"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-500 data-[state=closed]:duration-300\",\n sideClasses[this.side],\n this.rounded && roundingClasses[this.side],\n this.class\n );\n }\n}\n\n@Component({\n selector: 'tolle-sheet-header',\n standalone: true,\n template: `\n <div class=\"flex flex-col space-y-2 text-center sm:text-left\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class SheetHeaderComponent { }\n\n@Component({\n selector: 'tolle-sheet-footer',\n standalone: true,\n template: `\n <div class=\"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class SheetFooterComponent { }\n\n@Component({\n selector: 'tolle-sheet-title',\n standalone: true,\n template: `\n <h3 class=\"text-lg font-semibold text-foreground\">\n <ng-content></ng-content>\n </h3>\n `,\n host: { '[attr.id]': 'id' }\n})\nexport class SheetTitleComponent {\n /** Stable id referenced by the sheet's `aria-labelledby`. */\n readonly id = `tolle-sheet-title-${Math.random().toString(36).substr(2, 9)}`;\n}\n\n@Component({\n selector: 'tolle-sheet-description',\n standalone: true,\n template: `\n <p class=\"text-sm text-muted-foreground\">\n <ng-content></ng-content>\n </p>\n `,\n host: { '[attr.id]': 'id' }\n})\nexport class SheetDescriptionComponent {\n /** Stable id referenced by the sheet's `aria-describedby`. */\n readonly id = `tolle-sheet-description-${Math.random().toString(36).substr(2, 9)}`;\n}\n"
|
|
13
|
+
"content": "import { Component, Input, Output, EventEmitter, inject, TemplateRef, ViewChild, ViewContainerRef, OnDestroy, ContentChild, forwardRef, ElementRef, AfterContentInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Overlay, OverlayRef, OverlayConfig, ScrollStrategyOptions } from '@angular/cdk/overlay';\nimport { TemplatePortal } from '@angular/cdk/portal';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-sheet',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`\n})\nexport class SheetComponent {\n @Input() isOpen = false;\n @Input() hasBackdrop = true;\n @Output() isOpenChange = new EventEmitter<boolean>();\n\n private overlay = inject(Overlay);\n private viewContainerRef = inject(ViewContainerRef);\n private scrollStrategy = inject(ScrollStrategyOptions);\n\n private overlayRef?: OverlayRef;\n /** Pending teardown scheduled by `hide()` for the exit animation. */\n private disposeTimer?: ReturnType<typeof setTimeout>;\n /** Element focused before the sheet opened, restored on close. */\n private previouslyFocused?: HTMLElement | null;\n\n @ContentChild(forwardRef(() => SheetContentComponent)) contentComponent?: any;\n\n open() {\n if (this.isOpen) return;\n this.isOpen = true;\n this.isOpenChange.emit(true);\n // Re-opening inside the 300ms exit window: cancel the pending teardown so the\n // still-attached overlay is reused. Without this the timer disposes the overlay\n // after `show()` has already bailed out, leaving isOpen=true with no overlay —\n // and every later open() short-circuits on the guard above.\n this.cancelPendingDispose();\n this.show();\n }\n\n private cancelPendingDispose() {\n if (this.disposeTimer !== undefined) {\n clearTimeout(this.disposeTimer);\n this.disposeTimer = undefined;\n }\n }\n\n close() {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.isOpenChange.emit(false);\n this.hide();\n }\n\n private show() {\n if (this.overlayRef || !this.contentComponent?.contentTemplate) return;\n\n // Remember the trigger so focus can be restored when the sheet closes.\n this.previouslyFocused = document.activeElement as HTMLElement | null;\n\n const overlayConfig = new OverlayConfig({\n hasBackdrop: this.hasBackdrop,\n backdropClass: 'tolle-modal-backdrop',\n scrollStrategy: this.scrollStrategy.block(),\n positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically() // This centers key, but sheet is fixed via classes\n });\n\n this.overlayRef = this.overlay.create(overlayConfig);\n\n const portal = new TemplatePortal(this.contentComponent.contentTemplate, this.viewContainerRef);\n this.overlayRef.attach(portal);\n\n if (this.hasBackdrop) {\n this.overlayRef.backdropClick().subscribe(() => this.close());\n }\n\n // Escape-to-close.\n this.overlayRef.keydownEvents().subscribe((event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n event.preventDefault();\n this.close();\n }\n });\n }\n\n private hide() {\n if (this.overlayRef) {\n const toRestore = this.previouslyFocused;\n this.cancelPendingDispose();\n this.disposeTimer = setTimeout(() => {\n this.disposeTimer = undefined;\n if (this.overlayRef) {\n this.overlayRef.detach();\n this.overlayRef.dispose();\n this.overlayRef = undefined;\n }\n // Restore focus to the trigger after the exit animation.\n toRestore?.focus?.();\n }, 300);\n }\n }\n\n ngOnDestroy() {\n // Immediate cleanup on destroy\n this.cancelPendingDispose();\n if (this.overlayRef) {\n this.overlayRef.dispose();\n }\n }\n}\n\n@Component({\n selector: 'tolle-sheet-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n '(click)': 'onClick()',\n 'class': 'inline-block'\n }\n})\nexport class SheetTriggerComponent {\n private sheet = inject(SheetComponent);\n\n onClick() {\n this.sheet.open();\n }\n}\n\n@Component({\n selector: 'tolle-sheet-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `\n <ng-template #sheetContent>\n <div\n [class]=\"computedClass\"\n [attr.data-state]=\"sheet.isOpen ? 'open' : 'closed'\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"titleCmp?.id || null\"\n [attr.aria-describedby]=\"descriptionCmp?.id || null\"\n cdkTrapFocus cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n <button\n (click)=\"close()\"\n class=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none\"\n >\n <i class=\"ri-close-line text-xl\"></i>\n <span class=\"sr-only\">Close</span>\n </button>\n <ng-content></ng-content>\n </div>\n </ng-template>\n `\n})\nexport class SheetContentComponent {\n @Input() side: 'top' | 'bottom' | 'left' | 'right' = 'right';\n @Input() rounded: boolean = false;\n @Input() class: string = '';\n\n @ViewChild('sheetContent', { static: true }) contentTemplate!: TemplateRef<any>;\n\n /** Projected title/description used to build the dialog's accessible name. */\n @ContentChild(forwardRef(() => SheetTitleComponent)) titleCmp?: SheetTitleComponent;\n @ContentChild(forwardRef(() => SheetDescriptionComponent)) descriptionCmp?: SheetDescriptionComponent;\n\n public sheet = inject(SheetComponent);\n\n close() {\n this.sheet.close();\n }\n\n get computedClass() {\n const sideClasses = {\n top: \"inset-x-0 top-0 border-b data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n bottom: \"inset-x-0 bottom-0 border-t data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n left: \"inset-y-0 left-0 h-full w-3/4 border-r data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm\",\n right: \"inset-y-0 right-0 h-full w-3/4 border-l data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm\",\n };\n\n const roundingClasses = {\n top: \"rounded-b-2xl\",\n bottom: \"rounded-t-2xl\",\n left: \"rounded-r-2xl\",\n right: \"rounded-l-2xl\",\n };\n\n return cn(\n \"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-500 data-[state=closed]:duration-300\",\n sideClasses[this.side],\n this.rounded && roundingClasses[this.side],\n this.class\n );\n }\n}\n\n@Component({\n selector: 'tolle-sheet-header',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <div class=\"flex flex-col space-y-2 text-center sm:text-left\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class SheetHeaderComponent { }\n\n@Component({\n selector: 'tolle-sheet-footer',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <div class=\"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class SheetFooterComponent { }\n\n@Component({\n selector: 'tolle-sheet-title',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <h3 class=\"text-lg font-semibold text-foreground\">\n <ng-content></ng-content>\n </h3>\n `,\n host: { '[attr.id]': 'id' }\n})\nexport class SheetTitleComponent {\n /** Stable id referenced by the sheet's `aria-labelledby`. */\n readonly id = `tolle-sheet-title-${Math.random().toString(36).substr(2, 9)}`;\n}\n\n@Component({\n selector: 'tolle-sheet-description',\n styles: [':host { display: block; }'],\n standalone: true,\n template: `\n <p class=\"text-sm text-muted-foreground\">\n <ng-content></ng-content>\n </p>\n `,\n host: { '[attr.id]': 'id' }\n})\nexport class SheetDescriptionComponent {\n /** Stable id referenced by the sheet's `aria-describedby`. */\n readonly id = `tolle-sheet-description-${Math.random().toString(36).substr(2, 9)}`;\n}\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "utils/cn.ts",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shimmer",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Shimmer",
|
|
5
|
+
"description": "Shimmer component with size and active options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "shimmer.component.ts",
|
|
14
|
+
"target": "ui/shimmer.component.ts",
|
|
15
|
+
"content": "import { ChangeDetectionStrategy, Component, Input, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst shimmerVariants = cva('inline-block max-w-full align-baseline', {\n variants: {\n size: {\n xs: 'text-xs',\n sm: 'text-sm',\n default: 'text-base',\n lg: 'text-lg',\n xl: 'text-xl',\n },\n active: {\n // `.shimmer` is the animated gradient-sweep utility from theme.css.\n true: 'shimmer',\n false: 'text-muted-foreground',\n },\n },\n defaultVariants: { size: 'default', active: true },\n});\n\nexport type ShimmerProps = VariantProps<typeof shimmerVariants>;\n\n/**\n * Shimmering text for a live, in-progress status — \"Thinking…\", \"Generating\n * response…\". While `active` the projected text sweeps with the `.shimmer`\n * utility; when it is false the same text renders plain and static, so the\n * label can stay mounted as the state settles.\n *\n * ```html\n * <tolle-shimmer [active]=\"isStreaming\" size=\"sm\">Generating response…</tolle-shimmer>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-shimmer',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <span\n [attr.role]=\"active ? 'status' : null\"\n [attr.aria-live]=\"active ? 'polite' : null\"\n [attr.data-active]=\"active ? '' : null\"\n [class]=\"computedClass\"\n >\n <ng-content></ng-content>\n </span>\n `,\n})\nexport class ShimmerComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Runs the shimmer animation; false renders plain static text. @default true */\n @Input() active = true;\n /** Text size of the label. @default 'default' */\n @Input() size: ShimmerProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the label via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(shimmerVariants({ size: this.size, active: this.active }), this.class);\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
package/registry/r/sidebar.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
{
|
|
11
11
|
"path": "sidebar.component.ts",
|
|
12
12
|
"target": "ui/sidebar.component.ts",
|
|
13
|
-
"content": "import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { RouterModule } from '@angular/router';\nimport { cn } from './utils/cn';\n\n// --- Interfaces ---\nexport type SidebarItem = {\n title: string;\n url?: string;\n icon?: string;\n isActive?: boolean;\n items?: SidebarItem[];\n id?: string;\n expanded?: boolean;\n /** Render as a plain anchor to an external URL / static file (opens in a new tab) instead of a router link. */\n external?: boolean;\n}\n\nexport type SidebarGroup = {\n title: string;\n items: SidebarItem[];\n id?: string;\n}\n\n@Component({\n selector: 'tolle-sidebar',\n standalone: true,\n imports: [CommonModule, RouterModule],\n template: `\n <aside [class]=\"cn(\n 'flex flex-col h-full bg-background transition-[width] duration-300 ease-in-out shrink-0 overflow-hidden',\n collapsed ? 'w-16' : 'w-64',\n class\n )\">\n <div class=\"flex h-14 shrink-0 items-center px-3 overflow-hidden whitespace-nowrap\">\n <ng-content select=\"[header]\"></ng-content>\n </div>\n\n <div class=\"flex-1 min-h-0 overflow-y-auto overflow-x-hidden py-4 px-3\">\n\n @for (group of items; track group.id || group.title || $index; let gIndex = $index) {\n <div class=\"mb-6\">\n\n <div [class]=\"cn(\n 'mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground/70 transition-all duration-200 overflow-hidden',\n collapsed ? 'opacity-0 h-0 mb-0 px-0' : 'opacity-100 h-auto'\n )\">\n {{ group.title }}\n </div>\n\n <div class=\"flex flex-col gap-1\">\n @for (item of group.items; track item.id || item.title || $index; let i = $index) {\n\n @if (item.items && item.items.length) {\n <!-- Parent Item (Group) -->\n <div class=\"relative\">\n <button\n type=\"button\"\n (click)=\"toggleParent(group, item, gIndex, i)\"\n [disabled]=\"collapsed\"\n [attr.aria-expanded]=\"isParentExpanded(group, item, gIndex, i)\"\n [class]=\"cn(\n 'group w-full relative flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground',\n isParentExpanded(group, item, gIndex, i) ? 'text-foreground bg-accent/50' : 'text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n\n @if (!collapsed) {\n <i [class]=\"cn(\n 'ri-arrow-down-s-line ml-auto transition-transform duration-300',\n isParentExpanded(group, item, gIndex, i) ? 'rotate-180' : ''\n )\"></i>\n }\n </button>\n\n <!-- Submenu Grid Transition -->\n <div [class]=\"cn(\n 'grid transition-[grid-template-rows] duration-300 ease-in-out',\n isParentExpanded(group, item, gIndex, i) && !collapsed ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'\n )\">\n <div class=\"overflow-hidden\">\n <div class=\"flex flex-col gap-1 mt-1 ml-4 border-l border-border/50 pl-2\">\n\n @for (subItem of item.items; track subItem.id || subItem.title || $index; let j = $index) {\n @if (subItem.items && subItem.items.length) {\n <!-- Grandchild Item Group (Recursive-ish logic for depth 2) -->\n <div class=\"relative\">\n <button\n type=\"button\"\n (click)=\"toggleChild(group, item, subItem, gIndex, i, j); $event.stopPropagation()\"\n [attr.aria-expanded]=\"isChildExpanded(group, item, subItem, gIndex, i, j)\"\n [class]=\"cn(\n 'group w-full relative flex items-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'text-foreground bg-accent/30' : ''\n )\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border shrink-0\"></span>\n <span class=\"ml-2 truncate\">{{ subItem.title }}</span>\n <i [class]=\"cn(\n 'ri-arrow-right-s-line ml-auto transition-transform duration-300 text-xs',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'rotate-90' : ''\n )\"></i>\n </button>\n\n <div [class]=\"cn(\n 'grid transition-[grid-template-rows] duration-300 ease-in-out',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'\n )\">\n <div class=\"overflow-hidden\">\n <div class=\"flex flex-col gap-1 mt-1 ml-4 border-l border-border/30 pl-2\">\n @for (grandChild of subItem.items; track grandChild.title) {\n <a\n [routerLink]=\"grandChild.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n class=\"flex items-center gap-2 hover:no-underline rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground text-muted-foreground whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border/70 shrink-0\"></span>\n <span class=\"truncate\">{{ grandChild.title }}</span>\n </a>\n }\n </div>\n </div>\n </div>\n </div>\n }\n @else {\n <!-- Standard Sub Item -->\n <a\n [routerLink]=\"subItem.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n class=\"flex items-center gap-2 hover:no-underline rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground text-muted-foreground whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border shrink-0\"></span>\n <span class=\"truncate\">{{ subItem.title }}</span>\n </a>\n }\n }\n </div>\n </div>\n </div>\n </div>\n }\n @else if (item.external) {\n <!-- External / static-file Item (opens in a new tab) -->\n <a\n [href]=\"item.url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n [class]=\"cn(\n 'group relative hover:no-underline flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n @if (!collapsed) {\n <i aria-hidden=\"true\" class=\"ri-external-link-line ml-auto text-xs opacity-50 transition-opacity group-hover:opacity-100\"></i>\n }\n </a>\n }\n @else {\n <!-- Standard Top-Level Item -->\n <a\n [routerLink]=\"item.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n [class]=\"cn(\n 'group relative hover:no-underline flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n </a>\n }\n }\n </div>\n </div>\n }\n </div>\n\n <div class=\"shrink-0 p-3 overflow-hidden whitespace-nowrap\">\n <ng-content select=\"[footer]\"></ng-content>\n </div>\n </aside>\n `,\n styles: [`\n :host { display: block; height: 100%; }\n button:disabled { cursor: not-allowed; opacity: 0.6; }\n * { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }\n `]\n})\nexport class SidebarComponent implements OnChanges {\n @Input() items: SidebarGroup[] = [];\n @Input() collapsed = false;\n @Input() class = '';\n\n /**\n * Styling variant for active items.\n * - default: Solid primary background, light text (Matches Badge Default)\n * - secondary: Solid secondary background\n * - ghost: Transparent background, accent text (Shadcn standard)\n * - outline: Bordered\n */\n @Input() variant: 'default' | 'secondary' | 'ghost' | 'outline' = 'default';\n\n expandedParents = new Set<string>();\n expandedChildren = new Set<string>();\n\n protected readonly cn = cn;\n\n get activeClasses(): string {\n return cn(\n 'transition-colors',\n // Default: Solid Primary (Badge-like) with lighter opacity (80% instead of 90%)\n this.variant === 'default' && 'bg-primary/80 text-primary-foreground hover:bg-primary hover:text-primary-foreground',\n\n // Secondary: Solid Secondary\n this.variant === 'secondary' && 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n\n // Ghost: Subtle background (Sidebar standard)\n this.variant === 'ghost' && 'bg-accent text-accent-foreground',\n\n // Outline: Bordered\n this.variant === 'outline' && 'border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground'\n );\n }\n\n ngOnChanges(changes: SimpleChanges) {\n if (changes['collapsed'] && this.collapsed) {\n this.collapseAll();\n return;\n }\n if (changes['items']) {\n this.initializeState();\n }\n }\n\n // --- STATE INIT ---\n private initializeState() {\n this.collapseAll();\n if (!this.items) return;\n\n this.items.forEach((group, gIndex) => {\n group.items.forEach((item, iIndex) => {\n const parentId = this.getParentId(group, item, gIndex, iIndex);\n\n if (item.expanded) {\n this.expandedParents.add(parentId);\n }\n\n if (item.items) {\n item.items.forEach((subItem, sIndex) => {\n if (subItem.expanded) {\n const childId = this.getChildId(group, item, subItem, gIndex, iIndex, sIndex);\n this.expandedChildren.add(childId);\n this.expandedParents.add(parentId);\n }\n });\n }\n });\n });\n }\n\n // --- ID GENERATORS ---\n getGroupId(group: SidebarGroup, index: number): string {\n return group.id || group.title || `g-${index}`;\n }\n\n getParentId(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number): string {\n const groupId = this.getGroupId(group, gIndex);\n const itemId = item.id || item.title || `p-${iIndex}`;\n return `${groupId}__${itemId}`;\n }\n\n getChildId(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number): string {\n const parentId = this.getParentId(group, parent, gIndex, iIndex);\n const subItemId = subItem.id || subItem.title || `c-${sIndex}`;\n return `${parentId}__${subItemId}`;\n }\n\n // --- ACTIONS ---\n toggleParent(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number) {\n if (this.collapsed) return;\n const id = this.getParentId(group, item, gIndex, iIndex);\n\n const newSet = new Set(this.expandedParents);\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n this.expandedParents = newSet;\n }\n\n toggleChild(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number) {\n const id = this.getChildId(group, parent, subItem, gIndex, iIndex, sIndex);\n\n const newSet = new Set(this.expandedChildren);\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n this.expandedChildren = newSet;\n }\n\n private collapseAll() {\n this.expandedParents = new Set();\n this.expandedChildren = new Set();\n }\n\n // --- TEMPLATE HELPERS ---\n isParentExpanded(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number): boolean {\n return this.expandedParents.has(this.getParentId(group, item, gIndex, iIndex));\n }\n\n isChildExpanded(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number): boolean {\n return this.expandedChildren.has(this.getChildId(group, parent, subItem, gIndex, iIndex, sIndex));\n }\n}\n"
|
|
13
|
+
"content": "import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { RouterModule } from '@angular/router';\nimport { cn } from './utils/cn';\n\n// --- Interfaces ---\nexport type SidebarItem = {\n title: string;\n url?: string;\n icon?: string;\n isActive?: boolean;\n items?: SidebarItem[];\n id?: string;\n expanded?: boolean;\n /** Render as a plain anchor to an external URL / static file (opens in a new tab) instead of a router link. */\n external?: boolean;\n /** Short trailing pill, e.g. 'New' or 'Beta'. Hidden while the sidebar is collapsed. */\n badge?: string;\n}\n\nexport type SidebarGroup = {\n title: string;\n items: SidebarItem[];\n id?: string;\n}\n\n@Component({\n selector: 'tolle-sidebar',\n standalone: true,\n imports: [CommonModule, RouterModule],\n template: `\n <aside [class]=\"cn(\n 'flex flex-col h-full bg-background transition-[width] duration-300 ease-in-out shrink-0 overflow-hidden',\n collapsed ? 'w-16' : 'w-64',\n class\n )\">\n <div class=\"flex h-14 shrink-0 items-center px-3 overflow-hidden whitespace-nowrap\">\n <ng-content select=\"[header]\"></ng-content>\n </div>\n\n <div class=\"flex-1 min-h-0 overflow-y-auto overflow-x-hidden py-4 px-3\">\n\n @for (group of items; track group.id || group.title || $index; let gIndex = $index) {\n <div class=\"mb-6\">\n\n <div [class]=\"cn(\n 'mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground/70 transition-all duration-200 overflow-hidden',\n collapsed ? 'opacity-0 h-0 mb-0 px-0' : 'opacity-100 h-auto'\n )\">\n {{ group.title }}\n </div>\n\n <div class=\"flex flex-col gap-1\">\n @for (item of group.items; track item.id || item.title || $index; let i = $index) {\n\n @if (item.items && item.items.length) {\n <!-- Parent Item (Group) -->\n <div class=\"relative\">\n <button\n type=\"button\"\n (click)=\"toggleParent(group, item, gIndex, i)\"\n [disabled]=\"collapsed\"\n [attr.aria-expanded]=\"isParentExpanded(group, item, gIndex, i)\"\n [class]=\"cn(\n 'group w-full relative flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground',\n isParentExpanded(group, item, gIndex, i) ? 'text-foreground bg-accent/50' : 'text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n\n @if (!collapsed) {\n <i [class]=\"cn(\n 'ri-arrow-down-s-line ml-auto transition-transform duration-300',\n isParentExpanded(group, item, gIndex, i) ? 'rotate-180' : ''\n )\"></i>\n }\n </button>\n\n <!-- Submenu Grid Transition -->\n <div [class]=\"cn(\n 'grid transition-[grid-template-rows] duration-300 ease-in-out',\n isParentExpanded(group, item, gIndex, i) && !collapsed ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'\n )\">\n <div class=\"overflow-hidden\">\n <div class=\"flex flex-col gap-1 mt-1 ml-4 border-l border-border/50 pl-2\">\n\n @for (subItem of item.items; track subItem.id || subItem.title || $index; let j = $index) {\n @if (subItem.items && subItem.items.length) {\n <!-- Grandchild Item Group (Recursive-ish logic for depth 2) -->\n <div class=\"relative\">\n <button\n type=\"button\"\n (click)=\"toggleChild(group, item, subItem, gIndex, i, j); $event.stopPropagation()\"\n [attr.aria-expanded]=\"isChildExpanded(group, item, subItem, gIndex, i, j)\"\n [class]=\"cn(\n 'group w-full relative flex items-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'text-foreground bg-accent/30' : ''\n )\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border shrink-0\"></span>\n <span class=\"ml-2 truncate\">{{ subItem.title }}</span>\n <i [class]=\"cn(\n 'ri-arrow-right-s-line ml-auto transition-transform duration-300 text-xs',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'rotate-90' : ''\n )\"></i>\n </button>\n\n <div [class]=\"cn(\n 'grid transition-[grid-template-rows] duration-300 ease-in-out',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'\n )\">\n <div class=\"overflow-hidden\">\n <div class=\"flex flex-col gap-1 mt-1 ml-4 border-l border-border/30 pl-2\">\n @for (grandChild of subItem.items; track grandChild.title) {\n <a\n [routerLink]=\"grandChild.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n class=\"flex items-center gap-2 hover:no-underline rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground text-muted-foreground whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border/70 shrink-0\"></span>\n <span class=\"truncate\">{{ grandChild.title }}</span>\n </a>\n }\n </div>\n </div>\n </div>\n </div>\n }\n @else {\n <!-- Standard Sub Item -->\n <a\n [routerLink]=\"subItem.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n class=\"flex items-center gap-2 hover:no-underline rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground text-muted-foreground whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border shrink-0\"></span>\n <span class=\"truncate\">{{ subItem.title }}</span>\n </a>\n }\n }\n </div>\n </div>\n </div>\n </div>\n }\n @else if (item.external) {\n <!-- External / static-file Item (opens in a new tab) -->\n <a\n [href]=\"item.url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n [class]=\"cn(\n 'group relative hover:no-underline flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n @if (!collapsed) {\n <i aria-hidden=\"true\" class=\"ri-external-link-line ml-auto text-xs opacity-50 transition-opacity group-hover:opacity-100\"></i>\n }\n </a>\n }\n @else {\n <!-- Standard Top-Level Item -->\n <a\n [routerLink]=\"item.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n [class]=\"cn(\n 'group relative hover:no-underline flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n @if (item.badge && !collapsed) {\n <span class=\"ml-auto shrink-0 rounded-full border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium leading-none text-primary\">\n {{ item.badge }}\n </span>\n }\n </a>\n }\n }\n </div>\n </div>\n }\n </div>\n\n <div class=\"shrink-0 p-3 overflow-hidden whitespace-nowrap\">\n <ng-content select=\"[footer]\"></ng-content>\n </div>\n </aside>\n `,\n styles: [`\n :host { display: block; height: 100%; }\n button:disabled { cursor: not-allowed; opacity: 0.6; }\n * { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }\n `]\n})\nexport class SidebarComponent implements OnChanges {\n @Input() items: SidebarGroup[] = [];\n @Input() collapsed = false;\n @Input() class = '';\n\n /**\n * Styling variant for active items.\n * - default: Solid primary background, light text (Matches Badge Default)\n * - secondary: Solid secondary background\n * - ghost: Transparent background, accent text (Shadcn standard)\n * - outline: Bordered\n */\n @Input() variant: 'default' | 'secondary' | 'ghost' | 'outline' = 'default';\n\n expandedParents = new Set<string>();\n expandedChildren = new Set<string>();\n\n protected readonly cn = cn;\n\n get activeClasses(): string {\n return cn(\n 'transition-colors',\n // Default: Solid Primary (Badge-like) with lighter opacity (80% instead of 90%)\n this.variant === 'default' && 'bg-primary/80 text-primary-foreground hover:bg-primary hover:text-primary-foreground',\n\n // Secondary: Solid Secondary\n this.variant === 'secondary' && 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n\n // Ghost: Subtle background (Sidebar standard)\n this.variant === 'ghost' && 'bg-accent text-accent-foreground',\n\n // Outline: Bordered\n this.variant === 'outline' && 'border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground'\n );\n }\n\n ngOnChanges(changes: SimpleChanges) {\n if (changes['collapsed'] && this.collapsed) {\n this.collapseAll();\n return;\n }\n if (changes['items']) {\n this.initializeState();\n }\n }\n\n // --- STATE INIT ---\n private initializeState() {\n this.collapseAll();\n if (!this.items) return;\n\n this.items.forEach((group, gIndex) => {\n group.items.forEach((item, iIndex) => {\n const parentId = this.getParentId(group, item, gIndex, iIndex);\n\n if (item.expanded) {\n this.expandedParents.add(parentId);\n }\n\n if (item.items) {\n item.items.forEach((subItem, sIndex) => {\n if (subItem.expanded) {\n const childId = this.getChildId(group, item, subItem, gIndex, iIndex, sIndex);\n this.expandedChildren.add(childId);\n this.expandedParents.add(parentId);\n }\n });\n }\n });\n });\n }\n\n // --- ID GENERATORS ---\n getGroupId(group: SidebarGroup, index: number): string {\n return group.id || group.title || `g-${index}`;\n }\n\n getParentId(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number): string {\n const groupId = this.getGroupId(group, gIndex);\n const itemId = item.id || item.title || `p-${iIndex}`;\n return `${groupId}__${itemId}`;\n }\n\n getChildId(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number): string {\n const parentId = this.getParentId(group, parent, gIndex, iIndex);\n const subItemId = subItem.id || subItem.title || `c-${sIndex}`;\n return `${parentId}__${subItemId}`;\n }\n\n // --- ACTIONS ---\n toggleParent(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number) {\n if (this.collapsed) return;\n const id = this.getParentId(group, item, gIndex, iIndex);\n\n const newSet = new Set(this.expandedParents);\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n this.expandedParents = newSet;\n }\n\n toggleChild(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number) {\n const id = this.getChildId(group, parent, subItem, gIndex, iIndex, sIndex);\n\n const newSet = new Set(this.expandedChildren);\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n this.expandedChildren = newSet;\n }\n\n private collapseAll() {\n this.expandedParents = new Set();\n this.expandedChildren = new Set();\n }\n\n // --- TEMPLATE HELPERS ---\n isParentExpanded(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number): boolean {\n return this.expandedParents.has(this.getParentId(group, item, gIndex, iIndex));\n }\n\n isChildExpanded(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number): boolean {\n return this.expandedChildren.has(this.getChildId(group, parent, subItem, gIndex, iIndex, sIndex));\n }\n}\n"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"path": "utils/cn.ts",
|
package/registry/r/skeleton.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
{
|
|
13
13
|
"path": "skeleton.component.ts",
|
|
14
14
|
"target": "ui/skeleton.component.ts",
|
|
15
|
-
"content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst skeletonVariants = cva(\n // The background matches the Google Dark Mode \"Muted\" color\n 'animate-pulse bg-muted dark:bg-secondary rounded-md',\n {\n variants: {\n variant: {\n rect: '',\n circle: 'rounded-full',\n pill: 'rounded-full',\n },\n },\n defaultVariants: {\n variant: 'rect',\n },\n }\n);\n\nexport type SkeletonProps = VariantProps<typeof skeletonVariants>;\n\n@Component({\n selector: 'tolle-skeleton',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"computedClass\"></div>\n `,\n})\nexport class SkeletonComponent {\n /** Shape of the skeleton placeholder. @default 'rect' */\n @Input() variant: SkeletonProps['variant'] = 'rect';\n /** Extra Tailwind classes merged onto the skeleton via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(skeletonVariants({ variant: this.variant }), this.class);\n }\n}\n"
|
|
15
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst skeletonVariants = cva(\n // The background matches the Google Dark Mode \"Muted\" color\n 'animate-pulse bg-muted dark:bg-secondary rounded-md',\n {\n variants: {\n variant: {\n rect: '',\n circle: 'rounded-full',\n pill: 'rounded-full',\n },\n },\n defaultVariants: {\n variant: 'rect',\n },\n }\n);\n\nexport type SkeletonProps = VariantProps<typeof skeletonVariants>;\n\n@Component({\n selector: 'tolle-skeleton',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"computedClass\"></div>\n `,\n})\nexport class SkeletonComponent {\n /** Shape of the skeleton placeholder. @default 'rect' */\n @Input() variant: SkeletonProps['variant'] = 'rect';\n /** Extra Tailwind classes merged onto the skeleton via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(skeletonVariants({ variant: this.variant }), this.class);\n }\n}\n"
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
"path": "utils/cn.ts",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sources",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Sources",
|
|
5
|
+
"description": "Sources component with variant options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "sources.component.ts",
|
|
14
|
+
"target": "ui/sources.component.ts",
|
|
15
|
+
"content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Injectable, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { skip } from 'rxjs/operators';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nlet sourceId = 0;\n\n/**\n * Coordinates a `tolle-sources` with its trigger, content and sources.\n *\n * The trigger has to report how many sources were projected into a sibling\n * component, so every `tolle-source` registers here and the trigger renders\n * from the resulting count.\n *\n * Provided on `SourcesComponent`, so each citation list gets its own instance.\n */\n@Injectable()\nexport class SourcesService {\n private readonly openSource = new BehaviorSubject<boolean>(false);\n /** Emits whether the citation list is expanded. */\n readonly open$ = this.openSource.asObservable();\n\n private readonly idsSource = new BehaviorSubject<string[]>([]);\n /** Emits the ids of the registered sources, in registration (DOM) order. */\n readonly ids$ = this.idsSource.asObservable();\n\n /** True while the citation list is expanded. */\n get open(): boolean {\n return this.openSource.value;\n }\n\n /** How many sources are currently registered. */\n get count(): number {\n return this.idsSource.value.length;\n }\n\n setOpen(value: boolean): void {\n if (this.openSource.value !== value) this.openSource.next(value);\n }\n\n toggle(): void {\n this.setOpen(!this.openSource.value);\n }\n\n register(id: string): void {\n if (this.idsSource.value.includes(id)) return;\n this.idsSource.next([...this.idsSource.value, id]);\n }\n\n unregister(id: string): void {\n this.idsSource.next(this.idsSource.value.filter((existing) => existing !== id));\n }\n\n /** Zero-based position of a source, or -1 when it is not registered. */\n indexOf(id: string): number {\n return this.idsSource.value.indexOf(id);\n }\n}\n\nconst sourcesVariants = cva('flex w-full flex-col gap-2 text-sm', {\n variants: {\n variant: {\n default: '',\n card: 'rounded-md border border-border bg-card p-3 text-card-foreground',\n muted: 'rounded-md bg-muted p-3',\n },\n },\n defaultVariants: {\n variant: 'default',\n },\n});\n\nexport type SourcesProps = VariantProps<typeof sourcesVariants>;\n\n/**\n * A collapsible list of the citations behind a response.\n *\n * Compose it from `tolle-sources-trigger`, `tolle-sources-content` and one\n * `tolle-source` per citation — the trigger counts the sources for you.\n * @new\n */\n@Component({\n selector: 'tolle-sources',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [SourcesService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" [attr.data-state]=\"open ? 'open' : 'closed'\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SourcesComponent implements OnInit, OnChanges, OnDestroy {\n /** Whether the citation list is expanded. @default false */\n @Input() open = false;\n /** Visual treatment of the list container. @default 'default' */\n @Input() variant: SourcesProps['variant'] = 'default';\n /** Extra Tailwind classes merged onto the list via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the new state whenever the list is expanded or collapsed. */\n @Output() openChange = new EventEmitter<boolean>();\n\n private readonly service = inject(SourcesService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n /** True while an `open` input is being pushed into the service. */\n private applyingInput = false;\n\n ngOnInit(): void {\n this.service.setOpen(this.open);\n // `skip(1)` drops the BehaviorSubject's current value so mounting never\n // emits a spurious `openChange`.\n this.subscriptions.add(\n this.service.open$.pipe(skip(1)).subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n // Only user-driven toggles are worth reporting; echoing a change the\n // parent just made would fight a `[(open)]` binding.\n if (!this.applyingInput) this.openChange.emit(open);\n })\n );\n }\n\n ngOnChanges(changes: SimpleChanges): void {\n if (changes['open']) this.applyInput(this.open);\n \n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n private applyInput(open: boolean): void {\n this.applyingInput = true;\n try {\n this.service.setOpen(open);\n } finally {\n this.applyingInput = false;\n }\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(sourcesVariants({ variant: this.variant }), this.class);\n }\n}\n\n/** Toggle for a `tolle-sources`, labelled with how many sources there are. */\n@Component({\n selector: 'tolle-sources-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n type=\"button\"\n data-sources-trigger\n [attr.aria-expanded]=\"open\"\n [attr.data-state]=\"open ? 'open' : 'closed'\"\n [class]=\"computedClass\"\n (click)=\"onToggle()\"\n >\n <i class=\"ri-links-line\" aria-hidden=\"true\"></i>\n\n <span data-sources-label>{{ label }}</span>\n\n <i\n class=\"ri-arrow-down-s-line transition-transform duration-200\"\n [class.rotate-180]=\"open\"\n aria-hidden=\"true\"\n ></i>\n\n <ng-content></ng-content>\n </button>\n `,\n})\nexport class SourcesTriggerComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Overrides the counted number of sources. @default null */\n @Input() count: number | null = null;\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(SourcesService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n registered = 0;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.ids$.subscribe((ids) => {\n this.registered = ids.length;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** How many sources the trigger reports. */\n get total(): number {\n return this.count ?? this.registered;\n }\n\n get label(): string {\n return 'Used ' + this.total + (this.total === 1 ? ' source' : ' sources');\n }\n\n get computedClass() {\n return cn(\n 'flex w-fit items-center gap-1.5 rounded-md px-1.5 py-1 text-sm text-muted-foreground transition-colors',\n 'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n this.class\n );\n }\n\n onToggle(): void {\n this.service.toggle();\n }\n}\n\n/**\n * Holds the `tolle-source` entries of a `tolle-sources`.\n *\n * Stays in the DOM while collapsed — hidden and `inert` rather than removed —\n * so the sources inside it stay registered and the trigger's count is right\n * before the list has ever been opened.\n */\n@Component({\n selector: 'tolle-sources-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n data-sources-content\n [attr.data-state]=\"open ? 'open' : 'closed'\"\n [attr.inert]=\"open ? null : ''\"\n [attr.aria-hidden]=\"open ? null : 'true'\"\n [class]=\"computedClass\"\n >\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class SourcesContentComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the content via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(SourcesService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n open = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.open$.subscribe((open) => {\n this.open = open;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn('flex flex-col gap-1.5', !this.open && 'hidden', this.class);\n }\n}\n\nconst sourceVariants = cva(\n 'group flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n {\n variants: {\n size: {\n sm: 'text-xs',\n default: 'text-sm',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n }\n);\n\nexport type SourceProps = VariantProps<typeof sourceVariants>;\n\n/** A single citation inside a `tolle-sources-content`. */\n@Component({\n selector: 'tolle-source',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <a\n data-source\n [attr.href]=\"href || null\"\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n [class]=\"computedClass\"\n >\n <img\n *ngIf=\"favicon; else chip\"\n [attr.src]=\"favicon\"\n alt=\"\"\n aria-hidden=\"true\"\n class=\"size-4 shrink-0 rounded-sm\"\n />\n\n <ng-template #chip>\n <span\n class=\"flex size-4 shrink-0 items-center justify-center rounded-sm bg-muted text-[10px] font-medium text-muted-foreground\"\n aria-hidden=\"true\"\n >{{ position }}</span\n >\n </ng-template>\n\n <span class=\"truncate text-foreground\" data-source-title>{{ title || href }}</span>\n\n <i\n class=\"ri-external-link-line ml-auto shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100\"\n aria-hidden=\"true\"\n ></i>\n\n <ng-content></ng-content>\n </a>\n `,\n})\nexport class SourceComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** URL the citation points at. @default '' */\n @Input() href = '';\n /** Human-readable title; falls back to the href when empty. @default '' */\n @Input() title = '';\n /** URL of a favicon shown instead of the numbered chip. @default '' */\n @Input() favicon = '';\n /** Overrides the auto-numbered position chip. @default null */\n @Input() index: number | null = null;\n /** Text scale of the citation. @default 'default' */\n @Input() size: SourceProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the citation via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(SourcesService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n private readonly id = 'source-' + sourceId++;\n\n order = 0;\n\n ngOnInit(): void {\n this.service.register(this.id);\n this.subscriptions.add(\n this.service.ids$.subscribe(() => {\n this.order = this.service.indexOf(this.id);\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.service.unregister(this.id);\n this.subscriptions.unsubscribe();\n }\n\n /** One-based number shown in the chip. */\n get position(): number {\n return this.index ?? this.order + 1;\n }\n\n get computedClass() {\n return cn(sourceVariants({ size: this.size }), this.class);\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spinner",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Spinner",
|
|
5
|
+
"description": "Spinner component with size and variant options.",
|
|
6
|
+
"category": "feedback",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "spinner.component.ts",
|
|
14
|
+
"target": "ui/spinner.component.ts",
|
|
15
|
+
"content": "import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst spinnerVariants = cva('inline-block animate-spin rounded-full border-solid border-current border-r-transparent align-[-0.125em]', {\n variants: {\n size: {\n xs: 'h-3 w-3 border-[1.5px]',\n sm: 'h-4 w-4 border-2',\n default: 'h-5 w-5 border-2',\n lg: 'h-8 w-8 border-[3px]',\n xl: 'h-12 w-12 border-4',\n },\n variant: {\n default: 'text-foreground',\n primary: 'text-primary',\n muted: 'text-muted-foreground',\n destructive: 'text-destructive',\n },\n },\n defaultVariants: { size: 'default', variant: 'default' },\n});\n\nexport type SpinnerProps = VariantProps<typeof spinnerVariants>;\n\n/**\n * An indeterminate loading indicator. Renders as `role=\"status\"` with a\n * visually-hidden label so screen readers announce the loading state.\n * @new\n */\n@Component({\n selector: 'tolle-spinner',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <span role=\"status\" [attr.aria-label]=\"label\" [class]=\"computedClass\">\n <span class=\"sr-only\">{{ label }}</span>\n </span>\n `,\n})\nexport class SpinnerComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Diameter of the spinner. @default 'default' */\n @Input() size: SpinnerProps['size'] = 'default';\n /** Colour of the spinner, inherited from the theme tokens. @default 'default' */\n @Input() variant: SpinnerProps['variant'] = 'default';\n /** Accessible label announced while the spinner is visible. @default 'Loading' */\n @Input() label = 'Loading';\n /** Extra Tailwind classes merged onto the spinner via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(spinnerVariants({ size: this.size, variant: this.variant }), this.class);\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|