@tolle_/tolle-ui 18.2.28 → 18.2.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm2022/lib/accordion-item.component.mjs +5 -11
- package/esm2022/lib/accordion.component.mjs +4 -9
- package/esm2022/lib/alert-dialog-dynamic.component.mjs +5 -10
- package/esm2022/lib/alert-dialog.component.mjs +44 -59
- package/esm2022/lib/alert.component.mjs +5 -11
- package/esm2022/lib/aspect-ratio.component.mjs +119 -0
- package/esm2022/lib/attachment.component.mjs +334 -0
- package/esm2022/lib/avatar-fallback.component.mjs +3 -3
- package/esm2022/lib/avatar.component.mjs +4 -9
- package/esm2022/lib/badge.component.mjs +4 -9
- package/esm2022/lib/breadcrumb-item.component.mjs +3 -3
- package/esm2022/lib/breadcrumb-link.component.mjs +3 -3
- package/esm2022/lib/breadcrumb-separator.component.mjs +3 -3
- package/esm2022/lib/breadcrumb.component.mjs +3 -3
- package/esm2022/lib/bubble.component.mjs +301 -0
- package/esm2022/lib/button.component.mjs +5 -11
- package/esm2022/lib/calendar.component.mjs +5 -11
- package/esm2022/lib/card.component.mjs +14 -43
- package/esm2022/lib/carousel/carousel.component.mjs +5 -12
- package/esm2022/lib/chain-of-thought.component.mjs +380 -0
- package/esm2022/lib/chart-pie.component.mjs +386 -0
- package/esm2022/lib/chart.component.mjs +1146 -0
- package/esm2022/lib/chart.service.mjs +555 -0
- package/esm2022/lib/checkbox.component.mjs +5 -11
- package/esm2022/lib/checkpoint.component.mjs +134 -0
- package/esm2022/lib/collapsible.component.mjs +4 -11
- package/esm2022/lib/combobox.component.mjs +372 -0
- package/esm2022/lib/command-dialog.component.mjs +164 -0
- package/esm2022/lib/command.component.mjs +514 -0
- package/esm2022/lib/command.service.mjs +130 -0
- package/esm2022/lib/confirmation.component.mjs +219 -0
- package/esm2022/lib/context-menu-trigger.directive.mjs +36 -0
- package/esm2022/lib/context-menu.component.mjs +460 -0
- package/esm2022/lib/context-menu.service.mjs +121 -0
- package/esm2022/lib/context.component.mjs +377 -0
- package/esm2022/lib/conversation.component.mjs +442 -0
- package/esm2022/lib/country-selector.component.mjs +5 -11
- package/esm2022/lib/data-table.component.mjs +63 -12
- package/esm2022/lib/date-picker.component.mjs +251 -198
- package/esm2022/lib/date-range-picker.component.mjs +224 -116
- package/esm2022/lib/date-time-picker.component.mjs +506 -0
- package/esm2022/lib/direction.component.mjs +79 -0
- package/esm2022/lib/dropdown-item.component.mjs +3 -3
- package/esm2022/lib/dropdown-label.component.mjs +3 -3
- package/esm2022/lib/dropdown-menu.component.mjs +3 -3
- package/esm2022/lib/dropdown-separator.component.mjs +3 -3
- package/esm2022/lib/dropdown-trigger.directive.mjs +6 -4
- package/esm2022/lib/empty-state.component.mjs +4 -9
- package/esm2022/lib/field.component.mjs +394 -0
- package/esm2022/lib/hover-card.component.mjs +6 -16
- package/esm2022/lib/inline-citation.component.mjs +263 -0
- package/esm2022/lib/input-group.component.mjs +418 -0
- package/esm2022/lib/input.component.mjs +5 -11
- package/esm2022/lib/item.component.mjs +243 -0
- package/esm2022/lib/kbd.component.mjs +82 -0
- package/esm2022/lib/label.component.mjs +4 -9
- package/esm2022/lib/marker.component.mjs +135 -0
- package/esm2022/lib/masked-input.component.mjs +5 -11
- package/esm2022/lib/menubar.component.mjs +683 -0
- package/esm2022/lib/message-scroller.component.mjs +353 -0
- package/esm2022/lib/message-scroller.service.mjs +157 -0
- package/esm2022/lib/message.component.mjs +228 -0
- package/esm2022/lib/model-selector.component.mjs +310 -0
- package/esm2022/lib/multi-select.component.mjs +155 -72
- package/esm2022/lib/native-select.component.mjs +201 -0
- package/esm2022/lib/navigation-menu.component.mjs +642 -0
- package/esm2022/lib/otp-group.component.mjs +3 -3
- package/esm2022/lib/otp-slot.component.mjs +4 -9
- package/esm2022/lib/otp.component.mjs +5 -11
- package/esm2022/lib/pagination.component.mjs +7 -10
- package/esm2022/lib/plan.component.mjs +335 -0
- package/esm2022/lib/popover-content.component.mjs +4 -9
- package/esm2022/lib/popover.component.mjs +15 -13
- package/esm2022/lib/progress.component.mjs +4 -9
- package/esm2022/lib/prompt-input.component.mjs +526 -0
- package/esm2022/lib/queue.component.mjs +280 -0
- package/esm2022/lib/radio-group.component.mjs +6 -13
- package/esm2022/lib/radio-item.component.mjs +4 -9
- package/esm2022/lib/range-calendar.component.mjs +5 -11
- package/esm2022/lib/reasoning.component.mjs +335 -0
- package/esm2022/lib/resizable.component.mjs +219 -0
- package/esm2022/lib/segment.component.mjs +17 -7
- package/esm2022/lib/select-group.component.mjs +4 -8
- package/esm2022/lib/select-item.component.mjs +4 -9
- package/esm2022/lib/select-separator.component.mjs +4 -8
- package/esm2022/lib/select.component.mjs +133 -49
- package/esm2022/lib/separator.component.mjs +4 -9
- package/esm2022/lib/sheet.component.mjs +38 -56
- package/esm2022/lib/shimmer.component.mjs +84 -0
- package/esm2022/lib/sidebar.component.mjs +11 -1
- package/esm2022/lib/skeleton.component.mjs +4 -9
- package/esm2022/lib/sources.component.mjs +452 -0
- package/esm2022/lib/spinner.component.mjs +68 -0
- package/esm2022/lib/suggestion.component.mjs +207 -0
- package/esm2022/lib/switch.component.mjs +5 -11
- package/esm2022/lib/table.component.mjs +213 -0
- package/esm2022/lib/tabs.component.mjs +4 -11
- package/esm2022/lib/tag-input.component.mjs +5 -11
- package/esm2022/lib/task.component.mjs +359 -0
- package/esm2022/lib/textarea.component.mjs +5 -11
- package/esm2022/lib/theme.service.mjs +17 -6
- package/esm2022/lib/time-picker.component.mjs +923 -0
- package/esm2022/lib/toast.service.mjs +3 -2
- package/esm2022/lib/toaster.component.mjs +76 -39
- package/esm2022/lib/toggle-group.component.mjs +4 -9
- package/esm2022/lib/toggle.component.mjs +5 -11
- package/esm2022/lib/tool.component.mjs +462 -0
- package/esm2022/lib/typography.component.mjs +83 -0
- package/esm2022/lib/utils/color.mjs +140 -19
- package/esm2022/public-api.mjs +61 -1
- package/fesm2022/tolle-ui.mjs +16457 -1024
- package/fesm2022/tolle-ui.mjs.map +1 -1
- package/lib/alert-dialog.component.d.ts +10 -2
- package/lib/aspect-ratio.component.d.ts +20 -0
- package/lib/attachment.component.d.ts +101 -0
- package/lib/bubble.component.d.ts +112 -0
- package/lib/chain-of-thought.component.d.ts +166 -0
- package/lib/chart-pie.component.d.ts +108 -0
- package/lib/chart.component.d.ts +369 -0
- package/lib/chart.service.d.ts +222 -0
- package/lib/checkpoint.component.d.ts +51 -0
- package/lib/combobox.component.d.ts +90 -0
- package/lib/command-dialog.component.d.ts +53 -0
- package/lib/command.component.d.ts +189 -0
- package/lib/command.service.d.ts +67 -0
- package/lib/confirmation.component.d.ts +70 -0
- package/lib/context-menu-trigger.directive.d.ts +12 -0
- package/lib/context-menu.component.d.ts +41 -0
- package/lib/context-menu.service.d.ts +38 -0
- package/lib/context.component.d.ts +134 -0
- package/lib/conversation.component.d.ts +165 -0
- package/lib/data-table.component.d.ts +27 -1
- package/lib/date-picker.component.d.ts +72 -11
- package/lib/date-range-picker.component.d.ts +63 -6
- package/lib/date-time-picker.component.d.ts +119 -0
- package/lib/direction.component.d.ts +45 -0
- package/lib/field.component.d.ts +201 -0
- package/lib/inline-citation.component.d.ts +103 -0
- package/lib/input-group.component.d.ts +150 -0
- package/lib/item.component.d.ts +154 -0
- package/lib/kbd.component.d.ts +50 -0
- package/lib/marker.component.d.ts +58 -0
- package/lib/menubar.component.d.ts +247 -0
- package/lib/message-scroller.component.d.ts +159 -0
- package/lib/message-scroller.service.d.ts +73 -0
- package/lib/message.component.d.ts +128 -0
- package/lib/model-selector.component.d.ts +103 -0
- package/lib/multi-select.component.d.ts +66 -14
- package/lib/native-select.component.d.ts +57 -0
- package/lib/navigation-menu.component.d.ts +230 -0
- package/lib/plan.component.d.ts +120 -0
- package/lib/popover.component.d.ts +1 -0
- package/lib/prompt-input.component.d.ts +194 -0
- package/lib/queue.component.d.ts +106 -0
- package/lib/reasoning.component.d.ts +127 -0
- package/lib/resizable.component.d.ts +43 -0
- package/lib/segment.component.d.ts +13 -3
- package/lib/select.component.d.ts +37 -5
- package/lib/sheet.component.d.ts +3 -0
- package/lib/shimmer.component.d.ts +38 -0
- package/lib/sidebar.component.d.ts +2 -0
- package/lib/sources.component.d.ts +156 -0
- package/lib/spinner.component.d.ts +29 -0
- package/lib/suggestion.component.d.ts +96 -0
- package/lib/table.component.d.ts +106 -0
- package/lib/task.component.d.ts +151 -0
- package/lib/theme.service.d.ts +9 -0
- package/lib/time-picker.component.d.ts +243 -0
- package/lib/toast.service.d.ts +3 -1
- package/lib/toaster.component.d.ts +26 -9
- package/lib/tool.component.d.ts +158 -0
- package/lib/typography.component.d.ts +32 -0
- package/lib/utils/color.d.ts +19 -5
- package/package.json +1 -1
- package/public-api.d.ts +50 -0
- package/registry/docs-content.json +8497 -1553
- package/registry/llms-full.txt +2679 -318
- package/registry/llms.txt +43 -4
- package/registry/manifest.json +9093 -1739
- package/registry/r/accordion-item.json +1 -1
- package/registry/r/accordion.json +1 -1
- package/registry/r/alert-dialog-dynamic.json +1 -1
- package/registry/r/alert-dialog.json +1 -1
- package/registry/r/alert.json +1 -1
- package/registry/r/attachment.json +23 -0
- package/registry/r/avatar-fallback.json +1 -1
- package/registry/r/avatar.json +1 -1
- package/registry/r/badge.json +1 -1
- package/registry/r/breadcrumb-item.json +1 -1
- package/registry/r/breadcrumb-link.json +1 -1
- package/registry/r/breadcrumb-separator.json +1 -1
- package/registry/r/breadcrumb.json +1 -1
- package/registry/r/bubble.json +23 -0
- package/registry/r/button.json +1 -1
- package/registry/r/calendar.json +1 -1
- package/registry/r/card.json +1 -1
- package/registry/r/carousel.json +1 -1
- package/registry/r/chain-of-thought.json +23 -0
- package/registry/r/chart-pie.json +30 -0
- package/registry/r/chart.json +28 -0
- package/registry/r/checkbox.json +1 -1
- package/registry/r/checkpoint.json +23 -0
- package/registry/r/collapsible.json +1 -1
- package/registry/r/combobox.json +31 -0
- package/registry/r/command-dialog.json +21 -0
- package/registry/r/command.json +26 -0
- package/registry/r/confirmation.json +23 -0
- package/registry/r/context.json +26 -0
- package/registry/r/conversation.json +23 -0
- package/registry/r/country-selector.json +1 -1
- package/registry/r/data-table.json +1 -1
- package/registry/r/date-picker.json +4 -4
- package/registry/r/date-range-picker.json +3 -3
- package/registry/r/date-time-picker.json +28 -0
- package/registry/r/direction.json +16 -0
- package/registry/r/dropdown-item.json +1 -1
- package/registry/r/dropdown-label.json +1 -1
- package/registry/r/dropdown-menu.json +1 -1
- package/registry/r/dropdown-separator.json +1 -1
- package/registry/r/dropdown-trigger.json +1 -1
- package/registry/r/empty-state.json +1 -1
- package/registry/r/field.json +23 -0
- package/registry/r/hover-card.json +1 -1
- package/registry/r/inline-citation.json +26 -0
- package/registry/r/input-group.json +23 -0
- package/registry/r/input.json +1 -1
- package/registry/r/item.json +23 -0
- package/registry/r/kbd.json +23 -0
- package/registry/r/label.json +1 -1
- package/registry/r/marker.json +23 -0
- package/registry/r/masked-input.json +1 -1
- package/registry/r/menubar.json +24 -0
- package/registry/r/message-scroller.json +26 -0
- package/registry/r/message.json +23 -0
- package/registry/r/model-selector.json +32 -0
- package/registry/r/multi-select.json +2 -2
- package/registry/r/native-select.json +23 -0
- package/registry/r/navigation-menu.json +24 -0
- package/registry/r/otp-group.json +1 -1
- package/registry/r/otp-slot.json +1 -1
- package/registry/r/otp.json +1 -1
- package/registry/r/pagination.json +3 -2
- package/registry/r/plan.json +23 -0
- package/registry/r/popover-content.json +1 -1
- package/registry/r/popover.json +1 -1
- package/registry/r/progress.json +1 -1
- package/registry/r/prompt-input.json +23 -0
- package/registry/r/queue.json +23 -0
- package/registry/r/radio-group.json +1 -1
- package/registry/r/radio-item.json +1 -1
- package/registry/r/range-calendar.json +1 -1
- package/registry/r/reasoning.json +23 -0
- package/registry/r/segment.json +1 -1
- package/registry/r/select-group.json +1 -1
- package/registry/r/select-item.json +1 -1
- package/registry/r/select-separator.json +1 -1
- package/registry/r/select.json +4 -3
- package/registry/r/separator.json +1 -1
- package/registry/r/sheet.json +1 -1
- package/registry/r/shimmer.json +23 -0
- package/registry/r/sidebar.json +1 -1
- package/registry/r/skeleton.json +1 -1
- package/registry/r/sources.json +23 -0
- package/registry/r/spinner.json +23 -0
- package/registry/r/suggestion.json +23 -0
- package/registry/r/switch.json +1 -1
- package/registry/r/table.json +21 -0
- package/registry/r/tabs.json +1 -1
- package/registry/r/tag-input.json +1 -1
- package/registry/r/task.json +23 -0
- package/registry/r/textarea.json +1 -1
- package/registry/r/time-picker.json +24 -0
- package/registry/r/toaster.json +2 -2
- package/registry/r/toggle-group.json +1 -1
- package/registry/r/toggle.json +1 -1
- package/registry/r/tool.json +23 -0
- package/registry/r/typography.json +23 -0
- package/registry/registry.json +915 -22
- package/theme.css +109 -8
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "command",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Command",
|
|
5
|
+
"description": "Command component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "command.component.ts",
|
|
12
|
+
"target": "ui/command.component.ts",
|
|
13
|
+
"content": "import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ChangeDetectorRef, OnInit, OnChanges, OnDestroy, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Subscription } from 'rxjs';\nimport { cn } from './utils/cn';\nimport { CommandService } from './command.service';\n\nlet commandItemId = 0;\n\n/**\n * A filterable command menu — the list half of a command palette (⌘K),\n * a searchable dropdown, or an inline action list.\n *\n * Compose it from `tolle-command-input`, `tolle-command-list`,\n * `tolle-command-group`, `tolle-command-item` and `tolle-command-empty`.\n * Filtering, keyboard navigation and `aria-activedescendant` are handled for\n * you by the `CommandService` this component provides.\n *\n * For the ⌘K modal version use `tolle-command-dialog`; for a form control use\n * `tolle-combobox`.\n * @new\n */\n@Component({\n selector: 'tolle-command',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [CommandService],\n template: `\n <div\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n aria-expanded=\"true\"\n [attr.aria-label]=\"ariaLabel || null\"\n [class]=\"computedClass\"\n (keydown)=\"onKeyDown($event)\"\n >\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class CommandComponent implements OnInit, OnChanges, OnDestroy {\n /**\n * Set false to keep every item visible and filter the list yourself —\n * for server-side or fuzzy search. @default true\n */\n @Input() shouldFilter = true;\n /** Accessible name for the command menu. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the menu via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the chosen item's `value` when the user selects one. */\n @Output() selected = new EventEmitter<any>();\n /** Emitted with the query text whenever the search input changes. */\n @Output() queryChange = new EventEmitter<string>();\n\n private readonly service = inject(CommandService);\n private readonly subscriptions = new Subscription();\n\n ngOnInit(): void {\n this.service.shouldFilter = this.shouldFilter;\n this.subscriptions.add(this.service.selected$.subscribe((value) => this.selected.emit(value)));\n this.subscriptions.add(this.service.query$.subscribe((query) => this.queryChange.emit(query)));\n }\n\n ngOnChanges(): void {\n this.service.shouldFilter = this.shouldFilter;\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn('flex w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground', this.class);\n }\n\n /**\n * Menu-level keyboard handling, so the arrows work whether focus is in the\n * search input or on the menu itself.\n */\n onKeyDown(event: KeyboardEvent): void {\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault();\n this.service.move(1);\n break;\n case 'ArrowUp':\n event.preventDefault();\n this.service.move(-1);\n break;\n case 'Home':\n event.preventDefault();\n this.service.setActive(this.service.visibleIds[0] ?? null);\n break;\n case 'End':\n event.preventDefault();\n this.service.setActive(this.service.visibleIds[this.service.visibleIds.length - 1] ?? null);\n break;\n case 'Enter':\n event.preventDefault();\n this.service.selectActive();\n break;\n }\n }\n}\n\n/** Search box for a `tolle-command`. Typing here drives the filtering. */\n@Component({\n selector: 'tolle-command-input',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"cn('flex items-center gap-2 border-b border-border px-3', class)\">\n <i class=\"ri-search-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n <input\n [attr.placeholder]=\"placeholder\"\n [attr.aria-activedescendant]=\"activeId\"\n [attr.aria-label]=\"ariaLabel || placeholder || 'Search'\"\n aria-autocomplete=\"list\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n [value]=\"value\"\n [class]=\"cn(\n 'h-11 w-full bg-transparent py-3 text-sm text-foreground outline-none',\n 'placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',\n inputClass\n )\"\n (input)=\"onInput($event)\"\n />\n </div>\n `,\n})\nexport class CommandInputComponent implements OnInit, OnDestroy {\n /** Placeholder shown when the query is empty. @default 'Type a command or search…' */\n @Input() placeholder = 'Type a command or search…';\n /** Accessible name for the search box. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the search row via `cn()` (last-wins). */\n @Input() class = '';\n /** Extra Tailwind classes merged onto the `<input>` itself via `cn()` (last-wins). */\n @Input() inputClass = '';\n\n private readonly service = inject(CommandService);\n private readonly subscriptions = new Subscription();\n private readonly cdr = inject(ChangeDetectorRef);\n\n value = '';\n activeId: string | null = null;\n protected cn = cn;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.activeId$.subscribe((id) => {\n this.activeId = id;\n this.cdr.markForCheck();\n })\n );\n // The dialog clears the query when it reopens; mirror that back into the box.\n this.subscriptions.add(\n this.service.query$.subscribe((query) => {\n if (query !== this.value) {\n this.value = query;\n this.cdr.markForCheck();\n }\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n onInput(event: Event): void {\n const next = (event.target as HTMLInputElement).value;\n this.value = next;\n this.service.setQuery(next);\n }\n}\n\n/** Scrolling container for the command items. */\n@Component({\n selector: 'tolle-command-list',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div role=\"listbox\" [attr.aria-label]=\"ariaLabel || null\" [class]=\"cn('max-h-80 overflow-y-auto overflow-x-hidden p-1', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class CommandListComponent 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 /** Accessible name for the list. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the list via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/**\n * Shown only when the query matches nothing. Renders nothing otherwise, so it\n * can live in the template permanently.\n */\n@Component({\n selector: 'tolle-command-empty',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div *ngIf=\"isEmpty\" role=\"status\" [class]=\"cn('py-6 text-center text-sm text-muted-foreground', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class CommandEmptyComponent implements OnInit, OnDestroy {\n /** Extra Tailwind classes merged onto the empty state via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(CommandService);\n private readonly subscriptions = new Subscription();\n private readonly cdr = inject(ChangeDetectorRef);\n\n isEmpty = false;\n protected cn = cn;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.visible$.subscribe(() => {\n this.isEmpty = this.service.isEmpty;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n}\n\n/**\n * A labelled section of items. The heading hides itself when filtering removes\n * every item in the group, so you never get a stranded header.\n */\n@Component({\n selector: 'tolle-command-group',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div role=\"group\" [attr.aria-label]=\"heading || null\" [class]=\"cn('overflow-hidden p-1 text-foreground', class)\">\n <div\n *ngIf=\"heading\"\n [class]=\"cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', hidden && 'hidden')\"\n >\n {{ heading }}\n </div>\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class CommandGroupComponent implements OnInit, OnDestroy {\n /** Section heading. @default '' */\n @Input() heading = '';\n /** Extra Tailwind classes merged onto the group via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(CommandService);\n private readonly subscriptions = new Subscription();\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly memberIds = new Set<string>();\n\n hidden = false;\n protected cn = cn;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.visible$.subscribe((visible) => {\n // Hide the heading once none of this group's items survive the filter.\n this.hidden = this.memberIds.size > 0 && !visible.some((id) => this.memberIds.has(id));\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** Called by child items so the group knows which ids belong to it. */\n addMember(id: string): void {\n this.memberIds.add(id);\n }\n\n removeMember(id: string): void {\n this.memberIds.delete(id);\n }\n}\n\n/** A selectable row in a command menu. */\n@Component({\n selector: 'tolle-command-item',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <div\n *ngIf=\"visible\"\n [id]=\"id\"\n role=\"option\"\n [attr.aria-selected]=\"active\"\n [attr.aria-disabled]=\"disabled || null\"\n [attr.data-active]=\"active ? '' : null\"\n [class]=\"computedClass\"\n (click)=\"onClick()\"\n (mouseenter)=\"onMouseEnter()\"\n >\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class CommandItemComponent implements OnInit, OnChanges, OnDestroy {\n /** Value emitted from the menu's `selected` output when this row is chosen. */\n @Input() value: any = null;\n /**\n * Text matched against the query. Defaults to `value` when omitted — set it\n * explicitly whenever the row's visible text differs from its value.\n * @default ''\n */\n @Input() label = '';\n /** Extra terms that should also match this row, e.g. synonyms. @default [] */\n @Input() keywords: string[] = [];\n /** Makes the row unselectable and skipped by the arrow keys. @default false */\n @Input() disabled = false;\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when this specific row is chosen. */\n @Output() selected = new EventEmitter<any>();\n\n private readonly service = inject(CommandService);\n private readonly group = inject(CommandGroupComponent, { optional: true });\n private readonly subscriptions = new Subscription();\n private readonly cdr = inject(ChangeDetectorRef);\n\n readonly id = `command-item-${commandItemId++}`;\n visible = true;\n active = false;\n\n ngOnInit(): void {\n this.service.register({\n id: this.id,\n searchText: this.searchText,\n value: this.value,\n disabled: this.disabled,\n });\n this.group?.addMember(this.id);\n\n this.subscriptions.add(\n this.service.visible$.subscribe((ids) => {\n this.visible = ids.includes(this.id);\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.activeId$.subscribe((activeId) => {\n this.active = activeId === this.id;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.selected$.subscribe((value) => {\n if (value === this.value) this.selected.emit(value);\n })\n );\n }\n\n ngOnChanges(): void {\n this.service.update(this.id, {\n searchText: this.searchText,\n value: this.value,\n disabled: this.disabled,\n });\n }\n\n ngOnDestroy(): void {\n this.group?.removeMember(this.id);\n this.service.unregister(this.id);\n this.subscriptions.unsubscribe();\n }\n\n /** Label plus keywords, so synonyms match without being displayed. */\n private get searchText(): string {\n const base = this.label || (this.value != null ? String(this.value) : '');\n return [base, ...this.keywords].join(' ');\n }\n\n get computedClass() {\n return cn(\n 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',\n this.active && !this.disabled && 'bg-accent text-accent-foreground',\n this.disabled && 'pointer-events-none opacity-50',\n this.class\n );\n }\n\n onClick(): void {\n if (this.disabled) return;\n this.service.selectById(this.id);\n }\n\n onMouseEnter(): void {\n if (this.disabled) return;\n this.service.setActive(this.id);\n }\n}\n\n/** Divider between command groups. */\n@Component({\n selector: 'tolle-command-separator',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div role=\"separator\" [class]=\"cn('-mx-1 h-px bg-border', class)\"></div>`,\n})\nexport class CommandSeparatorComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the separator via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Right-aligned shortcut hint inside a command item. Pairs with `tolle-kbd`. */\n@Component({\n selector: 'tolle-command-shortcut',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<span [class]=\"cn('ml-auto flex items-center gap-1 text-xs tracking-widest text-muted-foreground', class)\"><ng-content></ng-content></span>`,\n})\nexport class CommandShortcutComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the shortcut via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "command.service.ts",
|
|
17
|
+
"target": "ui/command.service.ts",
|
|
18
|
+
"content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject, Subject } from 'rxjs';\n\n/** One registered `tolle-command-item`, as seen by the service. */\nexport interface CommandItemState {\n id: string;\n /** Text the query is matched against — the item's label plus any `keywords`. */\n searchText: string;\n value: any;\n disabled: boolean;\n /** Registration order, used to keep the visible list in DOM order. */\n order: number;\n}\n\n/**\n * Coordinates a `tolle-command` and its descendants: owns the query, decides\n * which items survive filtering, and tracks the active (highlighted) item for\n * `aria-activedescendant`.\n *\n * Provided on `CommandComponent`, so each command menu gets its own instance.\n */\n@Injectable()\nexport class CommandService {\n private readonly items = new Map<string, CommandItemState>();\n private nextOrder = 0;\n\n private readonly querySubject = new BehaviorSubject<string>('');\n /** Current search query. */\n readonly query$ = this.querySubject.asObservable();\n\n private readonly visibleSubject = new BehaviorSubject<string[]>([]);\n /** Ids of the items surviving the current query, in DOM order. */\n readonly visible$ = this.visibleSubject.asObservable();\n\n private readonly activeIdSubject = new BehaviorSubject<string | null>(null);\n /** Id of the highlighted item, or null when nothing matches. */\n readonly activeId$ = this.activeIdSubject.asObservable();\n\n private readonly selectedSubject = new Subject<any>();\n /** Emits the value of the item the user chose. */\n readonly selected$ = this.selectedSubject.asObservable();\n\n /**\n * When set, filtering is left entirely to the consumer — every registered\n * item stays visible regardless of the query. Use for server-side search.\n */\n shouldFilter = true;\n\n get query(): string {\n return this.querySubject.value;\n }\n\n get visibleIds(): string[] {\n return this.visibleSubject.value;\n }\n\n get activeId(): string | null {\n return this.activeIdSubject.value;\n }\n\n /**\n * True when there is nothing to show.\n *\n * With built-in filtering that means items exist but the query excluded them\n * all. With `shouldFilter = false` the consumer renders the result set itself,\n * so \"no items registered\" IS the empty result — requiring items to exist\n * would make `tolle-command-empty` permanently invisible in that mode.\n */\n get isEmpty(): boolean {\n if (!this.shouldFilter) return this.items.size === 0;\n return this.items.size > 0 && this.visibleSubject.value.length === 0;\n }\n\n register(item: Omit<CommandItemState, 'order'>): void {\n this.items.set(item.id, { ...item, order: this.nextOrder++ });\n this.recompute();\n }\n\n /** Keeps the stored search text/value in sync when an item's inputs change. */\n update(id: string, patch: Partial<Omit<CommandItemState, 'id' | 'order'>>): void {\n const existing = this.items.get(id);\n if (!existing) return;\n this.items.set(id, { ...existing, ...patch });\n this.recompute();\n }\n\n unregister(id: string): void {\n this.items.delete(id);\n this.recompute();\n }\n\n setQuery(query: string): void {\n this.querySubject.next(query);\n this.recompute();\n }\n\n isVisible(id: string): boolean {\n return this.visibleSubject.value.includes(id);\n }\n\n /** Moves the highlight by `delta` positions, wrapping at both ends. */\n move(delta: number): void {\n const ids = this.visibleSubject.value;\n if (!ids.length) return;\n const current = ids.indexOf(this.activeIdSubject.value ?? '');\n // From \"nothing active\", ArrowUp should land on the last item, not the second.\n const start = current < 0 ? (delta > 0 ? -1 : 0) : current;\n const next = (start + delta + ids.length) % ids.length;\n this.activeIdSubject.next(ids[next]);\n }\n\n setActive(id: string | null): void {\n if (id !== null && !this.isVisible(id)) return;\n this.activeIdSubject.next(id);\n }\n\n /** Selects the highlighted item, if there is one. */\n selectActive(): void {\n const id = this.activeIdSubject.value;\n if (!id) return;\n const item = this.items.get(id);\n if (!item || item.disabled) return;\n this.selectedSubject.next(item.value);\n }\n\n selectById(id: string): void {\n const item = this.items.get(id);\n if (!item || item.disabled) return;\n this.selectedSubject.next(item.value);\n }\n\n /** Recomputes the visible set and keeps the highlight on something valid. */\n private recompute(): void {\n const q = this.querySubject.value.trim().toLowerCase();\n\n const visible = [...this.items.values()]\n .filter((item) => !this.shouldFilter || !q || item.searchText.toLowerCase().includes(q))\n .sort((a, b) => a.order - b.order)\n .map((item) => item.id);\n\n this.visibleSubject.next(visible);\n\n // If the highlight fell out of the visible set (or there was none), move it\n // to the first selectable item so Enter always has a sensible target.\n const active = this.activeIdSubject.value;\n if (!active || !visible.includes(active)) {\n const firstEnabled = visible.find((id) => !this.items.get(id)?.disabled) ?? null;\n this.activeIdSubject.next(firstEnabled);\n }\n }\n}\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "utils/cn.ts",
|
|
22
|
+
"target": "ui/utils/cn.ts",
|
|
23
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "confirmation",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Confirmation",
|
|
5
|
+
"description": "Confirmation component.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "confirmation.component.ts",
|
|
14
|
+
"target": "ui/confirmation.component.ts",
|
|
15
|
+
"content": "import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, ChangeDetectorRef, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/** Whether the user has answered the prompt yet, and how. */\nexport type ConfirmationState = 'pending' | 'confirmed' | 'cancelled';\n\nconst confirmationVariants = cva('w-full rounded-lg border p-3 text-sm transition-colors', {\n variants: {\n state: {\n pending: 'border-border bg-card text-card-foreground',\n confirmed: 'border-success/40 bg-success/5 text-card-foreground',\n cancelled: 'border-destructive/40 bg-destructive/5 text-card-foreground',\n },\n },\n defaultVariants: { state: 'pending' },\n});\n\nconst confirmationIconVariants = cva('mt-0.5 shrink-0 text-base leading-none', {\n variants: {\n state: {\n pending: 'text-warning',\n confirmed: 'text-success',\n cancelled: 'text-destructive',\n },\n },\n defaultVariants: { state: 'pending' },\n});\n\nconst confirmationResolvedVariants = cva(\n 'inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium',\n {\n variants: {\n state: {\n pending: 'border-border text-muted-foreground',\n confirmed: 'border-success/40 bg-success/10 text-success',\n cancelled: 'border-destructive/40 bg-destructive/10 text-destructive',\n },\n },\n defaultVariants: { state: 'pending' },\n }\n);\n\nexport type ConfirmationProps = VariantProps<typeof confirmationVariants>;\n\n/** Remixicon class shown beside the prompt for each state. */\nconst CONFIRMATION_ICONS: Record<ConfirmationState, string> = {\n pending: 'ri-shield-check-line',\n confirmed: 'ri-checkbox-circle-fill',\n cancelled: 'ri-close-circle-fill',\n};\n\n/**\n * An inline approve / deny prompt for an agent action that needs a human to\n * sign off. While `state` is `'pending'` it shows both buttons; once the user\n * answers — or the host sets `state` — the buttons collapse into a compact\n * resolved row.\n *\n * ```html\n * <tolle-confirmation\n * title=\"Delete 12 rows from the orders table?\"\n * description=\"This cannot be undone.\"\n * (confirmed)=\"run()\"\n * (cancelled)=\"skip()\"\n * ></tolle-confirmation>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-confirmation',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" [attr.data-state]=\"currentState\" role=\"group\" [attr.aria-label]=\"title\">\n <div class=\"flex items-start gap-3\">\n <i [class]=\"iconClass\" aria-hidden=\"true\"></i>\n\n <div class=\"min-w-0 flex-1\">\n <p class=\"font-medium text-foreground\">{{ title }}</p>\n <p *ngIf=\"description\" class=\"mt-0.5 text-sm text-muted-foreground\">{{ description }}</p>\n\n <div class=\"empty:hidden\"><ng-content></ng-content></div>\n\n <div *ngIf=\"currentState === 'pending'\" class=\"mt-3 flex flex-wrap items-center gap-2\">\n <button type=\"button\" [disabled]=\"disabled\" [class]=\"confirmButtonClass\" (click)=\"confirm()\">\n <i class=\"ri-check-line\" aria-hidden=\"true\"></i>\n {{ confirmLabel }}\n </button>\n <button type=\"button\" [disabled]=\"disabled\" [class]=\"cancelButtonClass\" (click)=\"cancel()\">\n <i class=\"ri-close-line\" aria-hidden=\"true\"></i>\n {{ cancelLabel }}\n </button>\n </div>\n </div>\n\n <span *ngIf=\"currentState !== 'pending'\" [class]=\"resolvedClass\">\n <i [class]=\"iconName\" aria-hidden=\"true\"></i>\n {{ resolvedLabel }}\n </span>\n </div>\n </div>\n `,\n})\nexport class ConfirmationComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /** Headline describing the action awaiting approval. @default '' */\n @Input() title = '';\n /** Supporting detail shown under the title. @default '' */\n @Input() description = '';\n /** Label of the approve button. @default 'Approve' */\n @Input() confirmLabel = 'Approve';\n /** Label of the deny button. @default 'Deny' */\n @Input() cancelLabel = 'Deny';\n /** Text shown in the resolved row after approval. @default 'Approved' */\n @Input() confirmedLabel = 'Approved';\n /** Text shown in the resolved row after denial. @default 'Denied' */\n @Input() cancelledLabel = 'Denied';\n /** Whether the prompt is still open, and how it was answered. @default 'pending' */\n @Input() state: ConfirmationState = 'pending';\n /** Blocks both buttons without resolving the prompt. @default false */\n @Input() disabled = false;\n /** Extra Tailwind classes merged onto the prompt via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the user approves the action. */\n @Output() confirmed = new EventEmitter<void>();\n /** Emitted when the user denies the action. */\n @Output() cancelled = new EventEmitter<void>();\n\n /**\n * Set when the user answers, so the prompt resolves on its own without the\n * host having to bind `state` back. Cleared whenever `state` is pushed in.\n */\n private answer: ConfirmationState | null = null;\n\n ngOnChanges(changes: SimpleChanges): void {\n if (changes['state']) this.answer = null;\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 /** The state actually rendered: the user's answer if there is one, else `state`. */\n get currentState(): ConfirmationState {\n return this.answer ?? this.state;\n }\n\n get resolvedLabel(): string {\n return this.currentState === 'confirmed' ? this.confirmedLabel : this.cancelledLabel;\n }\n\n get iconName(): string {\n return CONFIRMATION_ICONS[this.currentState];\n }\n\n get computedClass() {\n return cn(confirmationVariants({ state: this.currentState }), this.class);\n }\n\n get iconClass() {\n return cn(confirmationIconVariants({ state: this.currentState }), this.iconName);\n }\n\n get resolvedClass() {\n return cn(confirmationResolvedVariants({ state: this.currentState }));\n }\n\n get confirmButtonClass() {\n return cn(\n 'inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground transition-colors',\n 'hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50'\n );\n }\n\n get cancelButtonClass() {\n return cn(\n 'inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-3 text-xs font-medium text-foreground transition-colors',\n 'hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50'\n );\n }\n\n confirm(): void {\n if (this.disabled || this.currentState !== 'pending') return;\n this.answer = 'confirmed';\n this.confirmed.emit();\n }\n\n cancel(): void {\n if (this.disabled || this.currentState !== 'pending') return;\n this.answer = 'cancelled';\n this.cancelled.emit();\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,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "context",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Context",
|
|
5
|
+
"description": "Context component.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"hover-card"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@floating-ui/dom",
|
|
12
|
+
"class-variance-authority"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
{
|
|
16
|
+
"path": "context.component.ts",
|
|
17
|
+
"target": "ui/context.component.ts",
|
|
18
|
+
"content": "import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Injectable, Input, OnChanges, OnDestroy, OnInit, forwardRef, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { BehaviorSubject, Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport {\n HoverCardComponent,\n HoverCardTriggerComponent,\n HoverCardContentComponent,\n} from './hover-card.component';\n\n/** How close the context window is to being full. */\nexport type ContextLevel = 'normal' | 'warning' | 'critical';\n\n/** Everything a `tolle-context` knows about the current window. */\nexport interface ContextUsage {\n used: number;\n total: number;\n inputTokens: number | null;\n outputTokens: number | null;\n cost: number | null;\n /** Fraction of the window consumed, clamped to 0–1. */\n ratio: number;\n level: ContextLevel;\n}\n\nconst contextTriggerVariants = cva(\n 'inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-medium tabular-nums transition-colors',\n {\n variants: {\n level: {\n normal: 'text-muted-foreground hover:text-foreground',\n warning: 'text-warning hover:text-warning',\n critical: 'text-destructive hover:text-destructive',\n },\n },\n defaultVariants: { level: 'normal' },\n }\n);\n\nconst contextBarVariants = cva('h-full rounded-full transition-[width,background-color] duration-300', {\n variants: {\n level: {\n normal: 'bg-primary',\n warning: 'bg-warning',\n critical: 'bg-destructive',\n },\n },\n defaultVariants: { level: 'normal' },\n});\n\nexport type ContextProps = VariantProps<typeof contextTriggerVariants>;\n\n/** Formats a raw token count as a short, readable string. */\nexport function formatTokens(value: number): string {\n if (!isFinite(value)) return '0';\n const abs = Math.abs(value);\n if (abs >= 1_000_000) return (value / 1_000_000).toFixed(1).replace(/\\.0$/, '') + 'M';\n if (abs >= 1_000) return (value / 1_000).toFixed(1).replace(/\\.0$/, '') + 'K';\n return String(Math.round(value));\n}\n\n/**\n * Publishes the parent `tolle-context`'s numbers to its OnPush trigger and\n * content children, which receive none of it as their own inputs.\n */\n@Injectable()\nexport class ContextService {\n private readonly usageSource = new BehaviorSubject<ContextUsage>({\n used: 0,\n total: 0,\n inputTokens: null,\n outputTokens: null,\n cost: null,\n ratio: 0,\n level: 'normal',\n });\n\n /** Emits the current context-window usage. */\n readonly usage$ = this.usageSource.asObservable();\n\n get usage(): ContextUsage {\n return this.usageSource.value;\n }\n\n setUsage(usage: ContextUsage): void {\n this.usageSource.next(usage);\n }\n}\n\n/**\n * A token / context-window usage indicator: a compact percentage and bar that\n * shifts to the warning and destructive tokens as the window fills, with a\n * hover breakdown of input, output and cost.\n *\n * ```html\n * <tolle-context [used]=\"82000\" [total]=\"128000\" [inputTokens]=\"61000\" [outputTokens]=\"21000\" [cost]=\"0.42\"></tolle-context>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-context',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [\n CommonModule,\n HoverCardComponent,\n HoverCardTriggerComponent,\n HoverCardContentComponent,\n // The sub-components are declared further down this file, so they are\n // referenced lazily rather than read at decoration time.\n forwardRef(() => ContextTriggerComponent),\n forwardRef(() => ContextContentComponent),\n ],\n providers: [ContextService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <tolle-hover-card\n [openDelay]=\"openDelay\"\n [closeDelay]=\"closeDelay\"\n [placement]=\"placement\"\n (mouseenter)=\"scheduleRefresh(openDelay)\"\n (mouseleave)=\"scheduleRefresh(closeDelay)\"\n >\n <tolle-hover-card-trigger>\n <tolle-context-trigger></tolle-context-trigger>\n </tolle-hover-card-trigger>\n <tolle-hover-card-content class=\"w-60 p-0\">\n <tolle-context-content></tolle-context-content>\n </tolle-hover-card-content>\n </tolle-hover-card>\n `,\n})\nexport class ContextComponent implements OnInit, OnChanges, OnDestroy {\n /** Tokens consumed so far. @default 0 */\n @Input() used = 0;\n /** Size of the context window in tokens. @default 0 */\n @Input() total = 0;\n /** Tokens spent on the prompt, shown in the breakdown. @default null */\n @Input() inputTokens: number | null = null;\n /** Tokens produced by the model, shown in the breakdown. @default null */\n @Input() outputTokens: number | null = null;\n /** Spend for this context, shown in the breakdown. @default null */\n @Input() cost: number | null = null;\n /** Ratio at or above which the indicator turns warning-coloured. @default 0.75 */\n @Input() warningThreshold = 0.75;\n /** Ratio at or above which the indicator turns destructive-coloured. @default 0.9 */\n @Input() criticalThreshold = 0.9;\n /** Side of the indicator the breakdown opens on. @default 'top' */\n @Input() placement: 'top' | 'bottom' | 'left' | 'right' = 'top';\n /** Milliseconds to wait before opening the breakdown. @default 150 */\n @Input() openDelay = 150;\n /** Milliseconds to wait before closing the breakdown. @default 200 */\n @Input() closeDelay = 200;\n /** Extra Tailwind classes merged onto the indicator via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ContextService);\n private readonly cdr = inject(ChangeDetectorRef);\n private refreshTimer?: ReturnType<typeof setTimeout>;\n\n ngOnInit(): void {\n this.push();\n }\n\n ngOnChanges(): void {\n this.push();\n }\n\n /** Fraction of the window consumed, clamped to 0–1. */\n get ratio(): number {\n if (!this.total || this.total <= 0) return 0;\n return Math.min(1, Math.max(0, this.used / this.total));\n }\n\n /** Which colour band the current usage falls into. */\n get level(): ContextLevel {\n const ratio = this.ratio;\n if (ratio >= this.criticalThreshold) return 'critical';\n if (ratio >= this.warningThreshold) return 'warning';\n return 'normal';\n }\n\n private push(): void {\n this.service.setUsage({\n used: this.used,\n total: this.total,\n inputTokens: this.inputTokens,\n outputTokens: this.outputTokens,\n cost: this.cost,\n ratio: this.ratio,\n level: this.level,\n });\n }\n\n /**\n * `tolle-hover-card` toggles its own visibility from a `setTimeout`, which\n * never marks this OnPush view dirty — re-check once the delay has elapsed.\n */\n scheduleRefresh(delay: number): void {\n clearTimeout(this.refreshTimer);\n this.refreshTimer = setTimeout(() => this.cdr.markForCheck(), delay + 32);\n }\n\n ngOnDestroy(): void {\n clearTimeout(this.refreshTimer);\n }\n}\n\n/** The always-visible part of a `tolle-context`: percentage plus usage bar. */\n@Component({\n selector: 'tolle-context-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <span\n role=\"meter\"\n [attr.aria-valuenow]=\"percent\"\n aria-valuemin=\"0\"\n aria-valuemax=\"100\"\n [attr.aria-label]=\"'Context window ' + percent + '% used'\"\n [attr.data-level]=\"usage.level\"\n [class]=\"computedClass\"\n >\n <span class=\"h-1.5 w-10 overflow-hidden rounded-full bg-muted\">\n <span [class]=\"barClass\" [style.width.%]=\"percent\"></span>\n </span>\n <span>{{ percent }}%</span>\n <ng-content></ng-content>\n </span>\n `,\n})\nexport class ContextTriggerComponent implements OnChanges, OnInit, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ContextService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n usage: ContextUsage = this.service.usage;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.usage$.subscribe((usage) => {\n this.usage = usage;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n /** Usage as a whole percentage. */\n get percent(): number {\n return Math.round(this.usage.ratio * 100);\n }\n\n get computedClass() {\n return cn(contextTriggerVariants({ level: this.usage.level }), this.class);\n }\n\n get barClass() {\n return cn('block', contextBarVariants({ level: this.usage.level }));\n }\n}\n\n/** The hover breakdown of a `tolle-context`: used, total, input, output, cost. */\n@Component({\n selector: 'tolle-context-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div class=\"flex items-baseline justify-between gap-2\">\n <span class=\"text-sm font-medium text-popover-foreground\">Context</span>\n <span class=\"text-xs tabular-nums text-muted-foreground\">{{ tokens(usage.used) }} / {{ tokens(usage.total) }}</span>\n </div>\n <dl class=\"mt-2 flex flex-col gap-1 text-xs\">\n <div *ngIf=\"usage.inputTokens !== null\" class=\"flex items-center justify-between gap-2\">\n <dt class=\"text-muted-foreground\">Input</dt>\n <dd class=\"tabular-nums text-popover-foreground\">{{ tokens(usage.inputTokens) }}</dd>\n </div>\n <div *ngIf=\"usage.outputTokens !== null\" class=\"flex items-center justify-between gap-2\">\n <dt class=\"text-muted-foreground\">Output</dt>\n <dd class=\"tabular-nums text-popover-foreground\">{{ tokens(usage.outputTokens) }}</dd>\n </div>\n <div *ngIf=\"usage.cost !== null\" class=\"flex items-center justify-between gap-2\">\n <dt class=\"text-muted-foreground\">Cost</dt>\n <dd class=\"tabular-nums text-popover-foreground\">{{ currency }}{{ usage.cost }}</dd>\n </div>\n </dl>\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ContextContentComponent 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 /** Symbol prefixed to the cost figure. @default '$' */\n @Input() currency = '$';\n /** Extra Tailwind classes merged onto the breakdown via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ContextService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly sub = new Subscription();\n\n usage: ContextUsage = this.service.usage;\n protected tokens = formatTokens;\n\n ngOnInit(): void {\n this.sub.add(\n this.service.usage$.subscribe((usage) => {\n this.usage = usage;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.sub.unsubscribe();\n }\n\n get computedClass() {\n return cn('flex flex-col p-3', this.class);\n }\n}\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "utils/cn.ts",
|
|
22
|
+
"target": "ui/utils/cn.ts",
|
|
23
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "conversation",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Conversation",
|
|
5
|
+
"description": "Conversation component with size options.",
|
|
6
|
+
"category": "ai",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "conversation.component.ts",
|
|
14
|
+
"target": "ui/conversation.component.ts",
|
|
15
|
+
"content": "import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, Injectable, Input, OnChanges, OnDestroy, OnInit, Output, ViewChild, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { BehaviorSubject, Subject, Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/**\n * Coordinates a `tolle-conversation` and its projected parts.\n *\n * The transcript owns the scroll position; the empty state and the\n * \"jump to latest\" button are OnPush children that cannot read the parent's\n * fields directly, so everything they need is published here as an observable.\n *\n * Provided on `ConversationComponent`, so each transcript gets its own instance.\n */\n@Injectable()\nexport class ConversationService {\n private readonly atBottomSource = new BehaviorSubject<boolean>(true);\n /** Emits whether the transcript is scrolled to (or near) the newest message. */\n readonly atBottom$ = this.atBottomSource.asObservable();\n\n private readonly emptySource = new BehaviorSubject<boolean>(false);\n /** Emits whether the transcript has no messages yet. */\n readonly empty$ = this.emptySource.asObservable();\n\n private readonly scrollRequestSource = new Subject<ScrollBehavior>();\n /** Emits when a child asks the transcript to scroll to the newest message. */\n readonly scrollRequest$ = this.scrollRequestSource.asObservable();\n\n /** True while the transcript is pinned to the bottom. */\n get atBottom(): boolean {\n return this.atBottomSource.value;\n }\n\n /** True while the transcript is empty. */\n get empty(): boolean {\n return this.emptySource.value;\n }\n\n setAtBottom(value: boolean): void {\n if (this.atBottomSource.value !== value) this.atBottomSource.next(value);\n }\n\n setEmpty(value: boolean): void {\n if (this.emptySource.value !== value) this.emptySource.next(value);\n }\n\n /** Asks the transcript to scroll to the newest message. */\n requestScrollToBottom(behavior: ScrollBehavior = 'smooth'): void {\n this.scrollRequestSource.next(behavior);\n }\n}\n\nconst conversationVariants = cva('relative flex w-full flex-col overflow-hidden', {\n variants: {\n size: {\n sm: 'max-h-72',\n default: 'max-h-[32rem]',\n lg: 'max-h-[48rem]',\n full: 'h-full max-h-full',\n },\n },\n defaultVariants: {\n size: 'default',\n },\n});\n\nexport type ConversationProps = VariantProps<typeof conversationVariants>;\n\n/**\n * A scrollable chat transcript. Announces new messages politely, keeps itself\n * pinned to the newest message while the reader is already at the bottom, and\n * hosts the empty state plus the floating \"jump to latest\" button.\n *\n * Compose it from `tolle-conversation-content`,\n * `tolle-conversation-empty-state` and `tolle-conversation-scroll-button`.\n * @new\n */\n@Component({\n selector: 'tolle-conversation',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [ConversationService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div\n #viewport\n data-conversation-viewport\n role=\"log\"\n aria-live=\"polite\"\n aria-relevant=\"additions text\"\n [attr.aria-label]=\"ariaLabel || null\"\n [class]=\"viewportComputedClass\"\n (scroll)=\"onScroll()\"\n >\n <ng-content></ng-content>\n </div>\n\n <ng-content select=\"tolle-conversation-scroll-button\"></ng-content>\n </div>\n `,\n})\nexport class ConversationComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /** Height preset for the scroll region. @default 'default' */\n @Input() size: ConversationProps['size'] = 'default';\n /** Keeps the newest message in view while the reader is already at the bottom. @default true */\n @Input() autoScroll = true;\n /** Set true before the first message so the empty state shows. @default false */\n @Input() empty = false;\n /** Distance from the bottom, in px, still counted as \"at the bottom\". @default 32 */\n @Input() threshold = 32;\n /** Accessible name for the transcript. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the transcript via `cn()` (last-wins). */\n @Input() class = '';\n /** Extra Tailwind classes merged onto the scrolling viewport via `cn()` (last-wins). */\n @Input() viewportClass = '';\n\n /** Emitted whenever the transcript becomes pinned to, or leaves, the bottom. */\n @Output() atBottomChange = new EventEmitter<boolean>();\n\n @ViewChild('viewport') private viewport?: ElementRef<HTMLElement>;\n\n private readonly service = inject(ConversationService);\n private readonly subscriptions = new Subscription();\n private observer?: MutationObserver;\n\n ngOnInit(): void {\n this.service.setEmpty(this.empty);\n this.subscriptions.add(\n this.service.scrollRequest$.subscribe((behavior) => this.scrollToBottom(behavior))\n );\n }\n\n ngOnChanges(): void {\n this.service.setEmpty(this.empty);\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 ngAfterViewInit(): void {\n this.measure();\n\n // New messages arrive as DOM mutations rather than as inputs on this\n // component, so watching the subtree is the only content-agnostic way to\n // keep a transcript of projected messages pinned to the bottom.\n if (typeof MutationObserver !== 'undefined' && this.viewport) {\n this.observer = new MutationObserver(() => this.onContentChanged());\n this.observer.observe(this.viewport.nativeElement, {\n childList: true,\n subtree: true,\n characterData: true,\n });\n }\n }\n\n ngOnDestroy(): void {\n this.observer?.disconnect();\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(conversationVariants({ size: this.size }), this.class);\n }\n\n get viewportComputedClass() {\n return cn('flex-1 overflow-y-auto overflow-x-hidden outline-none', this.viewportClass);\n }\n\n /** Scrolls the transcript to the newest message. */\n scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {\n const el = this.viewport?.nativeElement;\n if (!el) return;\n if (typeof el.scrollTo === 'function') {\n el.scrollTo({ top: el.scrollHeight, behavior });\n } else {\n el.scrollTop = el.scrollHeight;\n }\n this.measure();\n }\n\n /** Recomputes whether the viewport is at the bottom and publishes the result. */\n onScroll(): void {\n this.measure();\n }\n\n private onContentChanged(): void {\n if (this.autoScroll && this.service.atBottom) {\n this.scrollToBottom('auto');\n } else {\n this.measure();\n }\n }\n\n private measure(): void {\n const el = this.viewport?.nativeElement;\n if (!el) return;\n const distance = el.scrollHeight - el.scrollTop - el.clientHeight;\n const next = distance <= this.threshold;\n if (next !== this.service.atBottom) {\n this.service.setAtBottom(next);\n this.atBottomChange.emit(next);\n }\n }\n}\n\n/** Vertical stack of messages inside a `tolle-conversation`. */\n@Component({\n selector: 'tolle-conversation-content',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ConversationContentComponent implements OnChanges{\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the stack via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn('flex flex-col gap-4 p-4', this.class);\n }\n}\n\n/**\n * Placeholder shown while a `tolle-conversation` has no messages. Renders\n * nothing once the transcript reports content, so it can stay in the template.\n */\n@Component({\n selector: 'tolle-conversation-empty-state',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div *ngIf=\"visible\" data-conversation-empty [class]=\"computedClass\">\n <div\n *ngIf=\"icon\"\n class=\"flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground\"\n >\n <i [class]=\"icon\" aria-hidden=\"true\"></i>\n </div>\n\n <p *ngIf=\"title\" class=\"text-sm font-medium text-foreground\">{{ title }}</p>\n <p *ngIf=\"description\" class=\"max-w-sm text-sm text-muted-foreground\">{{ description }}</p>\n\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class ConversationEmptyStateComponent 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 for the illustration. @default 'ri-chat-3-line' */\n @Input() icon = 'ri-chat-3-line';\n /** Headline shown above the description. @default 'No messages yet' */\n @Input() title = 'No messages yet';\n /** Supporting copy under the headline. @default 'Start the conversation by sending a message.' */\n @Input() description = 'Start the conversation by sending a message.';\n /** Extra Tailwind classes merged onto the empty state via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(ConversationService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n visible = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.empty$.subscribe((empty) => {\n this.visible = empty;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(\n 'flex flex-col items-center justify-center gap-2 px-6 py-12 text-center',\n this.class\n );\n }\n}\n\nconst conversationScrollButtonVariants = cva(\n 'absolute bottom-4 left-1/2 z-10 inline-flex -translate-x-1/2 items-center justify-center gap-1.5 rounded-full border shadow-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',\n {\n variants: {\n variant: {\n default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/90',\n secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',\n outline: 'border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground',\n },\n size: {\n sm: 'h-7 px-2 text-xs',\n default: 'h-8 px-3 text-xs',\n icon: 'size-8 p-0 text-sm',\n },\n },\n defaultVariants: {\n variant: 'outline',\n size: 'default',\n },\n }\n);\n\nexport type ConversationScrollButtonProps = VariantProps<typeof conversationScrollButtonVariants>;\n\n/**\n * Floating \"jump to latest\" control. Appears only while the transcript is\n * scrolled away from the newest message, and scrolls it back on click.\n */\n@Component({\n selector: 'tolle-conversation-scroll-button',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <button\n *ngIf=\"visible\"\n type=\"button\"\n data-conversation-scroll-button\n [attr.aria-label]=\"label\"\n [class]=\"computedClass\"\n (click)=\"onClick()\"\n >\n <i class=\"ri-arrow-down-line\" aria-hidden=\"true\"></i>\n <span *ngIf=\"size !== 'icon'\">{{ label }}</span>\n </button>\n `,\n})\nexport class ConversationScrollButtonComponent 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 /** Visual style of the button. @default 'outline' */\n @Input() variant: ConversationScrollButtonProps['variant'] = 'outline';\n /** Size of the button; 'icon' drops the text label. @default 'default' */\n @Input() size: ConversationScrollButtonProps['size'] = 'default';\n /** Button text and accessible name. @default 'Jump to latest' */\n @Input() label = 'Jump to latest';\n /** Extra Tailwind classes merged onto the button via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the reader jumps back to the newest message. */\n @Output() jumped = new EventEmitter<void>();\n\n private readonly service = inject(ConversationService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n visible = false;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.atBottom$.subscribe((atBottom) => {\n this.visible = !atBottom;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(\n conversationScrollButtonVariants({ variant: this.variant, size: this.size }),\n this.class\n );\n }\n\n onClick(): void {\n this.service.requestScrollToBottom('smooth');\n this.jumped.emit();\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
|
+
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
{
|
|
21
21
|
"path": "country-selector.component.ts",
|
|
22
22
|
"target": "ui/country-selector.component.ts",
|
|
23
|
-
"content": "import {\n Component,\n Input,\n forwardRef,\n inject,\n OnInit,\n ViewChild,\n ElementRef,\n ChangeDetectorRef,\n Output,\n EventEmitter,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';\nimport { CountryCodesService } from './country-codes.service';\nimport { PopoverComponent } from './popover.component';\nimport { InputComponent } from './input.component';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-country-selector',\n standalone: true,\n imports: [CommonModule, FormsModule, PopoverComponent, InputComponent],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => CountrySelectorComponent),\n multi: true,\n },\n ],\n template: `\n <div class=\"flex w-full flex-col gap-1.5\">\n <label *ngIf=\"label\" [for]=\"id\" [class]=\"computedLabelClass\">\n {{ label }}\n </label>\n\n <tolle-popover\n #popover\n [placement]=\"'bottom-start'\"\n (onOpen)=\"onPopoverOpen()\"\n (onClose)=\"onPopoverClose()\">\n <div trigger class=\"w-full\">\n <button\n type=\"button\"\n [id]=\"id\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"popover?.isOpen\"\n [attr.aria-activedescendant]=\"popover?.isOpen ? activeDescendantId : null\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n [attr.aria-invalid]=\"error\"\n [attr.aria-describedby]=\"error && errorMessage ? id + '-error' : null\">\n <div class=\"flex items-center gap-2 truncate\">\n <img\n *ngIf=\"selectedCountry\"\n [src]=\"getFlagUrl(selectedCountry.flag)\"\n class=\"h-4 w-6 flex-shrink-0 rounded-sm border border-border object-cover\"\n [alt]=\"selectedCountry.name\" />\n <span *ngIf=\"selectedCountry && showName\" class=\"truncate font-medium\">\n {{ selectedCountry.name }}\n </span>\n <span *ngIf=\"!selectedCountry\" class=\"truncate text-muted-foreground\">\n {{ placeholder }}\n </span>\n </div>\n <i [class]=\"iconClass\"></i>\n </button>\n </div>\n\n <div\n class=\"flex min-w-[300px] max-w-[400px] flex-col overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md\"\n (mousedown)=\"$event.stopPropagation()\">\n <div class=\"sticky top-0 z-10 border-b border-border bg-popover p-2 shadow-sm\">\n <tolle-input\n size=\"sm\"\n placeholder=\"Search country...\"\n [(ngModel)]=\"searchQuery\"\n (ngModelChange)=\"filterCountries($event)\"\n (keydown)=\"onSearchKeyDown($event)\"\n class=\"w-full\"\n #searchInput>\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n </div>\n\n <div role=\"listbox\" class=\"max-h-[300px] overflow-y-auto p-1\">\n <div\n *ngFor=\"let country of shadowCountries; let i = index\"\n [id]=\"id + '-option-' + i\"\n role=\"option\"\n [attr.aria-selected]=\"selectedCountry?.isoAlpha2 === country.isoAlpha2\"\n [attr.data-highlighted]=\"i === activeIndex ? '' : null\"\n (click)=\"selectCountry(country)\"\n [class]=\"getItemClass(country, i)\">\n <div class=\"flex w-full items-center gap-3\">\n <img\n [src]=\"getFlagUrl(country.flag)\"\n class=\"h-4 w-6 flex-shrink-0 rounded-sm border border-border object-cover\"\n [alt]=\"country.name\" />\n <span class=\"flex-1 truncate text-sm\">{{ country.name }}</span>\n <span class=\"text-xs text-muted-foreground\">{{ country.dialCode }}</span>\n <i\n *ngIf=\"selectedCountry?.isoAlpha2 === country.isoAlpha2\"\n class=\"ri-check-line text-primary\"></i>\n </div>\n </div>\n <div\n *ngIf=\"shadowCountries.length === 0\"\n class=\"py-6 text-center text-sm text-muted-foreground\">\n No countries found.\n </div>\n </div>\n </div>\n </tolle-popover>\n\n <ng-container *ngIf=\"!disabled\">\n <p\n *ngIf=\"hint && !error\"\n class=\"px-1 text-xs text-muted-foreground transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideHintOnFocus\">\n {{ hint }}\n </p>\n <p\n *ngIf=\"error && errorMessage\"\n [id]=\"id + '-error'\"\n class=\"px-1 text-xs font-medium text-destructive\">\n {{ errorMessage }}\n </p>\n </ng-container>\n </div>\n `,\n})\nexport class CountrySelectorComponent implements OnInit, ControlValueAccessor {\n @Input() id = `country-selector-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label = '';\n @Input() hint = '';\n @Input() errorMessage = '';\n @Input() error = false;\n @Input() hideHintOnFocus = true;\n @Input() placeholder = 'Select country';\n @Input() class = '';\n @Input() disabled = false;\n @Input() readonly = false;\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() defaultCountryCode = 'GH';\n @Input() returnValue: 'object' | 'isoAlpha2' | 'dialCode' | 'name' = 'isoAlpha2';\n @Input() showName = true;\n @Input() externalFocused: boolean | undefined;\n\n @Output() onSelect = new EventEmitter<any>();\n @Output() onFocusChange = new EventEmitter<void>();\n @Output() onBlurChange = new EventEmitter<void>();\n\n @ViewChild('popover') popover!: PopoverComponent;\n @ViewChild('searchInput', { read: ElementRef }) searchInput?: ElementRef<HTMLElement>;\n\n private countryCodesService = inject(CountryCodesService);\n private sanitizer = inject(DomSanitizer);\n private cdr = inject(ChangeDetectorRef);\n\n value: any = null;\n selectedCountry: any = null;\n searchQuery = '';\n shadowCountries: any[] = [];\n isFocused = false;\n\n // Keyboard navigation (active-descendant) state over the filtered rows\n activeIndex = -1;\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n protected cn = cn;\n\n ngOnInit() {\n this.shadowCountries = this.countryCodesService.countries;\n if (this.defaultCountryCode && !this.value) {\n this.selectedCountry = this.countryCodesService.countries.find(\n c => c.isoAlpha2 === this.defaultCountryCode\n );\n if (this.selectedCountry) {\n this.value = this.getReturnValue(this.selectedCountry);\n }\n }\n }\n\n get computedTriggerClass() {\n const focused = this.externalFocused !== undefined ? this.externalFocused : this.isFocused;\n return cn(\n 'flex w-full items-center justify-between 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 // Rounded corners\n 'rounded-md',\n // Focus state\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 'shadow-none',\n this.error ? 'focus-visible:border-destructive/80' : 'focus-visible:border-primary/80',\n ],\n // External focus state (when controlled by parent)\n !(this.readonly || this.disabled) &&\n focused && [\n 'ring-4',\n 'ring-ring/30',\n 'ring-offset-0',\n 'shadow-none',\n this.error ? 'border-destructive/80' : 'border-primary/80',\n ],\n !(this.readonly || this.disabled) && 'hover:border-accent',\n // Error state\n this.error && [\n 'border-destructive',\n !(this.readonly || this.disabled) && focused && 'ring-destructive/30',\n ],\n this.disabled && 'cursor-not-allowed opacity-50 border-opacity-50',\n this.readonly && 'cursor-default border-dashed',\n this.class\n );\n }\n\n get computedLabelClass() {\n return cn(\n 'text-sm font-medium text-foreground leading-none transition-opacity duration-200',\n this.disabled && 'opacity-50'\n );\n }\n\n get iconClass() {\n return cn(\n 'ri-arrow-down-s-line text-muted-foreground ml-2 transition-transform duration-200',\n this.popover?.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 getItemClass(country: any, index: number) {\n const isSelected = this.selectedCountry?.isoAlpha2 === country.isoAlpha2;\n const isActive = index === this.activeIndex;\n return cn(\n 'flex items-center justify-between px-3 py-2 cursor-pointer transition-colors duration-150 rounded-sm w-full',\n isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50 text-foreground',\n // Active-descendant (keyboard) highlight\n isActive && 'bg-accent text-accent-foreground'\n );\n }\n\n get activeDescendantId(): string | null {\n return this.activeIndex >= 0 && this.activeIndex < this.shadowCountries.length\n ? `${this.id}-option-${this.activeIndex}`\n : null;\n }\n\n getFlagUrl(flagBase64: string): SafeResourceUrl {\n return this.sanitizer.bypassSecurityTrustResourceUrl('data:image/*;base64,' + flagBase64);\n }\n\n filterCountries(query: string) {\n const filter = (query || '').toLowerCase().trim();\n this.shadowCountries = this.countryCodesService.countries.filter(\n c =>\n c.name.toLowerCase().includes(filter) ||\n c.dialCode.includes(filter) ||\n c.isoAlpha2.toLowerCase().includes(filter)\n );\n // Re-anchor the active row to the first match after filtering.\n this.activeIndex = this.shadowCountries.length ? 0 : -1;\n this.syncActiveDescendant();\n }\n\n selectCountry(country: any) {\n this.selectedCountry = country;\n this.value = this.getReturnValue(country);\n this.onChange(this.value);\n this.onSelect.emit(country);\n this.popover.close();\n }\n\n private getReturnValue(country: any) {\n switch (this.returnValue) {\n case 'object':\n return country;\n case 'isoAlpha2':\n return country.isoAlpha2;\n case 'dialCode':\n return country.dialCode;\n case 'name':\n return country.name;\n default:\n return country.isoAlpha2;\n }\n }\n\n onFocus(): void {\n this.isFocused = true;\n this.onFocusChange.emit();\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n this.onBlurChange.emit();\n }\n\n onPopoverOpen() {\n // Anchor the active row to the currently selected country (or the first).\n const selIdx = this.selectedCountry\n ? this.shadowCountries.findIndex(c => c.isoAlpha2 === this.selectedCountry.isoAlpha2)\n : -1;\n this.activeIndex = selIdx >= 0 ? selIdx : (this.shadowCountries.length ? 0 : -1);\n setTimeout(() => {\n this.getSearchInputEl()?.focus();\n this.syncActiveDescendant();\n this.scrollActiveIntoView();\n }, 0);\n }\n\n onPopoverClose() {\n this.searchQuery = '';\n this.filterCountries('');\n this.activeIndex = -1;\n this.onBlur();\n }\n\n // ---- Keyboard navigation (WAI-ARIA listbox/combobox pattern) ----\n\n private getSearchInputEl(): HTMLInputElement | null {\n return this.searchInput?.nativeElement?.querySelector('input') ?? null;\n }\n\n private syncActiveDescendant() {\n const input = this.getSearchInputEl();\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 scrollActiveIntoView() {\n if (this.activeIndex < 0) return;\n document\n .getElementById(`${this.id}-option-${this.activeIndex}`)\n ?.scrollIntoView({ block: 'nearest' });\n }\n\n private moveActive(delta: number) {\n if (!this.shadowCountries.length) return;\n const next = this.activeIndex < 0\n ? (delta > 0 ? 0 : this.shadowCountries.length - 1)\n : this.activeIndex + delta;\n // Clamp (no wrap, matching shadcn).\n this.activeIndex = Math.max(0, Math.min(next, this.shadowCountries.length - 1));\n this.syncActiveDescendant();\n this.scrollActiveIntoView();\n }\n\n private selectActive() {\n const country = this.shadowCountries[this.activeIndex];\n if (!country) return;\n this.selectCountry(country); // closes the popover\n document.getElementById(this.id)?.focus();\n }\n\n onTriggerKeyDown(event: KeyboardEvent) {\n if (this.disabled || this.readonly) return;\n if (this.popover?.isOpen) return; // once open, focus moves to the search input\n const key = event.key;\n if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ' || key === 'Home' || key === 'End') {\n event.preventDefault();\n this.popover.open();\n } else if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {\n // Any printable character opens the popover (focus moves to the search box).\n this.popover.open();\n }\n }\n\n // Keydown while the search input is focused. Arrows/Enter/Escape drive the\n // list; other keys (incl. Home/End for caret) fall through to text editing.\n onSearchKeyDown(event: KeyboardEvent) {\n if (!this.popover?.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.popover.close();\n document.getElementById(this.id)?.focus();\n break;\n }\n }\n\n writeValue(value: any): void {\n this.value = value;\n if (value) {\n this.selectedCountry = this.countryCodesService.countries.find(c => {\n if (this.returnValue === 'object') return c.isoAlpha2 === value.isoAlpha2;\n if (this.returnValue === 'isoAlpha2') return c.isoAlpha2 === value;\n if (this.returnValue === 'dialCode') return c.dialCode === value;\n if (this.returnValue === 'name') return c.name === value;\n return false;\n });\n } else {\n this.selectedCountry = null;\n }\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
|
|
23
|
+
"content": "import {\n Component,\n Input,\n forwardRef,\n inject,\n OnInit,\n ViewChild,\n ElementRef,\n ChangeDetectorRef,\n Output,\n EventEmitter,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';\nimport { CountryCodesService } from './country-codes.service';\nimport { PopoverComponent } from './popover.component';\nimport { InputComponent } from './input.component';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-country-selector',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule, PopoverComponent, InputComponent],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => CountrySelectorComponent),\n multi: true,\n },\n ],\n template: `\n <div class=\"flex w-full flex-col gap-1.5\">\n <label *ngIf=\"label\" [for]=\"id\" [class]=\"computedLabelClass\">\n {{ label }}\n </label>\n\n <tolle-popover\n #popover\n [placement]=\"'bottom-start'\"\n (onOpen)=\"onPopoverOpen()\"\n (onClose)=\"onPopoverClose()\">\n <div trigger class=\"w-full\">\n <button\n type=\"button\"\n [id]=\"id\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"popover?.isOpen\"\n [attr.aria-activedescendant]=\"popover?.isOpen ? activeDescendantId : null\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n [attr.aria-invalid]=\"error\"\n [attr.aria-describedby]=\"error && errorMessage ? id + '-error' : null\">\n <div class=\"flex items-center gap-2 truncate\">\n <img\n *ngIf=\"selectedCountry\"\n [src]=\"getFlagUrl(selectedCountry.flag)\"\n class=\"h-4 w-6 flex-shrink-0 rounded-sm border border-border object-cover\"\n [alt]=\"selectedCountry.name\" />\n <span *ngIf=\"selectedCountry && showName\" class=\"truncate font-medium\">\n {{ selectedCountry.name }}\n </span>\n <span *ngIf=\"!selectedCountry\" class=\"truncate text-muted-foreground\">\n {{ placeholder }}\n </span>\n </div>\n <i [class]=\"iconClass\"></i>\n </button>\n </div>\n\n <div\n class=\"flex min-w-[300px] max-w-[400px] flex-col overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md\"\n (mousedown)=\"$event.stopPropagation()\">\n <div class=\"sticky top-0 z-10 border-b border-border bg-popover p-2 shadow-sm\">\n <tolle-input\n size=\"sm\"\n placeholder=\"Search country...\"\n [(ngModel)]=\"searchQuery\"\n (ngModelChange)=\"filterCountries($event)\"\n (keydown)=\"onSearchKeyDown($event)\"\n class=\"w-full\"\n #searchInput>\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n </div>\n\n <div role=\"listbox\" class=\"max-h-[300px] overflow-y-auto p-1\">\n <div\n *ngFor=\"let country of shadowCountries; let i = index\"\n [id]=\"id + '-option-' + i\"\n role=\"option\"\n [attr.aria-selected]=\"selectedCountry?.isoAlpha2 === country.isoAlpha2\"\n [attr.data-highlighted]=\"i === activeIndex ? '' : null\"\n (click)=\"selectCountry(country)\"\n [class]=\"getItemClass(country, i)\">\n <div class=\"flex w-full items-center gap-3\">\n <img\n [src]=\"getFlagUrl(country.flag)\"\n class=\"h-4 w-6 flex-shrink-0 rounded-sm border border-border object-cover\"\n [alt]=\"country.name\" />\n <span class=\"flex-1 truncate text-sm\">{{ country.name }}</span>\n <span class=\"text-xs text-muted-foreground\">{{ country.dialCode }}</span>\n <i\n *ngIf=\"selectedCountry?.isoAlpha2 === country.isoAlpha2\"\n class=\"ri-check-line text-primary\"></i>\n </div>\n </div>\n <div\n *ngIf=\"shadowCountries.length === 0\"\n class=\"py-6 text-center text-sm text-muted-foreground\">\n No countries found.\n </div>\n </div>\n </div>\n </tolle-popover>\n\n <ng-container *ngIf=\"!disabled\">\n <p\n *ngIf=\"hint && !error\"\n class=\"px-1 text-xs text-muted-foreground transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideHintOnFocus\">\n {{ hint }}\n </p>\n <p\n *ngIf=\"error && errorMessage\"\n [id]=\"id + '-error'\"\n class=\"px-1 text-xs font-medium text-destructive\">\n {{ errorMessage }}\n </p>\n </ng-container>\n </div>\n `,\n})\nexport class CountrySelectorComponent implements OnInit, ControlValueAccessor {\n @Input() id = `country-selector-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label = '';\n @Input() hint = '';\n @Input() errorMessage = '';\n @Input() error = false;\n @Input() hideHintOnFocus = true;\n @Input() placeholder = 'Select country';\n @Input() class = '';\n @Input() disabled = false;\n @Input() readonly = false;\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() defaultCountryCode = 'GH';\n @Input() returnValue: 'object' | 'isoAlpha2' | 'dialCode' | 'name' = 'isoAlpha2';\n @Input() showName = true;\n @Input() externalFocused: boolean | undefined;\n\n @Output() onSelect = new EventEmitter<any>();\n @Output() onFocusChange = new EventEmitter<void>();\n @Output() onBlurChange = new EventEmitter<void>();\n\n @ViewChild('popover') popover!: PopoverComponent;\n @ViewChild('searchInput', { read: ElementRef }) searchInput?: ElementRef<HTMLElement>;\n\n private countryCodesService = inject(CountryCodesService);\n private sanitizer = inject(DomSanitizer);\n private cdr = inject(ChangeDetectorRef);\n\n value: any = null;\n selectedCountry: any = null;\n searchQuery = '';\n shadowCountries: any[] = [];\n isFocused = false;\n\n // Keyboard navigation (active-descendant) state over the filtered rows\n activeIndex = -1;\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n protected cn = cn;\n\n ngOnInit() {\n this.shadowCountries = this.countryCodesService.countries;\n if (this.defaultCountryCode && !this.value) {\n this.selectedCountry = this.countryCodesService.countries.find(\n c => c.isoAlpha2 === this.defaultCountryCode\n );\n if (this.selectedCountry) {\n this.value = this.getReturnValue(this.selectedCountry);\n }\n }\n }\n\n get computedTriggerClass() {\n const focused = this.externalFocused !== undefined ? this.externalFocused : this.isFocused;\n return cn(\n 'flex w-full items-center justify-between 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 // Rounded corners\n 'rounded-md',\n // Focus state\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 'shadow-none',\n this.error ? 'focus-visible:border-destructive/80' : 'focus-visible:border-primary/80',\n ],\n // External focus state (when controlled by parent)\n !(this.readonly || this.disabled) &&\n focused && [\n 'ring-4',\n 'ring-ring/30',\n 'ring-offset-0',\n 'shadow-none',\n this.error ? 'border-destructive/80' : 'border-primary/80',\n ],\n !(this.readonly || this.disabled) && 'hover:border-accent',\n // Error state\n this.error && [\n 'border-destructive',\n !(this.readonly || this.disabled) && focused && 'ring-destructive/30',\n ],\n this.disabled && 'cursor-not-allowed opacity-50 border-opacity-50',\n this.readonly && 'cursor-default border-dashed',\n this.class\n );\n }\n\n get computedLabelClass() {\n return cn(\n 'text-sm font-medium text-foreground leading-none transition-opacity duration-200',\n this.disabled && 'opacity-50'\n );\n }\n\n get iconClass() {\n return cn(\n 'ri-arrow-down-s-line text-muted-foreground ml-2 transition-transform duration-200',\n this.popover?.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 getItemClass(country: any, index: number) {\n const isSelected = this.selectedCountry?.isoAlpha2 === country.isoAlpha2;\n const isActive = index === this.activeIndex;\n return cn(\n 'flex items-center justify-between px-3 py-2 cursor-pointer transition-colors duration-150 rounded-sm w-full',\n isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50 text-foreground',\n // Active-descendant (keyboard) highlight\n isActive && 'bg-accent text-accent-foreground'\n );\n }\n\n get activeDescendantId(): string | null {\n return this.activeIndex >= 0 && this.activeIndex < this.shadowCountries.length\n ? `${this.id}-option-${this.activeIndex}`\n : null;\n }\n\n getFlagUrl(flagBase64: string): SafeResourceUrl {\n return this.sanitizer.bypassSecurityTrustResourceUrl('data:image/*;base64,' + flagBase64);\n }\n\n filterCountries(query: string) {\n const filter = (query || '').toLowerCase().trim();\n this.shadowCountries = this.countryCodesService.countries.filter(\n c =>\n c.name.toLowerCase().includes(filter) ||\n c.dialCode.includes(filter) ||\n c.isoAlpha2.toLowerCase().includes(filter)\n );\n // Re-anchor the active row to the first match after filtering.\n this.activeIndex = this.shadowCountries.length ? 0 : -1;\n this.syncActiveDescendant();\n }\n\n selectCountry(country: any) {\n this.selectedCountry = country;\n this.value = this.getReturnValue(country);\n this.onChange(this.value);\n this.onSelect.emit(country);\n this.popover.close();\n }\n\n private getReturnValue(country: any) {\n switch (this.returnValue) {\n case 'object':\n return country;\n case 'isoAlpha2':\n return country.isoAlpha2;\n case 'dialCode':\n return country.dialCode;\n case 'name':\n return country.name;\n default:\n return country.isoAlpha2;\n }\n }\n\n onFocus(): void {\n this.isFocused = true;\n this.onFocusChange.emit();\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n this.onBlurChange.emit();\n }\n\n onPopoverOpen() {\n // Anchor the active row to the currently selected country (or the first).\n const selIdx = this.selectedCountry\n ? this.shadowCountries.findIndex(c => c.isoAlpha2 === this.selectedCountry.isoAlpha2)\n : -1;\n this.activeIndex = selIdx >= 0 ? selIdx : (this.shadowCountries.length ? 0 : -1);\n setTimeout(() => {\n this.getSearchInputEl()?.focus();\n this.syncActiveDescendant();\n this.scrollActiveIntoView();\n }, 0);\n }\n\n onPopoverClose() {\n this.searchQuery = '';\n this.filterCountries('');\n this.activeIndex = -1;\n this.onBlur();\n }\n\n // ---- Keyboard navigation (WAI-ARIA listbox/combobox pattern) ----\n\n private getSearchInputEl(): HTMLInputElement | null {\n return this.searchInput?.nativeElement?.querySelector('input') ?? null;\n }\n\n private syncActiveDescendant() {\n const input = this.getSearchInputEl();\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 scrollActiveIntoView() {\n if (this.activeIndex < 0) return;\n document\n .getElementById(`${this.id}-option-${this.activeIndex}`)\n ?.scrollIntoView({ block: 'nearest' });\n }\n\n private moveActive(delta: number) {\n if (!this.shadowCountries.length) return;\n const next = this.activeIndex < 0\n ? (delta > 0 ? 0 : this.shadowCountries.length - 1)\n : this.activeIndex + delta;\n // Clamp (no wrap, matching shadcn).\n this.activeIndex = Math.max(0, Math.min(next, this.shadowCountries.length - 1));\n this.syncActiveDescendant();\n this.scrollActiveIntoView();\n }\n\n private selectActive() {\n const country = this.shadowCountries[this.activeIndex];\n if (!country) return;\n this.selectCountry(country); // closes the popover\n document.getElementById(this.id)?.focus();\n }\n\n onTriggerKeyDown(event: KeyboardEvent) {\n if (this.disabled || this.readonly) return;\n if (this.popover?.isOpen) return; // once open, focus moves to the search input\n const key = event.key;\n if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ' || key === 'Home' || key === 'End') {\n event.preventDefault();\n this.popover.open();\n } else if (key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {\n // Any printable character opens the popover (focus moves to the search box).\n this.popover.open();\n }\n }\n\n // Keydown while the search input is focused. Arrows/Enter/Escape drive the\n // list; other keys (incl. Home/End for caret) fall through to text editing.\n onSearchKeyDown(event: KeyboardEvent) {\n if (!this.popover?.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.popover.close();\n document.getElementById(this.id)?.focus();\n break;\n }\n }\n\n writeValue(value: any): void {\n this.value = value;\n if (value) {\n this.selectedCountry = this.countryCodesService.countries.find(c => {\n if (this.returnValue === 'object') return c.isoAlpha2 === value.isoAlpha2;\n if (this.returnValue === 'isoAlpha2') return c.isoAlpha2 === value;\n if (this.returnValue === 'dialCode') return c.dialCode === value;\n if (this.returnValue === 'name') return c.name === value;\n return false;\n });\n } else {\n this.selectedCountry = null;\n }\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"path": "utils/cn.ts",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
{
|
|
23
23
|
"path": "data-table.component.ts",
|
|
24
24
|
"target": "ui/data-table.component.ts",
|
|
25
|
-
"content": "import {\n Component, Input, OnInit, OnChanges, SimpleChanges,\n TemplateRef, ContentChildren, QueryList\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { PaginationComponent } from './pagination.component';\nimport { TolleCellDirective } from './tolle-cell.directive';\nimport { InputComponent } from './input.component';\nimport { PopoverComponent } from './popover.component';\nimport { PopoverContentComponent } from './popover-content.component';\nimport { CheckboxComponent } from './checkbox.component';\n\nexport interface TableColumn {\n key: string;\n label: string;\n sortable?: boolean;\n class?: string;\n}\n\n@Component({\n selector: 'tolle-data-table',\n standalone: true,\n styles: [`\n :host { display: block; width: 100%; height: 100%; }\n table { width: 100%; border-collapse: collapse; display: table; }\n thead { display: table-header-group; }\n tbody { display: table-row-group; }\n tr { display: table-row; }\n th, td { display: table-cell; }\n `],\n imports: [\n CommonModule,\n FormsModule,\n PaginationComponent,\n InputComponent,\n PopoverComponent,\n PopoverContentComponent,\n CheckboxComponent,\n ],\n template: `\n <div class=\"h-full flex flex-col w-full\">\n <div class=\"flex items-center justify-between py-2 flex-none\">\n <div *ngIf=\"searchable\" class=\"w-full max-w-sm\">\n <tolle-input\n [size]=\"size === 'lg' ? 'default' : 'sm'\"\n class=\"w-full\"\n aria-label=\"Filter records\"\n placeholder=\"Filter records...\"\n [(ngModel)]=\"searchTerm\"\n (ngModelChange)=\"onSearch()\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n </div>\n\n <tolle-popover *ngIf=\"allowColumnHiding && showSettings\" class=\"ml-auto\">\n <button trigger\n [class]=\"cn(\n 'ml-auto flex items-center gap-2 rounded-md border border-input bg-background px-3 hover:bg-accent hover:text-accent-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring',\n size === 'xs' ? 'h-7 text-xs' : 'h-9 text-sm'\n )\">\n <i class=\"ri-settings-3-line\"></i>\n View\n <i class=\"ri-arrow-down-s-line ml-1 opacity-50\"></i>\n </button>\n <tolle-popover-content class=\"w-56 p-0 bg-background border-border\">\n <div class=\"p-2 border-b border-border\">\n <p class=\"text-xs font-medium text-muted-foreground px-2\">Toggle Columns</p>\n </div>\n <div class=\"p-2 max-h-[300px] overflow-y-auto space-y-1\">\n <label *ngFor=\"let col of columns\" class=\"flex items-center gap-2 px-2 py-1.5 rounded-sm hover:bg-accent cursor-pointer text-sm transition-colors\">\n <tolle-checkbox [ngModel]=\"columnVisibility[col.key]\"\n (ngModelChange)=\"toggleColumn(col.key)\"/>\n <span>{{ col.label }}</span>\n </label>\n </div>\n </tolle-popover-content>\n </tolle-popover>\n </div>\n\n <div class=\"flex-1 min-h-0 rounded-md border border-border overflow-hidden relative w-full\">\n <div class=\"overflow-auto w-full h-full\" [style.max-height]=\"maxHeight\">\n\n <table class=\"w-full table-auto border-collapse\">\n <thead class=\"border-b border-border bg-background\">\n <tr>\n <th *ngIf=\"expandable\" scope=\"col\" [class]=\"cn('px-4', size === 'xs' ? 'w-[32px]' : 'w-[48px]', stickyHeader ? 'sticky top-0 z-10 bg-background' : '')\"></th>\n\n <th *ngFor=\"let col of activeColumns\"\n scope=\"col\"\n [attr.aria-sort]=\"sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : (sortDir === 'desc' ? 'descending' : 'none')) : 'none'\"\n [class]=\"cn('text-left font-medium text-foreground',headerPaddingClass,fontSizeClass, col.class, stickyHeader ? 'sticky top-0 z-10 bg-background shadow-sm' : '')\">\n\n <button *ngIf=\"col.sortable; else plainHeader\"\n type=\"button\"\n (click)=\"toggleSort(col.key)\"\n class=\"flex items-center gap-1 cursor-pointer select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm\">\n {{ col.label }}\n <i [class]=\"getSortIcon(col.key)\" aria-hidden=\"true\"></i>\n </button>\n\n <ng-template #plainHeader>\n {{ col.label }}\n </ng-template>\n </th>\n </tr>\n </thead>\n\n <tbody class=\"divide-y divide-border\">\n <ng-container *ngFor=\"let row of pagedData; let i = index\">\n <tr class=\"hover:bg-muted/50 transition-colors\">\n <td *ngIf=\"expandable\" class=\"px-4\">\n <button (click)=\"toggleRow(i)\"\n [attr.aria-label]=\"expandedRows.has(i) ? 'Collapse row' : 'Expand row'\"\n [attr.aria-expanded]=\"expandedRows.has(i)\"\n [class]=\"cn(\n 'rounded-md hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n size === 'xs' ? 'h-6 w-6' : 'h-8 w-8'\n )\">\n <i [class]=\"expandedRows.has(i)\n ? 'ri-arrow-down-s-line'\n : 'ri-arrow-right-s-line'\" aria-hidden=\"true\">\n </i>\n </button>\n </td>\n\n <td *ngFor=\"let col of activeColumns\"\n [class]=\"cn('align-middle',cellPaddingClass,fontSizeClass,col.class)\">\n\n <ng-container *ngIf=\"getTemplate(col.key) as cell; else defaultCell\">\n <ng-container\n *ngTemplateOutlet=\"cell.template; context: { $implicit: row[col.key], row: row }\">\n </ng-container>\n </ng-container>\n\n <ng-template #defaultCell>\n {{ row[col.key] }}\n </ng-template>\n </td>\n </tr>\n\n <tr *ngIf=\"expandedRows.has(i)\" class=\"bg-muted/10\">\n <td [attr.colspan]=\"activeColumns.length + (expandable ? 1 : 0)\"\n class=\"p-0\">\n <div class=\"p-6 border-t border-dashed border-border\">\n <ng-container *ngIf=\"expandedTemplate; else noExpanded\">\n <ng-container\n *ngTemplateOutlet=\"expandedTemplate; context: { row: row }\">\n </ng-container>\n </ng-container>\n\n <ng-template #noExpanded>\n <p class=\"text-xs text-muted-foreground italic\">\n No details available.\n </p>\n </ng-template>\n </div>\n </td>\n </tr>\n </ng-container>\n\n <tr *ngIf=\"pagedData.length === 0\">\n <td [attr.colspan]=\"activeColumns.length + (expandable ? 1 : 0)\"\n class=\"h-24 text-center text-muted-foreground\">\n No results found.\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"flex-none\">\n <tolle-pagination\n *ngIf=\"paginate && filteredData.length > 0\"\n [totalRecords]=\"filteredData.length\"\n [pageSizeOptions]=\"pageSizeOptions\"\n [currentPage]=\"currentPage\"\n [currentPageSize]=\"pageSize\"\n (onPageNumberChange)=\"handlePageChange($event)\"\n (onPageSizeChange)=\"handlePageSizeChange($event)\"\n ></tolle-pagination>\n </div>\n </div>\n `\n})\nexport class DataTableComponent implements OnInit, OnChanges {\n @Input() data: any[] = [];\n @Input() columns: TableColumn[] = [];\n @Input() searchable = true;\n @Input() paginate = true;\n @Input() pageSizeOptions: number[] = []\n @Input() pageSize = 10;\n @Input() expandable = false;\n\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() stickyHeader = false;\n @Input() maxHeight?: string;\n\n // --- Column Hiding Settings ---\n @Input() allowColumnHiding = true;\n @Input() showSettings = true;\n\n // Track visibility state\n columnVisibility: Record<string, boolean> = {};\n\n // Filters columns based on visibility state\n get activeColumns() {\n return this.columns.filter(col => this.columnVisibility[col.key]);\n }\n\n expandedRows = new Set<number>();\n @ContentChildren(TolleCellDirective) cellTemplates!: QueryList<TolleCellDirective>;\n @Input() expandedTemplate?: TemplateRef<any>;\n\n filteredData: any[] = [];\n pagedData: any[] = [];\n searchTerm = '';\n currentPage = 1;\n sortKey = '';\n sortDir: 'asc' | 'desc' | null = null;\n\n // Style Getters\n get cellPaddingClass(): string {\n switch (this.size) {\n case 'xs': return 'p-1 px-4';\n case 'sm': return 'p-2 px-4';\n case 'lg': return 'p-6 px-4';\n default: return 'p-4';\n }\n }\n\n get headerPaddingClass(): string {\n switch (this.size) {\n case 'xs': return 'h-7 px-4';\n case 'sm': return 'h-9 px-4';\n case 'lg': return 'h-14 px-4';\n default: return 'h-12 px-4';\n }\n }\n\n get fontSizeClass(): string {\n switch (this.size) {\n case 'xs': return 'text-[11px]';\n case 'sm': return 'text-xs';\n case 'lg': return 'text-base';\n default: return 'text-sm';\n }\n }\n\n protected cn = cn;\n\n ngOnInit() {\n this.initializeVisibility();\n this.refreshTable();\n }\n\n ngOnChanges(changes: SimpleChanges) {\n if (changes['data'] && !changes['data'].firstChange) {\n this.currentPage = 1; // Reset to page 1 when data changes\n this.refreshTable();\n }\n if (changes['columns']) {\n this.initializeVisibility();\n }\n }\n\n private initializeVisibility() {\n this.columns.forEach(col => {\n // Only set true if undefined, preserves user selection on other data refreshes\n if (this.columnVisibility[col.key] === undefined) {\n this.columnVisibility[col.key] = true;\n }\n });\n }\n\n refreshTable() {\n this.applySearch();\n this.applySort();\n this.updatePage();\n }\n onSearch() {\n this.currentPage = 1;\n this.refreshTable();\n }\n\n // Toggle function used by the checkbox in popover\n toggleColumn(key: string) {\n this.columnVisibility[key] = !this.columnVisibility[key];\n // No explicit refresh needed as the getter 'activeColumns' will automatically re-evaluate\n // when change detection runs after the click event.\n }\n\n private applySearch() {\n if (!this.searchTerm) {\n this.filteredData =\n [...this.data];\n return;\n }\n const q = this.searchTerm.toLowerCase();\n this.filteredData = this.data.filter(row =>\n Object.values(row).some(val => String(val).toLowerCase().includes(q))\n );\n }\n\n private applySort() {\n if (!this.sortKey || !this.sortDir) return;\n this.filteredData.sort((a, b) => {\n const valA = a[this.sortKey]; const valB = b[this.sortKey];\n if (valA < valB) return this.sortDir === 'asc' ? -1 : 1;\n if (valA > valB) return this.sortDir === 'asc' ? 1 : -1;\n return 0;\n });\n }\n\n handlePageChange(newPage: number) {\n this.currentPage = newPage;\n this.updatePage();\n }\n\n handlePageSizeChange(newSize: number) {\n if (newSize !== this.pageSize) {\n this.pageSize = newSize;\n this.currentPage = 1; // Reset to page 1 when size changes\n this.updatePage();\n }\n }\n\n updatePage() {\n if (!this.paginate) {\n this.pagedData = this.filteredData;\n return;\n }\n\n const start = (this.currentPage - 1) * this.pageSize;\n const end = start + this.pageSize;\n\n // Safety check if current page is out of bounds after filtering\n if (start >= this.filteredData.length && this.currentPage > 1) {\n this.currentPage = 1;\n this.updatePage();\n return;\n }\n\n this.pagedData = this.filteredData.slice(start, end);\n }\n\n toggleSort(key: string) {\n if (this.sortKey === key) {\n this.sortDir = this.sortDir === 'asc' ? 'desc' : this.sortDir === 'desc' ? null : 'asc';\n } else {\n this.sortKey = key;\n this.sortDir = 'asc';\n }\n this.currentPage = 1; // Reset to page 1 when sorting\n this.refreshTable();\n }\n\n getSortIcon(key: string) {\n if (this.sortKey !== key || !this.sortDir) return 'ri-expand-up-down-line opacity-20'; // Changed icon for inactive state\n return this.sortDir === 'asc' ? 'ri-arrow-up-line text-foreground' : 'ri-arrow-down-line text-foreground';\n }\n\n toggleRow(index: number) {\n if (this.expandedRows.has(index)) {\n this.expandedRows.delete(index);\n }\n else {\n this.expandedRows.add(index);\n }\n }\n\n getTemplate(key: string): TolleCellDirective | undefined {\n return this.cellTemplates?.find(t => t.name === key);\n }\n}\n"
|
|
25
|
+
"content": "import {\n Component, Input, OnInit, OnChanges, SimpleChanges,\n TemplateRef, ContentChildren, QueryList\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { PaginationComponent } from './pagination.component';\nimport { TolleCellDirective } from './tolle-cell.directive';\nimport { InputComponent } from './input.component';\nimport { PopoverComponent } from './popover.component';\nimport { PopoverContentComponent } from './popover-content.component';\nimport { CheckboxComponent } from './checkbox.component';\n\nexport interface TableColumn {\n key: string;\n label: string;\n sortable?: boolean;\n class?: string;\n}\n\n@Component({\n selector: 'tolle-data-table',\n standalone: true,\n styles: [`\n :host { display: block; width: 100%; height: 100%; }\n table { width: 100%; border-collapse: collapse; display: table; }\n thead { display: table-header-group; }\n tbody { display: table-row-group; }\n tr { display: table-row; }\n th, td { display: table-cell; }\n `],\n imports: [\n CommonModule,\n FormsModule,\n PaginationComponent,\n InputComponent,\n PopoverComponent,\n PopoverContentComponent,\n CheckboxComponent,\n ],\n template: `\n <div class=\"h-full flex flex-col w-full\">\n <div class=\"flex items-center justify-between py-2 flex-none\">\n <div *ngIf=\"searchable\" class=\"w-full max-w-sm\">\n <tolle-input\n [size]=\"size === 'lg' ? 'default' : 'sm'\"\n class=\"w-full\"\n aria-label=\"Filter records\"\n placeholder=\"Filter records...\"\n [(ngModel)]=\"searchTerm\"\n (ngModelChange)=\"onSearch()\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n </div>\n\n <tolle-popover *ngIf=\"allowColumnHiding && showSettings\" class=\"ml-auto\">\n <button trigger\n [class]=\"cn(\n 'ml-auto flex items-center gap-2 rounded-md border border-input bg-background px-3 hover:bg-accent hover:text-accent-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring',\n size === 'xs' ? 'h-7 text-xs' : 'h-9 text-sm'\n )\">\n <i class=\"ri-settings-3-line\"></i>\n View\n <i class=\"ri-arrow-down-s-line ml-1 opacity-50\"></i>\n </button>\n <tolle-popover-content class=\"w-56 p-0 bg-background border-border\">\n <div class=\"p-2 border-b border-border\">\n <p class=\"text-xs font-medium text-muted-foreground px-2\">Toggle Columns</p>\n </div>\n <div class=\"p-2 max-h-[300px] overflow-y-auto space-y-1\">\n <label *ngFor=\"let col of columns\" class=\"flex items-center gap-2 px-2 py-1.5 rounded-sm hover:bg-accent cursor-pointer text-sm transition-colors\">\n <tolle-checkbox [ngModel]=\"columnVisibility[col.key]\"\n (ngModelChange)=\"toggleColumn(col.key)\"/>\n <span>{{ col.label }}</span>\n </label>\n </div>\n </tolle-popover-content>\n </tolle-popover>\n </div>\n\n <div [class]=\"cn('flex-1 min-h-0 overflow-hidden relative w-full', bordered ? 'rounded-md border border-border' : '', tableBackground)\">\n <div class=\"overflow-auto w-full h-full\" [style.max-height]=\"maxHeight\">\n\n <table class=\"w-full table-auto border-collapse\">\n <thead [class]=\"cn('border-b border-border', resolvedHeaderBackground)\">\n <tr>\n <th *ngIf=\"expandable\" scope=\"col\" [class]=\"cn('px-4', size === 'xs' ? 'w-[32px]' : 'w-[48px]', stickyHeader ? 'sticky top-0 z-10 ' + resolvedHeaderBackground : '')\"></th>\n\n <th *ngFor=\"let col of activeColumns\"\n scope=\"col\"\n [attr.aria-sort]=\"sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : (sortDir === 'desc' ? 'descending' : 'none')) : 'none'\"\n [class]=\"cn('text-left font-medium text-foreground',headerPaddingClass,fontSizeClass, col.class, stickyHeader ? 'sticky top-0 z-10 shadow-sm ' + resolvedHeaderBackground : '')\">\n\n <button *ngIf=\"col.sortable; else plainHeader\"\n type=\"button\"\n (click)=\"toggleSort(col.key)\"\n class=\"flex items-center gap-1 cursor-pointer select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm\">\n {{ col.label }}\n <i [class]=\"getSortIcon(col.key)\" aria-hidden=\"true\"></i>\n </button>\n\n <ng-template #plainHeader>\n {{ col.label }}\n </ng-template>\n </th>\n </tr>\n </thead>\n\n <tbody class=\"divide-y divide-border\">\n <ng-container *ngFor=\"let row of pagedData; let i = index\">\n <tr [class]=\"cn('transition-colors', getRowBackground(row, i), 'hover:bg-muted/50')\">\n <td *ngIf=\"expandable\" class=\"px-4\">\n <button (click)=\"toggleRow(i)\"\n [attr.aria-label]=\"expandedRows.has(i) ? 'Collapse row' : 'Expand row'\"\n [attr.aria-expanded]=\"expandedRows.has(i)\"\n [class]=\"cn(\n 'rounded-md hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n size === 'xs' ? 'h-6 w-6' : 'h-8 w-8'\n )\">\n <i [class]=\"expandedRows.has(i)\n ? 'ri-arrow-down-s-line'\n : 'ri-arrow-right-s-line'\" aria-hidden=\"true\">\n </i>\n </button>\n </td>\n\n <td *ngFor=\"let col of activeColumns\"\n [class]=\"cn('align-middle',cellPaddingClass,fontSizeClass,col.class)\">\n\n <ng-container *ngIf=\"getTemplate(col.key) as cell; else defaultCell\">\n <ng-container\n *ngTemplateOutlet=\"cell.template; context: { $implicit: row[col.key], row: row }\">\n </ng-container>\n </ng-container>\n\n <ng-template #defaultCell>\n {{ row[col.key] }}\n </ng-template>\n </td>\n </tr>\n\n <tr *ngIf=\"expandedRows.has(i)\" class=\"bg-muted/10\">\n <td [attr.colspan]=\"activeColumns.length + (expandable ? 1 : 0)\"\n class=\"p-0\">\n <div class=\"p-6 border-t border-dashed border-border\">\n <ng-container *ngIf=\"expandedTemplate; else noExpanded\">\n <ng-container\n *ngTemplateOutlet=\"expandedTemplate; context: { row: row }\">\n </ng-container>\n </ng-container>\n\n <ng-template #noExpanded>\n <p class=\"text-xs text-muted-foreground italic\">\n No details available.\n </p>\n </ng-template>\n </div>\n </td>\n </tr>\n </ng-container>\n\n <tr *ngIf=\"pagedData.length === 0\">\n <td [attr.colspan]=\"activeColumns.length + (expandable ? 1 : 0)\"\n class=\"h-24 text-center text-muted-foreground\">\n No results found.\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"flex-none\">\n <tolle-pagination\n *ngIf=\"paginate && filteredData.length > 0\"\n [totalRecords]=\"filteredData.length\"\n [pageSizeOptions]=\"pageSizeOptions\"\n [currentPage]=\"currentPage\"\n [currentPageSize]=\"pageSize\"\n (onPageNumberChange)=\"handlePageChange($event)\"\n (onPageSizeChange)=\"handlePageSizeChange($event)\"\n ></tolle-pagination>\n </div>\n </div>\n `\n})\nexport class DataTableComponent implements OnInit, OnChanges {\n @Input() data: any[] = [];\n @Input() columns: TableColumn[] = [];\n @Input() searchable = true;\n @Input() paginate = true;\n @Input() pageSizeOptions: number[] = []\n @Input() pageSize = 10;\n @Input() expandable = false;\n\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() stickyHeader = false;\n @Input() maxHeight?: string;\n\n // Draws a physical border around the table itself (not the whole component).\n @Input() bordered = true;\n\n // --- Background Colors ---\n // All of these take Tailwind class strings (e.g. 'bg-card', 'bg-muted/40') so\n // they stay theme-token aware and follow dark mode, matching `col.class`.\n\n /** Background for the table surface, behind both header and rows. */\n @Input() tableBackground = '';\n\n /**\n * Background for the header row. Must be opaque when `stickyHeader` is on,\n * otherwise rows scroll visibly through the header — hence the default\n * `bg-background` rather than transparent.\n */\n @Input() headerBackground = 'bg-background';\n\n /** Alternating row backgrounds. Applied to odd rows only. */\n @Input() striped = false;\n\n /** Background applied to odd rows when `striped` is on. */\n @Input() stripeBackground = 'bg-muted/30';\n\n /**\n * Per-row background, resolved against the row's own data — e.g.\n * `[rowBackground]=\"r => r.overdue ? 'bg-destructive/10' : ''\"`.\n * Takes precedence over `striped`.\n */\n @Input() rowBackground?: (row: any, index: number) => string;\n\n /**\n * A sticky header with no background lets rows scroll visibly through it, so\n * fall back to an opaque default if a consumer clears `headerBackground`.\n */\n get resolvedHeaderBackground(): string {\n if (this.stickyHeader && !this.headerBackground) return 'bg-background';\n return this.headerBackground;\n }\n\n /** Resolves the background for a body row: per-row callback wins over striping. */\n getRowBackground(row: any, index: number): string {\n const custom = this.rowBackground?.(row, index);\n if (custom) return custom;\n return this.striped && index % 2 === 1 ? this.stripeBackground : '';\n }\n\n // --- Column Hiding Settings ---\n @Input() allowColumnHiding = true;\n @Input() showSettings = true;\n\n // Track visibility state\n columnVisibility: Record<string, boolean> = {};\n\n // Filters columns based on visibility state\n get activeColumns() {\n return this.columns.filter(col => this.columnVisibility[col.key]);\n }\n\n expandedRows = new Set<number>();\n @ContentChildren(TolleCellDirective) cellTemplates!: QueryList<TolleCellDirective>;\n @Input() expandedTemplate?: TemplateRef<any>;\n\n filteredData: any[] = [];\n pagedData: any[] = [];\n searchTerm = '';\n currentPage = 1;\n sortKey = '';\n sortDir: 'asc' | 'desc' | null = null;\n\n // Style Getters\n get cellPaddingClass(): string {\n switch (this.size) {\n case 'xs': return 'p-1 px-4';\n case 'sm': return 'p-2 px-4';\n case 'lg': return 'p-6 px-4';\n default: return 'p-4';\n }\n }\n\n get headerPaddingClass(): string {\n switch (this.size) {\n case 'xs': return 'h-7 px-4';\n case 'sm': return 'h-9 px-4';\n case 'lg': return 'h-14 px-4';\n default: return 'h-12 px-4';\n }\n }\n\n get fontSizeClass(): string {\n switch (this.size) {\n case 'xs': return 'text-[11px]';\n case 'sm': return 'text-xs';\n case 'lg': return 'text-base';\n default: return 'text-sm';\n }\n }\n\n protected cn = cn;\n\n ngOnInit() {\n this.initializeVisibility();\n this.refreshTable();\n }\n\n ngOnChanges(changes: SimpleChanges) {\n if (changes['data'] && !changes['data'].firstChange) {\n this.currentPage = 1; // Reset to page 1 when data changes\n this.refreshTable();\n }\n if (changes['columns']) {\n this.initializeVisibility();\n }\n }\n\n private initializeVisibility() {\n this.columns.forEach(col => {\n // Only set true if undefined, preserves user selection on other data refreshes\n if (this.columnVisibility[col.key] === undefined) {\n this.columnVisibility[col.key] = true;\n }\n });\n }\n\n refreshTable() {\n this.applySearch();\n this.applySort();\n this.updatePage();\n }\n onSearch() {\n this.currentPage = 1;\n this.refreshTable();\n }\n\n // Toggle function used by the checkbox in popover\n toggleColumn(key: string) {\n this.columnVisibility[key] = !this.columnVisibility[key];\n // No explicit refresh needed as the getter 'activeColumns' will automatically re-evaluate\n // when change detection runs after the click event.\n }\n\n private applySearch() {\n if (!this.searchTerm) {\n this.filteredData =\n [...this.data];\n return;\n }\n const q = this.searchTerm.toLowerCase();\n this.filteredData = this.data.filter(row =>\n Object.values(row).some(val => String(val).toLowerCase().includes(q))\n );\n }\n\n private applySort() {\n if (!this.sortKey || !this.sortDir) return;\n this.filteredData.sort((a, b) => {\n const valA = a[this.sortKey]; const valB = b[this.sortKey];\n if (valA < valB) return this.sortDir === 'asc' ? -1 : 1;\n if (valA > valB) return this.sortDir === 'asc' ? 1 : -1;\n return 0;\n });\n }\n\n handlePageChange(newPage: number) {\n this.currentPage = newPage;\n this.updatePage();\n }\n\n handlePageSizeChange(newSize: number) {\n if (newSize !== this.pageSize) {\n this.pageSize = newSize;\n this.currentPage = 1; // Reset to page 1 when size changes\n this.updatePage();\n }\n }\n\n updatePage() {\n if (!this.paginate) {\n this.pagedData = this.filteredData;\n return;\n }\n\n const start = (this.currentPage - 1) * this.pageSize;\n const end = start + this.pageSize;\n\n // Safety check if current page is out of bounds after filtering\n if (start >= this.filteredData.length && this.currentPage > 1) {\n this.currentPage = 1;\n this.updatePage();\n return;\n }\n\n this.pagedData = this.filteredData.slice(start, end);\n }\n\n toggleSort(key: string) {\n if (this.sortKey === key) {\n this.sortDir = this.sortDir === 'asc' ? 'desc' : this.sortDir === 'desc' ? null : 'asc';\n } else {\n this.sortKey = key;\n this.sortDir = 'asc';\n }\n this.currentPage = 1; // Reset to page 1 when sorting\n this.refreshTable();\n }\n\n getSortIcon(key: string) {\n if (this.sortKey !== key || !this.sortDir) return 'ri-expand-up-down-line opacity-20'; // Changed icon for inactive state\n return this.sortDir === 'asc' ? 'ri-arrow-up-line text-foreground' : 'ri-arrow-down-line text-foreground';\n }\n\n toggleRow(index: number) {\n if (this.expandedRows.has(index)) {\n this.expandedRows.delete(index);\n }\n else {\n this.expandedRows.add(index);\n }\n }\n\n getTemplate(key: string): TolleCellDirective | undefined {\n return this.cellTemplates?.find(t => t.name === key);\n }\n}\n"
|
|
26
26
|
},
|
|
27
27
|
{
|
|
28
28
|
"path": "select.service.ts",
|
|
@@ -2,21 +2,21 @@
|
|
|
2
2
|
"name": "date-picker",
|
|
3
3
|
"type": "registry:ui",
|
|
4
4
|
"title": "Date Picker",
|
|
5
|
-
"description": "Date Picker component.",
|
|
5
|
+
"description": "Date Picker component with size and invalid options.",
|
|
6
6
|
"category": "date-time",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"calendar"
|
|
9
|
-
"masked-input"
|
|
8
|
+
"calendar"
|
|
10
9
|
],
|
|
11
10
|
"dependencies": [
|
|
12
11
|
"@floating-ui/dom",
|
|
12
|
+
"class-variance-authority",
|
|
13
13
|
"date-fns"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
|
17
17
|
"path": "date-picker.component.ts",
|
|
18
18
|
"target": "ui/date-picker.component.ts",
|
|
19
|
-
"content": "import {\n Component, Input, forwardRef, ElementRef, ViewChild, ChangeDetectorRef, OnDestroy, HostListener\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { format, parse, isValid, startOfDay } from 'date-fns';\nimport { cn } from './utils/cn';\nimport { MaskedInputComponent } from './masked-input.component';\nimport { CalendarComponent, CalendarMode } from './calendar.component';\n\n@Component({\n selector: 'tolle-date-picker',\n standalone: true,\n imports: [CommonModule, FormsModule, MaskedInputComponent, CalendarComponent],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => DatePickerComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"relative w-full\" #triggerContainer>\n <tolle-masked-input\n #maskInput\n [mask]=\"getMask()\"\n [placeholder]=\"getPlaceholder()\"\n [disabled]=\"disabled\"\n [(ngModel)]=\"inputValue\"\n (ngModelChange)=\"onInputChange($event)\"\n [class]=\"cn(class)\">\n <div suffix class=\"flex items-center gap-1.5\">\n <button\n *ngIf=\"value && !disabled && showClear\"\n type=\"button\"\n aria-label=\"Clear date\"\n (click)=\"clear($event)\"\n class=\"ri-close-line cursor-pointer text-muted-foreground hover:text-foreground transition-colors\"\n tabindex=\"-1\"\n ></button>\n\n <button\n type=\"button\"\n #trigger\n aria-label=\"Open calendar\"\n aria-haspopup=\"dialog\"\n [attr.aria-expanded]=\"isOpen\"\n (click)=\"togglePopover($event)\"\n [disabled]=\"disabled\"\n [class]=\"cn(\n 'cursor-pointer text-muted-foreground transition-colors',\n 'ri-calendar-line'\n )\"\n tabindex=\"-1\"\n ></button>\n </div>\n </tolle-masked-input>\n\n <div\n #popover\n *ngIf=\"isOpen\"\n role=\"dialog\"\n class=\"fixed z-[50]\"\n style=\"visibility: hidden; top: 0; left: 0;\"\n >\n <tolle-calendar class=\"shadow-lg\"\n [(ngModel)]=\"value\"\n (ngModelChange)=\"onCalendarChange($event)\"\n [mode]=\"mode\"\n [disablePastDates]=\"disablePastDates\"\n [minDate]=\"minDate\"\n [maxDate]=\"maxDate\"\n [showQuickActions]=\"showQuickActions\"\n [formatMonthFn]=\"formatMonthFn\"\n [formatYearFn]=\"formatYearFn\"\n ></tolle-calendar>\n </div>\n </div>\n `\n})\nexport class DatePickerComponent implements ControlValueAccessor, OnDestroy {\n @Input() placeholder = 'MM/DD/YYYY';\n @Input() disabled = false;\n @Input() class = '';\n @Input() disablePastDates = false;\n @Input() showClear = true;\n @Input() showQuickActions = true;\n @Input() minDate?: Date;\n @Input() maxDate?: Date;\n @Input() mode: CalendarMode = 'date';\n @Input() formatMonthFn?: (date: Date) => string;\n @Input() formatYearFn?: (date: Date) => string;\n\n // Format functions for display\n @Input() displayFormat?: (date: Date, mode: CalendarMode) => string;\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n\n value: Date | null = null;\n inputValue: string = '';\n isOpen = false;\n cleanupAutoUpdate?: () => void;\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 constructor(private cdr: ChangeDetectorRef) {}\n\n getMask(): string {\n switch (this.mode) {\n case 'date': return '00/00/0000';\n case 'month': return '00/0000';\n case 'year': return '0000';\n default: return '00/00/0000';\n }\n }\n\n getPlaceholder(): string {\n switch (this.mode) {\n case 'date': return 'MM/DD/YYYY';\n case 'month': return 'MM/YYYY';\n case 'year': return 'YYYY';\n default: return 'MM/DD/YYYY';\n }\n }\n\n getFormatString(): string {\n switch (this.mode) {\n case 'date': return 'MM/dd/yyyy';\n case 'month': return 'MM/yyyy';\n case 'year': return 'yyyy';\n default: return 'MM/dd/yyyy';\n }\n }\n\n formatDate(date: Date): string {\n if (this.displayFormat) {\n return this.displayFormat(date, this.mode);\n }\n\n switch (this.mode) {\n case 'date': return format(date, 'MM/dd/yyyy');\n case 'month': return format(date, 'MM/yyyy');\n case 'year': return format(date, 'yyyy');\n default: return format(date, 'MM/dd/yyyy');\n }\n }\n\n parseDate(str: string): Date | null {\n try {\n const parsed = parse(str, this.getFormatString(), new Date());\n return isValid(parsed) ? startOfDay(parsed) : null;\n } catch {\n return null;\n }\n }\n\n onInputChange(str: string) {\n const expectedLength = this.getFormatString().replace(/[^0]/g, '').length;\n\n if (str?.length === expectedLength) {\n const parsed = this.parseDate(str);\n if (parsed) {\n this.value = parsed;\n this.onChange(this.value);\n }\n } else if (!str) {\n this.value = null;\n this.onChange(null);\n }\n }\n\n onCalendarChange(date: Date | null) {\n this.value = date;\n if (date) {\n this.inputValue = this.formatDate(date);\n } else {\n this.inputValue = '';\n }\n this.onChange(this.value);\n this.close();\n }\n\n togglePopover(event: MouseEvent) {\n event.stopPropagation();\n if (this.disabled) return;\n this.isOpen ? this.close() : this.open();\n }\n\n @HostListener('keydown.escape') onEscape() { if (this.isOpen) this.close(); }\n\n open() {\n if (this.disabled) return;\n this.isOpen = true;\n // Render the popover synchronously so its DOM node + @ViewChild('popover') exist\n // before we position it. Otherwise, with zone eventCoalescing (Angular defers change\n // detection to the next animation frame), updatePosition() can run first, find no\n // popover, and the calendar stays hidden — regardless of where the picker is used.\n this.cdr.detectChanges();\n this.trigger.nativeElement.focus();\n requestAnimationFrame(() => {\n this.updatePosition();\n document.addEventListener('mousedown', this._outsideClickHandler);\n });\n }\n\n close() {\n this.isOpen = false;\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n clear(event: MouseEvent) {\n event.stopPropagation();\n this.value = null;\n this.inputValue = '';\n this.onChange(null);\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 ],\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 ngOnDestroy() {\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n // CVA Implementation\n onChange: any = () => {};\n onTouched: any = () => {};\n\n writeValue(val: any): void {\n if (val) {\n const date = new Date(val);\n if (isValid(date)) {\n this.value = startOfDay(date);\n this.inputValue = this.formatDate(this.value);\n }\n } else {\n this.value = null;\n this.inputValue = '';\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 { this.disabled = isDisabled; }\n protected cn = cn;\n}\n"
|
|
19
|
+
"content": "import { Component, Input, Output, EventEmitter, forwardRef, ElementRef, ViewChild, ChangeDetectorRef, ChangeDetectionStrategy, OnDestroy, HostListener, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { format, isValid, startOfDay } from 'date-fns';\nimport { cn } from './utils/cn';\nimport { CalendarComponent, CalendarMode } from './calendar.component';\n\n/**\n * The trigger button — same shape as `tolle-date-time-picker`'s, so the two\n * controls read as siblings: a bordered field that opens a floating panel,\n * not a typeable text input.\n */\nconst datePickerTriggerVariants = cva(\n 'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background text-foreground shadow-sm transition-colors ' +\n 'focus:outline-none focus-visible:border-primary/80 focus-visible:ring-4 focus-visible:ring-ring/30 ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-8 px-2 text-xs',\n sm: 'h-9 px-3 text-sm',\n default: 'h-10 px-3 text-sm',\n lg: 'h-11 px-4 text-base',\n },\n invalid: {\n true: 'border-destructive focus-visible:border-destructive/80 focus-visible:ring-destructive/30',\n false: '',\n },\n },\n defaultVariants: { size: 'default', invalid: false },\n }\n);\n\n/** Variant props for the date picker. */\nexport type DatePickerProps = VariantProps<typeof datePickerTriggerVariants>;\n\n/**\n * A button trigger paired with a `tolle-calendar` in a floating panel.\n *\n * The value is a `Date` normalised to the start of its day — a calendar date\n * has no time, and carrying one would make two selections of the same day\n * compare unequal. `mode` switches the whole control between picking a day, a\n * month or a year, taking the calendar's own quick-actions row with it.\n *\n * Implements `ControlValueAccessor`, so it works with `ngModel` and reactive\n * forms. Use `tolle-date-range-picker` for a span and `tolle-date-time-picker`\n * when a time is needed alongside — this trigger is deliberately the same\n * shape as that one, not a typeable masked field.\n */\n@Component({\n selector: 'tolle-date-picker',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule, CalendarComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => DatePickerComponent),\n multi: true\n }\n ],\n template: `\n <button\n #trigger\n type=\"button\"\n aria-haspopup=\"dialog\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n >\n <span [class]=\"cn('truncate', displayValue ? 'text-foreground' : 'text-muted-foreground')\">\n {{ displayValue || placeholder }}\n </span>\n <i class=\"ri-calendar-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n </button>\n\n <div\n #popover\n *ngIf=\"isOpen\"\n role=\"dialog\"\n [attr.aria-label]=\"'Choose a date'\"\n class=\"fixed left-0 top-0 z-[50]\"\n style=\"visibility: hidden\"\n >\n <div class=\"rounded-md border border-border bg-popover text-popover-foreground shadow-md\">\n <div class=\"p-3\">\n <tolle-calendar\n [bordered]=\"false\"\n [showQuickActions]=\"false\"\n [(ngModel)]=\"value\"\n (ngModelChange)=\"onCalendarChange($event)\"\n [mode]=\"mode\"\n [disablePastDates]=\"disablePastDates\"\n [minDate]=\"minDate\"\n [maxDate]=\"maxDate\"\n [formatMonthFn]=\"formatMonthFn\"\n [formatYearFn]=\"formatYearFn\"\n ></tolle-calendar>\n </div>\n\n <div\n *ngIf=\"showQuickActions\"\n class=\"flex items-center justify-between gap-2 border-t border-border px-3 py-2\"\n >\n <button *ngIf=\"showClear\" type=\"button\" [class]=\"footerButtonClass\" (click)=\"clearFromFooter()\">\n Clear\n </button>\n <button\n type=\"button\"\n [class]=\"cn(footerButtonClass, 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground', !showClear && 'ml-auto')\"\n (click)=\"done()\"\n >\n Done\n </button>\n </div>\n </div>\n </div>\n `\n})\nexport class DatePickerComponent implements OnChanges, ControlValueAccessor, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Text shown on the trigger when no date is selected. @default 'Pick a date' */\n @Input() placeholder = 'Pick a date';\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). @default '' */\n @Input() class = '';\n /** Disables every day before today in the calendar. @default false */\n @Input() disablePastDates = false;\n /** Shows the footer's Clear button. @default true */\n @Input() showClear = true;\n /** Shows the panel's Clear / Done footer. @default true */\n @Input() showQuickActions = true;\n /** Earliest selectable date; earlier days are disabled. */\n @Input() minDate?: Date;\n /** Latest selectable date; later days are disabled. */\n @Input() maxDate?: Date;\n /** Whether the control picks a day, a month or a year. @default 'date' */\n @Input() mode: CalendarMode = 'date';\n /** Overrides how a month is labelled in the calendar header. */\n @Input() formatMonthFn?: (date: Date) => string;\n /** Overrides how a year is labelled in the calendar header. */\n @Input() formatYearFn?: (date: Date) => string;\n /** Overrides how the selected date is rendered on the trigger. */\n @Input() displayFormat?: (date: Date, mode: CalendarMode) => string;\n /** Height and text size of the trigger. @default 'default' */\n @Input() size: DatePickerProps['size'] = 'default';\n /** Applies the destructive border and focus ring. @default false */\n @Input() invalid = false;\n /** Accessible name when there is no associated visible label. @default '' */\n @Input() ariaLabel = '';\n\n /** Emitted with the chosen date, or `null` when the value is cleared. */\n @Output() valueChange = new EventEmitter<Date | null>();\n /** Emitted when the calendar panel opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the calendar panel closes. */\n @Output() closed = new EventEmitter<void>();\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n\n value: Date | null = null;\n isOpen = false;\n cleanupAutoUpdate?: () => void;\n\n /** Footer buttons, styled exactly as the date-time picker's. */\n readonly footerButtonClass = cn(\n 'rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors',\n 'hover:bg-accent hover:text-accent-foreground',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'\n );\n\n /** Footer Clear — empties the value and leaves the panel open, like the reference. */\n clearFromFooter(): void {\n this.value = null;\n this.emit();\n this.cdr.markForCheck();\n }\n\n /** Footer Done — commits what is already selected and closes. */\n done(): void {\n this.close();\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 constructor(private cdr: ChangeDetectorRef) {}\n\n get computedTriggerClass(): string {\n return cn(datePickerTriggerVariants({ size: this.size, invalid: this.invalid }), this.class);\n }\n\n get displayValue(): string {\n return this.value ? this.formatDate(this.value) : '';\n }\n\n formatDate(date: Date): string {\n if (this.displayFormat) {\n return this.displayFormat(date, this.mode);\n }\n\n switch (this.mode) {\n case 'date': return format(date, 'MMM d, yyyy');\n case 'month': return format(date, 'MMMM yyyy');\n case 'year': return format(date, 'yyyy');\n default: return format(date, 'MMM d, yyyy');\n }\n }\n\n onCalendarChange(date: Date | null) {\n this.value = date;\n this.emit();\n this.close();\n this.cdr.markForCheck();\n }\n\n toggle(): void {\n this.isOpen ? this.close() : this.open();\n }\n\n onTriggerKeyDown(event: KeyboardEvent): void {\n if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key) && !this.isOpen) {\n event.preventDefault();\n this.open();\n }\n }\n\n @HostListener('keydown.escape') onEscape() { if (this.isOpen) this.close(); }\n\n open() {\n if (this.disabled || this.isOpen) return;\n this.isOpen = true;\n this.opened.emit();\n // Render the popover synchronously so its DOM node + @ViewChild('popover') exist\n // before we position it. Otherwise, with zone eventCoalescing (Angular defers change\n // detection to the next animation frame), updatePosition() can run first, find no\n // popover, and the calendar stays hidden — regardless of where the picker is used.\n this.cdr.detectChanges();\n this.trigger.nativeElement.focus();\n requestAnimationFrame(() => {\n this.updatePosition();\n document.addEventListener('pointerdown', this._outsideClickHandler, true);\n });\n }\n\n close() {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.closed.emit();\n this.teardown();\n // The outside-click handler is a raw document listener, so nothing else\n // marks this OnPush view dirty when it fires.\n this.cdr.markForCheck();\n }\n\n /** Reports the current value to the form and to `valueChange`. */\n private emit() {\n this.onChange(this.value);\n this.valueChange.emit(this.value);\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 ],\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 private teardown() {\n if (this.cleanupAutoUpdate) {\n this.cleanupAutoUpdate();\n this.cleanupAutoUpdate = undefined;\n }\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n }\n\n ngOnDestroy() {\n this.teardown();\n }\n\n // CVA Implementation\n onChange: any = () => {};\n onTouched: any = () => {};\n\n writeValue(val: any): void {\n if (val) {\n const date = new Date(val);\n this.value = isValid(date) ? startOfDay(date) : null;\n } else {\n this.value = null;\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 protected cn = cn;\n}\n"
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
"path": "utils/cn.ts",
|
|
@@ -2,21 +2,21 @@
|
|
|
2
2
|
"name": "date-range-picker",
|
|
3
3
|
"type": "registry:ui",
|
|
4
4
|
"title": "Date Range Picker",
|
|
5
|
-
"description": "Date Range Picker component.",
|
|
5
|
+
"description": "Date Range Picker component with size and invalid options.",
|
|
6
6
|
"category": "date-time",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"input",
|
|
9
8
|
"range-calendar"
|
|
10
9
|
],
|
|
11
10
|
"dependencies": [
|
|
12
11
|
"@floating-ui/dom",
|
|
12
|
+
"class-variance-authority",
|
|
13
13
|
"date-fns"
|
|
14
14
|
],
|
|
15
15
|
"files": [
|
|
16
16
|
{
|
|
17
17
|
"path": "date-range-picker.component.ts",
|
|
18
18
|
"target": "ui/date-range-picker.component.ts",
|
|
19
|
-
"content": "import {\n Component, Input, forwardRef, ElementRef, ViewChild, ChangeDetectorRef, OnDestroy, HostListener\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 { cn } from './utils/cn';\nimport { RangeCalendarComponent } from './range-calendar.component';\nimport { format } from 'date-fns';\nimport { InputComponent } from './input.component';\nimport { DateRange } from './types/date-range';\n\n@Component({\n selector: 'tolle-date-range-picker',\n standalone: true,\n imports: [CommonModule, FormsModule, RangeCalendarComponent, InputComponent],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => DateRangePickerComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"relative w-full\">\n <tolle-input\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [ngModel]=\"displayValue\"\n [class]=\"class\"\n >\n <div suffix class=\"flex items-center gap-1.5\">\n <button\n type=\"button\"\n tabindex=\"-1\"\n aria-label=\"Clear date range\"\n *ngIf=\"(value.start || value.end) && !disabled\"\n (click)=\"clear($event)\"\n class=\"ri-close-line cursor-pointer text-muted-foreground hover:text-foreground transition-colors\"\n ></button>\n\n <button\n #trigger\n type=\"button\"\n (click)=\"togglePopover($event)\"\n aria-label=\"Open calendar\"\n aria-haspopup=\"dialog\"\n [attr.aria-expanded]=\"isOpen\"\n class=\"ri-calendar-line cursor-pointer text-muted-foreground hover:text-primary transition-colors bg-transparent border-0 p-0\"\n ></button>\n </div>\n </tolle-input>\n\n <div\n #popover\n *ngIf=\"isOpen\"\n role=\"dialog\"\n class=\"fixed z-50\"\n style=\"visibility: hidden;\"\n >\n <tolle-range-calendar class=\"shadow-lg\"\n [ngModel]=\"value\"\n (rangeSelect)=\"onCalendarSelect($event)\"\n [disablePastDates]=\"disablePastDates\"\n [numberOfMonths]=\"numberOfMonths\"\n ></tolle-range-calendar>\n </div>\n </div>\n `\n})\nexport class DateRangePickerComponent implements ControlValueAccessor, OnDestroy {\n @Input() disabled = false;\n @Input() placeholder = 'Pick a date range';\n @Input() class = '';\n @Input() disablePastDates = false;\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n /** Consecutive months shown in the popover (e.g. `2` for a two-month picker). @default 1 */\n @Input() numberOfMonths = 1;\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n\n value: DateRange = { start: null, end: null };\n isOpen = false;\n cleanupAutoUpdate?: () => void;\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 constructor(private cdr: ChangeDetectorRef) {}\n\n get displayValue(): string {\n if (!this.value.start) return '';\n\n const startStr = format(this.value.start, 'MMM dd, yyyy');\n if (!this.value.end) return startStr;\n\n const endStr = format(this.value.end, 'MMM dd, yyyy');\n return `${startStr} - ${endStr}`;\n }\n\n onCalendarSelect(range: DateRange) {\n this.value = range;\n this.onChange(this.value);\n\n // Close only if range is complete\n if (range.start && range.end) {\n setTimeout(() => this.close(), 150);\n }\n }\n\n togglePopover(event: MouseEvent) {\n event.stopPropagation();\n if (this.disabled) return;\n this.isOpen ? this.close() : this.open();\n }\n\n @HostListener('keydown.escape') onEscape() { if (this.isOpen) this.close(); }\n\n open() {\n this.isOpen = true;\n // Render the popover synchronously so its DOM node + @ViewChild('popover') exist\n // before we position it. Otherwise, with zone eventCoalescing (Angular defers change\n // detection to the next animation frame), updatePosition() can run first, find no\n // popover, and the calendar stays hidden — regardless of where the picker is used.\n this.cdr.detectChanges();\n if (this.trigger?.nativeElement) {\n this.trigger.nativeElement.focus();\n }\n const schedule = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : (fn: FrameRequestCallback) => setTimeout(fn, 0);\n schedule(() => {\n this.updatePosition();\n document.addEventListener('mousedown', this._outsideClickHandler);\n });\n }\n\n close() {\n this.isOpen = false;\n if (this.cleanupAutoUpdate) {\n this.cleanupAutoUpdate();\n this.cleanupAutoUpdate = undefined;\n }\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n clear(event: MouseEvent) {\n event.stopPropagation();\n this.value = { start: null, end: null };\n this.onChange(this.value);\n this.cdr.markForCheck();\n }\n\n // --- Floating UI Positioning with Fixed Strategy ---\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 placement: 'bottom-end',\n strategy: 'fixed', // Use fixed to escape column layout\n middleware: [\n offset(4),\n flip({\n fallbackAxisSideDirection: 'start',\n padding: 8\n }),\n shift({ padding: 8 }),\n size({\n apply({ rects, elements, availableHeight }) {\n // Constrain popover to available space\n Object.assign(elements.floating.style, {\n maxHeight: `${Math.min(400, availableHeight)}px`,\n minWidth: `${Math.max(rects.reference.width, 320)}px`, // Calendar minimum width\n });\n }\n })\n ],\n }).then(({ x, y, placement }) => {\n Object.assign(this.popover.nativeElement.style, {\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n\n // Optional: Add placement class for styling\n this.popover.nativeElement.classList.remove('calendar-top', 'calendar-bottom');\n if (placement.includes('top')) {\n this.popover.nativeElement.classList.add('calendar-top');\n } else {\n this.popover.nativeElement.classList.add('calendar-bottom');\n }\n });\n }\n );\n }\n\n ngOnDestroy() {\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n @HostListener('window:resize')\n onWindowResize() {\n if (this.isOpen) {\n this.close(); // Close on resize for simplicity\n }\n }\n\n @HostListener('window:scroll')\n onWindowScroll() {\n // Floating-UI's autoUpdate handles scroll repositioning\n }\n\n // --- Control Value Accessor ---\n onChange: any = () => {};\n onTouched: any = () => {};\n\n writeValue(val: DateRange | null): void {\n if (val) {\n this.value = { ...val };\n } else {\n this.value = { start: null, end: null };\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 { this.disabled = isDisabled; }\n\n protected cn = cn;\n}\n"
|
|
19
|
+
"content": "import { Component, Input, Output, EventEmitter, forwardRef, ElementRef, ViewChild, ChangeDetectorRef, ChangeDetectionStrategy, OnDestroy, HostListener, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate, size as sizeMiddleware } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { RangeCalendarComponent } from './range-calendar.component';\nimport { format } from 'date-fns';\nimport { DateRange } from './types/date-range';\n\n/**\n * The trigger button — same shape as `tolle-date-time-picker`'s and\n * `tolle-date-picker`'s, so all three read as one family: a bordered field\n * that opens a floating panel, not a typeable text input.\n */\nconst dateRangePickerTriggerVariants = cva(\n 'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background text-foreground shadow-sm transition-colors ' +\n 'focus:outline-none focus-visible:border-primary/80 focus-visible:ring-4 focus-visible:ring-ring/30 ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-8 px-2 text-xs',\n sm: 'h-9 px-3 text-sm',\n default: 'h-10 px-3 text-sm',\n lg: 'h-11 px-4 text-base',\n },\n invalid: {\n true: 'border-destructive focus-visible:border-destructive/80 focus-visible:ring-destructive/30',\n false: '',\n },\n },\n defaultVariants: { size: 'default', invalid: false },\n }\n);\n\n/** Variant props for the date range picker. */\nexport type DateRangePickerProps = VariantProps<typeof dateRangePickerTriggerVariants>;\n\n/**\n * A button trigger paired with a `tolle-range-calendar` in a floating panel,\n * for picking a start and an end date.\n *\n * The value is a `DateRange` — `{ start, end }` — and a half-made range is a\n * legal intermediate state: picking a start emits `{ start, end: null }` and\n * leaves the panel open, so a consumer can react to the first click. The panel\n * only closes once both ends exist.\n *\n * Implements `ControlValueAccessor`, so it works with `ngModel` and reactive\n * forms. Use `tolle-date-picker` for a single date — this trigger is\n * deliberately the same shape as that one and as `tolle-date-time-picker`.\n */\n@Component({\n selector: 'tolle-date-range-picker',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, FormsModule, RangeCalendarComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => DateRangePickerComponent),\n multi: true\n }\n ],\n template: `\n <button\n #trigger\n type=\"button\"\n aria-haspopup=\"dialog\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n >\n <span [class]=\"cn('truncate', displayValue ? 'text-foreground' : 'text-muted-foreground')\">\n {{ displayValue || placeholder }}\n </span>\n <i class=\"ri-calendar-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n </button>\n\n <div\n #popover\n *ngIf=\"isOpen\"\n role=\"dialog\"\n [attr.aria-label]=\"'Choose a date range'\"\n class=\"fixed left-0 top-0 z-50\"\n style=\"visibility: hidden\"\n >\n <div class=\"rounded-md border border-border bg-popover text-popover-foreground shadow-md\">\n <div class=\"p-3\">\n <tolle-range-calendar\n [bordered]=\"false\"\n [ngModel]=\"value\"\n (rangeSelect)=\"onCalendarSelect($event)\"\n [disablePastDates]=\"disablePastDates\"\n [numberOfMonths]=\"numberOfMonths\"\n ></tolle-range-calendar>\n </div>\n\n <div class=\"flex items-center justify-between gap-2 border-t border-border px-3 py-2\">\n <button type=\"button\" [class]=\"footerButtonClass\" (click)=\"clearFromFooter()\">\n Clear\n </button>\n <button\n type=\"button\"\n [class]=\"cn(footerButtonClass, 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground')\"\n (click)=\"done()\"\n >\n Done\n </button>\n </div>\n </div>\n </div>\n `\n})\nexport class DateRangePickerComponent implements OnChanges, ControlValueAccessor, OnDestroy {\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Text shown on the trigger when no range is selected. @default 'Pick a date range' */\n @Input() placeholder = 'Pick a date range';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). @default '' */\n @Input() class = '';\n /** Disables every day before today in the calendar. @default false */\n @Input() disablePastDates = false;\n /** Height and text size of the trigger. @default 'default' */\n @Input() size: DateRangePickerProps['size'] = 'default';\n /** Consecutive months shown in the popover (e.g. `2` for a two-month picker). @default 1 */\n @Input() numberOfMonths = 1;\n /** Applies the destructive border and focus ring. @default false */\n @Input() invalid = false;\n /** Accessible name when there is no associated visible label. @default '' */\n @Input() ariaLabel = '';\n\n /** Emitted with the selected range, including the half-made `{ start, end: null }` state. */\n @Output() valueChange = new EventEmitter<DateRange>();\n /** Emitted when the calendar panel opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the calendar panel closes. */\n @Output() closed = new EventEmitter<void>();\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n\n value: DateRange = { start: null, end: null };\n isOpen = false;\n cleanupAutoUpdate?: () => void;\n\n /** Footer buttons, styled exactly as the date-time picker's. */\n readonly footerButtonClass = cn(\n 'rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors',\n 'hover:bg-accent hover:text-accent-foreground',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'\n );\n\n /** Footer Clear — empties the range and leaves the panel open, like the reference. */\n clearFromFooter(): void {\n this.value = { start: null, end: null };\n this.emit();\n this.cdr.markForCheck();\n }\n\n /** Footer Done — commits what is already selected and closes. */\n done(): void {\n this.close();\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 constructor(private cdr: ChangeDetectorRef) {}\n\n get computedTriggerClass(): string {\n return cn(dateRangePickerTriggerVariants({ size: this.size, invalid: this.invalid }), this.class);\n }\n\n get displayValue(): string {\n if (!this.value.start) return '';\n\n const startStr = format(this.value.start, 'MMM d, yyyy');\n if (!this.value.end) return startStr;\n\n const endStr = format(this.value.end, 'MMM d, yyyy');\n return startStr + ' - ' + endStr;\n }\n\n onCalendarSelect(range: DateRange) {\n this.value = range;\n this.emit();\n\n // Close only if range is complete\n if (range.start && range.end) {\n setTimeout(() => this.close(), 150);\n }\n this.cdr.markForCheck();\n }\n\n toggle(): void {\n this.isOpen ? this.close() : this.open();\n }\n\n onTriggerKeyDown(event: KeyboardEvent): void {\n if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key) && !this.isOpen) {\n event.preventDefault();\n this.open();\n }\n }\n\n @HostListener('keydown.escape') onEscape() { if (this.isOpen) this.close(); }\n\n open() {\n if (this.disabled || this.isOpen) return;\n this.isOpen = true;\n this.opened.emit();\n // Render the popover synchronously so its DOM node + @ViewChild('popover') exist\n // before we position it. Otherwise, with zone eventCoalescing (Angular defers change\n // detection to the next animation frame), updatePosition() can run first, find no\n // popover, and the calendar stays hidden — regardless of where the picker is used.\n this.cdr.detectChanges();\n if (this.trigger?.nativeElement) {\n this.trigger.nativeElement.focus();\n }\n const schedule = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : (fn: FrameRequestCallback) => setTimeout(fn, 0);\n schedule(() => {\n this.updatePosition();\n document.addEventListener('pointerdown', this._outsideClickHandler, true);\n });\n }\n\n close() {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.closed.emit();\n this.teardown();\n // Both the outside-click handler and the deferred close after a completed\n // range run outside any template binding, so nothing else marks this OnPush\n // view dirty.\n this.cdr.markForCheck();\n }\n\n /** Reports the current range to the form and to `valueChange`. */\n private emit() {\n this.onChange(this.value);\n this.valueChange.emit(this.value);\n }\n\n // --- Floating UI Positioning with Fixed Strategy ---\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 placement: 'bottom-start',\n strategy: 'fixed', // Use fixed to escape column layout\n middleware: [\n offset(4),\n flip({\n fallbackAxisSideDirection: 'start',\n padding: 8\n }),\n shift({ padding: 8 }),\n sizeMiddleware({\n apply({ rects, elements, availableHeight }) {\n // Constrain popover to available space\n Object.assign(elements.floating.style, {\n maxHeight: `${Math.min(400, availableHeight)}px`,\n minWidth: `${Math.max(rects.reference.width, 320)}px`, // Calendar minimum width\n });\n }\n })\n ],\n }).then(({ x, y, placement }) => {\n Object.assign(this.popover.nativeElement.style, {\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n\n // Optional: Add placement class for styling\n this.popover.nativeElement.classList.remove('calendar-top', 'calendar-bottom');\n if (placement.includes('top')) {\n this.popover.nativeElement.classList.add('calendar-top');\n } else {\n this.popover.nativeElement.classList.add('calendar-bottom');\n }\n });\n }\n );\n }\n\n private teardown() {\n if (this.cleanupAutoUpdate) {\n this.cleanupAutoUpdate();\n this.cleanupAutoUpdate = undefined;\n }\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n }\n\n ngOnDestroy() {\n this.teardown();\n }\n\n @HostListener('window:resize')\n onWindowResize() {\n if (this.isOpen) {\n this.close(); // Close on resize for simplicity\n }\n }\n\n @HostListener('window:scroll')\n onWindowScroll() {\n // Floating-UI's autoUpdate handles scroll repositioning\n }\n\n // --- Control Value Accessor ---\n onChange: any = () => {};\n onTouched: any = () => {};\n\n writeValue(val: DateRange | null): void {\n if (val) {\n this.value = { ...val };\n } else {\n this.value = { start: null, end: null };\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 protected cn = cn;\n}\n"
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
"path": "types/date-range.ts",
|